Compare commits

...

39 Commits

Author SHA1 Message Date
aikaterna
7685c4d5d5 [V3] Bump version to 3.0.0b17 (#1929) 2018-07-12 04:21:38 +02:00
aikaterna
e701ec9617 [V3 Audio] More aggressive empty disconnect (#1925) 2018-07-12 04:14:58 +02:00
Michael H
6c1ee096a1 [V3 permissions] command usage consistency (#1905)
* make the default rule settings consistent with the rest

* update docs to match new behavior
2018-07-12 04:09:28 +02:00
aikaterna
2df282222f [V3 Audio] Fix for playlist queue duplicates (#1890)
* [V3 Audio] Fix for playlist queue duplicates

And some sanitizing of playlist names.

* [V3 Audio] Playlist naming standardization

Enforced single-word playlist name across all playlist commands, removed A-Z 0-9 name standardization. [p]playlist delete will still accept playlist names with quotes as there should be a way to remove already-existing playlists with spaces in their name.

* [V3 Audio] Black formatting
2018-07-12 04:01:11 +02:00
El Laggron
43c7bd48c7 [V3 Mod] Fix wrong reason used for modlog (#1842) 2018-07-12 03:54:32 +02:00
aikaterna
86579068d9 [V3 Admin] Clean up help strings (#1906) 2018-07-12 03:49:37 +02:00
Michael H
8e6ab9aa35 [V3 reports] Display user discrim (#1913)
* [V3 reports] Display user discrim

* Update reports.py

minor change for clarity

* format pass
2018-07-12 03:38:07 +02:00
palmtree5
77566a887a [V3 Warnings] changes to the warnings cog (#1867)
* [V3 Warnings] clarify text on entering commands

* Fix up action commands and allow for no command on both add and remove

* Notify warned user when they receive a warning + disallow warning and unwarning self

* Add myself to COOWNERS for the warnings cog
2018-07-12 03:29:22 +02:00
Redjumpman
9d0eca1914 Update economy.py (#1898) 2018-07-12 03:22:29 +02:00
Michael H
79a3164d9d [V3] Mod/admin role logic corrections (#1914)
* [V3] Mod/admin role logic corrections

* Update mod.py

* Update mod.py
2018-07-12 03:17:44 +02:00
aikaterna
eb73e48192 [V3 Audio] Respect voice channel permissions (#1878)
* [V3 Audio] Respect voice channel permissions

* [V3 Audio] Respect the user limit

* [V3 Audio] Exemption for channels with no limit
2018-07-12 03:11:51 +02:00
Eslyium
cd6af7f185 [V3 Streams] Rewording Responses/Docstrings (#1837)
* [V3 Streams] Rewording Responses/Docstrings

w/ Black if I did this right

* Readding strings
2018-07-12 03:07:15 +02:00
Michael H
3d6020b9cf [V3/permissions] Performance improvements (#1885)
* basic caching layer

* bit more work, now with an upper size to the cache

* cache fix

* smarter cache invalidation

* One more cache case

* Put in a bare skeleton of something else still needed

* more logic handling improvements

* more work, still not finished

* mass-resolve is done in theory, but needs testing

* small bugfixin + comments

* add note about before/after hooks

* LRU-dict fix

* when making comments about optimizations, provide historical context

* fmt pass
2018-07-12 02:56:08 +02:00
Michael H
461f03aac0 [V3 Context] Aliasing Colour to color (#1916)
* [V3 Context] Aliasing Colour to color

We should stay consistent here and keep the aliasing from upstream, even when we extend this.

* fmt pass
2018-07-12 02:50:59 +02:00
Michael H
35149f8837 [V3] DM usage fixes (#1919)
* DM usage fixes

* ...

* ...

* ...

* ok, formatting...
2018-07-12 02:46:14 +02:00
Toby Harradine
c0d01f32a6 [V3] Use our own checks instead of discord.py's (#1861)
* [V3] Use our own checks instead of discord.py's

* Remove bot.has_permissions checks too
2018-07-12 02:33:39 +02:00
Toby Harradine
83a0459b6a [V3] Remove all mentions of Python 3.5 (#1896)
We don't speak of him any more.
2018-07-12 02:23:18 +02:00
Toby Harradine
50f6dcef2f [V3] Stop tmp dir showing up (#1895) 2018-07-12 02:17:54 +02:00
Eslyium
5c514fd663 [V3 Reports] Rewording Responses/Docstrings (#1860)
* [v3 Reports] Rewording Responses/Docstrings

* Add the old name for [p]reportset toggle as alias

Also made some lines a bit shorter

* A few more

* Fix typo

* Clarity in [p]report docstring
2018-07-12 02:02:41 +02:00
Michael H
1c2196f78f autohelp changes. (#1836) 2018-07-12 01:23:18 +02:00
Michael H
43cc3c40f3 [V3] use configured color in all places (#1918)
* use configured color

* help formatter too
2018-07-12 00:50:46 +02:00
Michael H
7a6a4cf59d typo fix (#1917) 2018-07-08 22:18:53 +02:00
Michael H
3bcf375204 [V3] Fix duplicate help on ignore (#1862) 2018-06-25 22:00:56 +10:00
Michael H
a175bdc1c7 [V3 Cleanup] Fix cleaning up too many messages (#1864)
Resolves #1863
2018-06-25 21:45:43 +10:00
El Laggron
b557b437a3 [V3] More badges in README (#1879)
* [V3 README] More badges

* Fixed destination
2018-06-25 21:38:32 +10:00
Michael H
d1f0b59b5d [V3] Verify checks for command groups (#1882) 2018-06-25 21:33:36 +10:00
aikaterna
3ece3a1f2b [V3 Audio] Fix for not saving via playlist create (#1889) 2018-06-25 21:20:28 +10:00
aikaterna
1f1a85de18 [V3 Audio] Add checking for valid file on upload (#1891) 2018-06-25 20:32:46 +10:00
aikaterna
e08c9dafa6 [V3 Economy] Payday leaderboard clarification (#1892)
When [p]payday is used with a global bank, the number place shown on the message is relative to the global leaderboard. Since [p]leaderboard shows the server leaderboard by default, this can be confusing on a global bank as payday will most likely report a different place number than the user's server leaderboard does.
2018-06-25 20:17:44 +10:00
El Laggron
ad27607ccc [V3] --token and --no-instance flags (#1872)
* Ability to run Red with token without instance

* --no-instance flag

* Reformatted cli with black

* Fix changes requested by @Tobotimus

- Use "system reboot" to be clearer
- save_default_config renamed to create_temp_config
- More documentation for the create_temp_config function

* Update create_temp_config call

* Fix up imports
2018-06-25 19:25:23 +10:00
El Laggron
c1bcca4432 [V3 Downloader] Allow use of prefix in install message (#1869)
* [V3 Downloader] Allow to use the prefix in install msg

* Update docs

* Let's do the same for repo addition

* Fix indent

* Use replace instead of format

* Update docs
2018-06-25 12:42:47 +10:00
El Laggron
9f2ed694ce [V3 Launcher] Show version in main menu (#1810)
* [V3 Launcher] Show version in main menu

* Fix syntax

* Fix typo

* Make text underlined

* Show if red is outdated
2018-06-23 12:50:34 +10:00
Will
edadd8f2fd [V3 Config] Fix for unnecessary writes on context mgr exit (#1859)
* Fix for #1857

* And copy it so we don't have mutability issues

* Add a test
2018-06-23 12:15:22 +10:00
Will
afa08713e0 [V3] Make pytest fixtures available as a plugin (#1858)
* Move all fixtures to pytest plugin folder

* Add core dunder all

* Update other dunder all's

* Black reformat
2018-06-23 11:33:06 +10:00
Michael H
d23620727e [V3 Mod] Userinfo past nicks/names (#1865)
* [V3 Mod] Userinfo past nicks/names

Prevents trying to do a string replace on a NoneType

* address root cause as well

* remove extra whitespace that got pasted in from web editor
2018-06-20 19:49:01 +02:00
Redjumpman
b456c6ad3b [V3 Config] Fixed set_raw example in docstring (#1876)
fixed doc string
2018-06-20 11:27:36 +10:00
Tobotimus
0298b53803 [V3 JSON] Drivers deepcopy input/output data (#1855)
* [V3 JSON] Return deepcopy in JSON driver

* Add a test

* foo not bar

* Add a test for setting and then mutating

* Resolve issue for setting and mutating as well

* Reformat
2018-06-11 12:31:01 -04:00
Michael H
bfd6e4af3f [V3 Report] Remove outdated reference to tunnel.close() (#1856) 2018-06-11 22:16:41 +10:00
Michael H
31612aae4a [V3 Mod] Fix [p]modset used without a subcommand (#1854) 2018-06-11 22:02:29 +10:00
64 changed files with 1081 additions and 583 deletions

2
.github/CODEOWNERS vendored
View File

@@ -24,6 +24,7 @@ redbot/core/utils/mod.py @palmtree5
redbot/core/utils/data_converter.py @mikeshardmind
redbot/core/utils/antispam.py @mikeshardmind
redbot/core/utils/tunnel.py @mikeshardmind
redbot/core/utils/caching.py @mikeshardmind
# Cogs
redbot/cogs/admin/* @tekulvw
@@ -44,6 +45,7 @@ redbot/cogs/trivia/* @Tobotimus
redbot/cogs/dataconverter/* @mikeshardmind
redbot/cogs/reports/* @mikeshardmind
redbot/cogs/permissions/* @mikeshardmind
redbot/cogs/warnings/* @palmtree5
# Docs
docs/* @tekulvw @palmtree5

View File

@@ -31,7 +31,7 @@ We love receiving contributions from our community. Any assistance you can provi
# 2. Ground Rules
We've made a point to use [ZenHub](https://www.zenhub.com/) (a plugin for GitHub) as our main source of collaboration and coordination. Your experience contributing to Red will be greatly improved if you go get that plugin.
1. Ensure cross compatibility for Windows, Mac OS and Linux.
2. Ensure all Python features used in contributions exist and work in Python 3.5 and above.
2. Ensure all Python features used in contributions exist and work in Python 3.6 and above.
3. Create new tests for code you add or bugs you fix. It helps us help you by making sure we don't accidentally break anything :grinning:
4. Create any issues for new features you'd like to implement and explain why this feature is useful to everyone and not just you personally.
5. Don't add new cogs unless specifically given approval in an issue discussing said cog idea.
@@ -79,7 +79,7 @@ Note: If you haven't used `pipenv` before but are comfortable with virtualenvs,
We've recently started using [tox](https://github.com/tox-dev/tox) to run all of our tests. It's extremely simple to use, and if you followed the previous section correctly, it is already installed to your virtual environment.
Currently, tox does the following, creating its own virtual environments for each stage:
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on both python 3.5 and 3.6 (test environments `py35` and `py36` respectively)
- Runs all of our unit tests with [pytest](https://github.com/pytest-dev/pytest) on python 3.6 (test environment `py36`)
- Ensures documentation builds without warnings, and all hyperlinks have a valid destination (test environment `docs`)
- Ensures that the code meets our style guide with [black](https://github.com/ambv/black) (test environment `style`)
@@ -94,8 +94,6 @@ Our style checker of choice, [black](https://github.com/ambv/black), actually ha
Use the command `black --help` to see how to use this tool. The full style guide is explained in detail on [black's GitHub repository](https://github.com/ambv/black). **There is one exception to this**, however, which is that we set the line length to 99, instead of black's default 88. When using `black` on the command line, simply use it like so: `black -l 99 <src>`.
Note: Python 3.6+ is required to install and run black. If you installed your development environment with Python 3.5, black will not be installed.
### 4.4 Make
You may have noticed we have a `Makefile` and a `make.bat` in the top-level directory. For now, you can do two things with them:
1. `make reformat`: Reformat all python files in the project with Black

View File

@@ -11,9 +11,21 @@
.. class:: center
.. image:: https://discordapp.com/api/guilds/133049272517001216/embed.png
:target: https://discord.gg/red
:alt: Discord server
.. image:: https://api.travis-ci.org/Cog-Creators/Red-DiscordBot.svg?branch=V3/develop
:target: https://travis-ci.org/Cog-Creators/Red-DiscordBot
:alt: Travis CI status
.. image:: https://readthedocs.org/projects/red-discordbot/badge/?version=v3-develop
:target: http://red-discordbot.readthedocs.io/en/v3-develop/?badge=v3-develop
:alt: Documentation Status
.. image:: https://img.shields.io/badge/discord-py-blue.svg
:target: https://github.com/Rapptz/discord.py
:alt: discord.py
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/ambv/black
@@ -26,6 +38,10 @@
.. image:: https://img.shields.io/badge/Support-Red!-orange.svg
:target: https://www.patreon.com/Red_Devs
:alt: Patreon
.. image:: https://img.shields.io/badge/PRs-welcome-brightgreen.svg
:target: http://makeapullrequest.com
:alt: PRs open
==========
Overview

View File

@@ -89,8 +89,10 @@ Locking Audio to specific voice channel(s) as a serverowner or admin:
.. code-block:: none
[p]permissions setguilddefault Audio deny
[p]permissions addguildrule allow Audio [voice channel ID or name]
[p]permissions setguilddefault deny play
[p]permissions setguilddefault deny "playlist start"
[p]permissions addguildrule allow play [voice channel ID or name]
[p]permissions addguildrule allow "playlist start" [voice channel ID or name]
Allowing extra roles to use cleanup

View File

@@ -20,6 +20,9 @@ Keys common to both repo and cog info.json (case sensitive)
- ``install_msg`` (string) - The message that gets displayed when a cog
is installed or a repo is added
.. tip:: You can use the ``[p]`` key in your string to use the prefix
used for installing.
- ``short`` (string) - A short description of the cog or repo. For cogs, this info
is displayed when a user executes ``!cog list``

View File

@@ -17,11 +17,10 @@ you in the process.
Getting started
---------------
To start off, be sure that you have installed Python 3.5 or higher (if you
are on Windows, stick with 3.5). Open a terminal or command prompt and type
To start off, be sure that you have installed Python 3.6 or higher. Open a terminal or command prompt and type
:code:`pip install --process-dependency-links -U git+https://github.com/Cog-Creators/Red-DiscordBot@V3/develop#egg=redbot[test]`
(note that if you get an error with this, try again but put :code:`python -m` in front of the command
This will install the latest version of V3.
This will install the latest version of V3.
--------------------
Setting up a package

View File

@@ -12,12 +12,3 @@ if discord.version_info.major < 1:
" >= 1.0.0."
)
sys.exit(1)
if sys.version_info < (3, 6, 0):
print(Back.RED + "[DEPRECATION WARNING]")
print(
Back.RED + "You are currently running Python 3.5."
" Support for Python 3.5 will end with the release of beta 16."
" Please update your environment to Python 3.6 as soon as possible to avoid"
" any interruptions after the beta 16 release."
)

View File

@@ -6,7 +6,7 @@ import sys
import discord
from redbot.core.bot import Red, ExitCodes
from redbot.core.cog_manager import CogManagerUI
from redbot.core.data_manager import load_basic_configuration, config_file
from redbot.core.data_manager import create_temp_config, load_basic_configuration, config_file
from redbot.core.json_io import JsonIO
from redbot.core.global_checks import init_global_checks
from redbot.core.events import init_events
@@ -106,9 +106,17 @@ def main():
elif cli_flags.version:
print(description)
sys.exit(0)
elif not cli_flags.instance_name:
elif not cli_flags.instance_name and not cli_flags.no_instance:
print("Error: No instance name was provided!")
sys.exit(1)
if cli_flags.no_instance:
print(
"\033[1m"
"Warning: The data will be placed in a temporary folder and removed on next system reboot."
"\033[0m"
)
cli_flags.instance_name = "temporary_red"
create_temp_config()
load_basic_configuration(cli_flags.instance_name)
log, sentry_log = init_loggers(cli_flags)
red = Red(cli_flags=cli_flags, description=description, pm_help=None)
@@ -122,6 +130,8 @@ def main():
tmp_data = {}
loop.run_until_complete(_get_prefix_and_token(red, tmp_data))
token = os.environ.get("RED_TOKEN", tmp_data["token"])
if cli_flags.token:
token = cli_flags.token
prefix = cli_flags.prefix or tmp_data["prefix"]
if not (token and prefix):
if cli_flags.no_prompt is False:

View File

@@ -127,8 +127,8 @@ class Admin:
self, ctx: commands.Context, rolename: discord.Role, *, user: MemberDefaultAuthor = None
):
"""
Adds a role to a user. If user is left blank it defaults to the
author of the command.
Adds a role to a user.
If user is left blank it defaults to the author of the command.
"""
if user is None:
user = ctx.author
@@ -145,8 +145,8 @@ class Admin:
self, ctx: commands.Context, rolename: discord.Role, *, user: MemberDefaultAuthor = None
):
"""
Removes a role from a user. If user is left blank it defaults to the
author of the command.
Removes a role from a user.
If user is left blank it defaults to the author of the command.
"""
if user is None:
user = ctx.author
@@ -156,7 +156,7 @@ class Admin:
else:
await self.complain(ctx, USER_HIERARCHY_ISSUE)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(manage_roles=True)
async def editrole(self, ctx: commands.Context):
@@ -291,11 +291,11 @@ class Admin:
# noinspection PyTypeChecker
return valid_roles
@commands.guild_only()
@commands.group(invoke_without_command=True)
async def selfrole(self, ctx: commands.Context, *, selfrole: SelfRole):
"""
Add a role to yourself that server admins have configured as
user settable.
Add a role to yourself that server admins have configured as user settable.
NOTE: The role is case sensitive!
"""
@@ -313,7 +313,7 @@ class Admin:
await self._removerole(ctx, ctx.author, selfrole)
@selfrole.command(name="add")
@commands.has_permissions(manage_roles=True)
@checks.admin_or_permissions(manage_roles=True)
async def selfrole_add(self, ctx: commands.Context, *, role: discord.Role):
"""
Add a role to the list of available selfroles.
@@ -327,7 +327,7 @@ class Admin:
await ctx.send("The selfroles list has been successfully modified.")
@selfrole.command(name="delete")
@commands.has_permissions(manage_roles=True)
@checks.admin_or_permissions(manage_roles=True)
async def selfrole_delete(self, ctx: commands.Context, *, role: SelfRole):
"""
Removes a role from the list of available selfroles.

View File

@@ -170,13 +170,13 @@ class Alias:
new_message.content = "{}{} {}".format(prefix, alias.command, args)
await self.bot.process_commands(new_message)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
async def alias(self, ctx: commands.Context):
"""Manage per-server aliases for commands"""
pass
@alias.group(name="global", autohelp=True)
@alias.group(name="global")
async def global_(self, ctx: commands.Context):
"""
Manage global aliases.

View File

@@ -15,7 +15,7 @@ from .manager import shutdown_lavalink_server
_ = Translator("Audio", __file__)
__version__ = "0.0.6b"
__version__ = "0.0.6c"
__author__ = ["aikaterna", "billy/bollo/ati"]
@@ -166,7 +166,7 @@ class Audio:
await message_channel.send(embed=embed)
await player.skip()
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
async def audioset(self, ctx):
"""Music configuration options."""
@@ -586,6 +586,12 @@ class Audio:
shuffle = await self.config.guild(ctx.guild).shuffle()
if not self._player_check(ctx):
try:
if not ctx.author.voice.channel.permissions_for(
ctx.me
).connect == True or self._userlimit(ctx.author.voice.channel):
return await self._embed_msg(
ctx, "I don't have permission to connect to your channel."
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
@@ -657,7 +663,7 @@ class Audio:
await player.play()
await ctx.send(embed=embed)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
async def playlist(self, ctx):
"""Playlist configuration options."""
@@ -708,8 +714,10 @@ class Audio:
return await self._embed_msg(
ctx, "Playlist name already exists, try again with a different name."
)
playlist_name = playlist_name.split(" ")[0].strip('"')
playlist_list = self._to_json(ctx, None, None)
playlists[playlist_name] = playlist_list
async with self.config.guild(ctx.guild).playlists() as playlists:
playlists[playlist_name] = playlist_list
await self._embed_msg(ctx, "Empty playlist {} created.".format(playlist_name))
@playlist.command(name="delete")
@@ -768,6 +776,7 @@ class Audio:
)
await ctx.send(embed=embed)
@commands.cooldown(1, 15, discord.ext.commands.BucketType.guild)
@playlist.command(name="queue")
async def _playlist_queue(self, ctx, playlist_name=None):
"""Save the queue to a playlist."""
@@ -794,11 +803,11 @@ class Audio:
await self._embed_msg(ctx, "Please enter a name for this playlist.")
def check(m):
return m.author == ctx.author
return m.author == ctx.author and not m.content.startswith(ctx.prefix)
try:
playlist_name_msg = await ctx.bot.wait_for("message", timeout=15.0, check=check)
playlist_name = str(playlist_name_msg.content)
playlist_name = playlist_name_msg.content.split(" ")[0].strip('"')
if len(playlist_name) > 20:
return await self._embed_msg(ctx, "Try the command again with a shorter name.")
if playlist_name in playlists:
@@ -809,11 +818,12 @@ class Audio:
return await self._embed_msg(ctx, "No playlist name entered, try again later.")
playlist_list = self._to_json(ctx, None, tracklist)
async with self.config.guild(ctx.guild).playlists() as playlists:
playlist_name = playlist_name.split(" ")[0].strip('"')
playlists[playlist_name] = playlist_list
await self._embed_msg(
ctx,
"Playlist {} saved from current queue: {} tracks added.".format(
playlist_name, len(tracklist)
playlist_name.split(" ")[0].strip('"'), len(tracklist)
),
)
@@ -861,6 +871,7 @@ class Audio:
playlist_list = self._to_json(ctx, playlist_url, tracklist)
if tracklist is not None:
async with self.config.guild(ctx.guild).playlists() as playlists:
playlist_name = playlist_name.split(" ")[0].strip('"')
playlists[playlist_name] = playlist_list
return await self._embed_msg(
ctx,
@@ -919,8 +930,11 @@ class Audio:
file_suffix = file_url.rsplit(".", 1)[1]
if file_suffix != "txt":
return await self._embed_msg(ctx, "Only playlist files can be uploaded.")
async with self.session.request("GET", file_url) as r:
v2_playlist = await r.json(content_type="text/plain")
try:
async with self.session.request("GET", file_url) as r:
v2_playlist = await r.json(content_type="text/plain")
except UnicodeDecodeError:
return await self._embed_msg(ctx, "Not a valid playlist file.")
try:
v2_playlist_url = v2_playlist["link"]
except KeyError:
@@ -989,6 +1003,12 @@ class Audio:
return False
if not self._player_check(ctx):
try:
if not ctx.author.voice.channel.permissions_for(
ctx.me
).connect == True or self._userlimit(ctx.author.voice.channel):
return await self._embed_msg(
ctx, "I don't have permission to connect to your channel."
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
@@ -1206,6 +1226,12 @@ class Audio:
"""
if not self._player_check(ctx):
try:
if not ctx.author.voice.channel.permissions_for(
ctx.me
).connect == True or self._userlimit(ctx.author.voice.channel):
return await self._embed_msg(
ctx, "I don't have permission to connect to your channel."
)
await lavalink.connect(ctx.author.voice.channel)
player = lavalink.get_player(ctx.guild.id)
player.store("connect", datetime.datetime.utcnow())
@@ -1618,7 +1644,7 @@ class Audio:
embed.set_footer(text="Nothing playing.")
await ctx.send(embed=embed)
@commands.group(aliases=["llset"], autohelp=True)
@commands.group(aliases=["llset"])
@commands.guild_only()
@checks.is_owner()
async def llsetup(self, ctx):
@@ -1750,7 +1776,7 @@ class Audio:
if server.id not in stop_times:
stop_times[server.id] = None
if p.current is None and [self.bot.user] == p.channel.members:
if [self.bot.user] == p.channel.members:
if stop_times[server.id] is None:
stop_times[server.id] = int(time.time())
@@ -1883,6 +1909,15 @@ class Audio:
track_obj[key] = value
return track_obj
@staticmethod
def _userlimit(channel):
if channel.user_limit == 0:
return False
if channel.user_limit < len(channel.members) + 1:
return True
else:
return False
async def on_voice_state_update(self, member, before, after):
if after.channel != before.channel:
try:

View File

@@ -17,13 +17,15 @@ def check_global_setting_guildowner():
async def pred(ctx: commands.Context):
author = ctx.author
if await ctx.bot.is_owner(author):
return True
if not await bank.is_global():
if not isinstance(ctx.channel, discord.abc.GuildChannel):
return False
if await ctx.bot.is_owner(author):
return True
permissions = ctx.channel.permissions_for(author)
return author == ctx.guild.owner or permissions.administrator
else:
return await ctx.bot.is_owner(author)
return commands.check(pred)
@@ -36,15 +38,17 @@ def check_global_setting_admin():
async def pred(ctx: commands.Context):
author = ctx.author
if await ctx.bot.is_owner(author):
return True
if not await bank.is_global():
if not isinstance(ctx.channel, discord.abc.GuildChannel):
return False
if await ctx.bot.is_owner(author):
return True
permissions = ctx.channel.permissions_for(author)
is_guild_owner = author == ctx.guild.owner
admin_role = await ctx.bot.db.guild(ctx.guild).admin_role()
return admin_role in author.roles or is_guild_owner or permissions.manage_guild
else:
return await ctx.bot.is_owner(author)
return commands.check(pred)
@@ -58,8 +62,9 @@ class Bank:
# SECTION commands
@commands.group(autohelp=True)
@check_global_setting_guildowner()
@checks.guildowner_or_permissions(administrator=True)
@commands.group(autohelp=True)
async def bankset(self, ctx: commands.Context):
"""Base command for bank settings"""
if ctx.invoked_subcommand is None:

View File

@@ -71,11 +71,11 @@ class Cleanup:
to_delete = []
too_old = False
while not too_old and len(to_delete) - 1 < number:
while not too_old and len(to_delete) < number:
message = None
async for message in channel.history(limit=limit, before=before, after=after):
if (
(not number or len(to_delete) - 1 < number)
(not number or len(to_delete) < number)
and check(message)
and (ctx.message.created_at - message.created_at).days < 14
and (delete_pinned or not message.pinned)
@@ -92,7 +92,7 @@ class Cleanup:
before = message
return to_delete
@commands.group(autohelp=True)
@commands.group()
@checks.mod_or_permissions(manage_messages=True)
async def cleanup(self, ctx: commands.Context):
"""Deletes messages."""
@@ -100,7 +100,6 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def text(
self, ctx: commands.Context, text: str, number: int, delete_pinned: bool = False
):
@@ -112,6 +111,10 @@ class Cleanup:
Remember to use double quotes."""
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send("I need the Manage Messages permission to do this.")
return
author = ctx.author
is_bot = self.bot.user.bot
@@ -150,7 +153,6 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def user(
self, ctx: commands.Context, user: str, number: int, delete_pinned: bool = False
):
@@ -159,6 +161,10 @@ class Cleanup:
Examples:
cleanup user @\u200bTwentysix 2
cleanup user Red 6"""
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send("I need the Manage Messages permission to do this.")
return
member = None
try:
@@ -171,7 +177,6 @@ class Cleanup:
else:
_id = member.id
channel = ctx.channel
author = ctx.author
is_bot = self.bot.user.bot
@@ -212,7 +217,6 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def after(self, ctx: commands.Context, message_id: int, delete_pinned: bool = False):
"""Deletes all messages after specified message.
@@ -224,6 +228,9 @@ class Cleanup:
"""
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send("I need the Manage Messages permission to do this.")
return
author = ctx.author
is_bot = self.bot.user.bot
@@ -250,7 +257,6 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def messages(self, ctx: commands.Context, number: int, delete_pinned: bool = False):
"""Deletes last X messages.
@@ -258,6 +264,9 @@ class Cleanup:
cleanup messages 26"""
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send("I need the Manage Messages permission to do this.")
return
author = ctx.author
is_bot = self.bot.user.bot
@@ -284,11 +293,13 @@ class Cleanup:
@cleanup.command(name="bot")
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def cleanup_bot(self, ctx: commands.Context, number: int, delete_pinned: bool = False):
"""Cleans up command messages and messages from the bot."""
channel = ctx.message.channel
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send("I need the Manage Messages permission to do this.")
return
author = ctx.message.author
is_bot = self.bot.user.bot
@@ -412,7 +423,7 @@ class Cleanup:
if author == self.bot.user:
to_delete.append(ctx.message)
if channel.name:
if ctx.guild:
channel_name = "channel " + channel.name
else:
channel_name = str(channel)

View File

@@ -141,13 +141,13 @@ class CustomCommands:
self.config.register_guild(commands={})
self.commandobj = CommandObj(config=self.config, bot=self.bot)
@commands.group(aliases=["cc"], autohelp=True)
@commands.group(aliases=["cc"])
@commands.guild_only()
async def customcom(self, ctx: commands.Context):
"""Custom commands management"""
pass
@customcom.group(name="add", autohelp=True)
@customcom.group(name="add")
@checks.mod_or_permissions(administrator=True)
async def cc_add(self, ctx: commands.Context):
"""

View File

@@ -204,7 +204,7 @@ class Downloader:
)
)
@commands.group(autohelp=True)
@commands.group()
@checks.is_owner()
async def repo(self, ctx):
"""
@@ -234,7 +234,7 @@ class Downloader:
else:
await ctx.send(_("Repo `{}` successfully added.").format(name))
if repo.install_msg is not None:
await ctx.send(repo.install_msg)
await ctx.send(repo.install_msg.replace("[p]", ctx.prefix))
@repo.command(name="delete")
async def _repo_del(self, ctx, repo_name: Repo):
@@ -272,7 +272,7 @@ class Downloader:
msg = _("Information on {}:\n{}").format(repo_name.name, repo_name.description or "")
await ctx.send(box(msg))
@commands.group(autohelp=True)
@commands.group()
@checks.is_owner()
async def cog(self, ctx):
"""
@@ -319,7 +319,7 @@ class Downloader:
await ctx.send(_("`{}` cog successfully installed.").format(cog_name))
if cog.install_msg is not None:
await ctx.send(cog.install_msg)
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
@cog.command(name="uninstall")
async def _cog_uninstall(self, ctx, cog_name: InstalledCog):

View File

@@ -9,7 +9,8 @@ import discord
from redbot.cogs.bank import check_global_setting_guildowner, check_global_setting_admin
from redbot.core import Config, bank, commands
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import pagify, box
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.bot import Red
@@ -137,7 +138,8 @@ class Economy:
self.config.register_role(**self.default_role_settings)
self.slot_register = defaultdict(dict)
@commands.group(name="bank", autohelp=True)
@guild_only_check()
@commands.group(name="bank")
async def _bank(self, ctx: commands.Context):
"""Bank operations"""
pass
@@ -209,7 +211,6 @@ class Economy:
)
@_bank.command()
@guild_only_check()
@check_global_setting_guildowner()
async def reset(self, ctx, confirmation: bool = False):
"""Deletes bank accounts"""
@@ -230,8 +231,8 @@ class Economy:
)
)
@commands.command()
@guild_only_check()
@commands.command()
async def payday(self, ctx: commands.Context):
"""Get some free currency"""
author = ctx.author
@@ -251,7 +252,7 @@ class Economy:
_(
"{0.mention} Here, take some {1}. Enjoy! (+{2} {1}!)\n\n"
"You currently have {3} {1}.\n\n"
"You are currently #{4} on the leaderboard!"
"You are currently #{4} on the global leaderboard!"
).format(
author,
credits_name,
@@ -309,8 +310,8 @@ class Economy:
"""Prints out the leaderboard
Defaults to top 10"""
# Originally coded by Airenkun - edited by irdumb, rewritten by Palm__ for v3
guild = ctx.guild
author = ctx.author
if top < 1:
top = 10
if (
@@ -320,25 +321,25 @@ class Economy:
bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
if len(bank_sorted) < top:
top = len(bank_sorted)
highscore = ""
for pos, acc in enumerate(bank_sorted, 1):
pos = pos
poswidth = 2
name = acc[1]["name"]
namewidth = 35
balance = acc[1]["balance"]
balwidth = 2
highscore += "{pos: <{poswidth}} {name: <{namewidth}s} {balance: >{balwidth}}\n".format(
pos=pos,
poswidth=poswidth,
name=name,
namewidth=namewidth,
balance=balance,
balwidth=balwidth,
header = f"{f'#':4}{f'Name':36}{f'Score':2}\n"
highscores = [
(
f"{f'{pos}.': <{3 if pos < 10 else 2}} {acc[1]['name']: <{35}s} "
f"{acc[1]['balance']: >{2 if pos < 10 else 1}}\n"
)
if highscore != "":
for page in pagify(highscore, shorten_by=12):
await ctx.send(box(page, lang="py"))
if acc[0] != author.id
else (
f"{f'{pos}.': <{3 if pos < 10 else 2}} <<{acc[1]['name'] + '>>': <{33}s} "
f"{acc[1]['balance']: >{2 if pos < 10 else 1}}\n"
)
for pos, acc in enumerate(bank_sorted, 1)
]
if highscores:
pages = [
f"```md\n{header}{''.join(''.join(highscores[x:x + 10]))}```"
for x in range(0, len(highscores), 10)
]
await menu(ctx, pages, DEFAULT_CONTROLS)
else:
await ctx.send(_("There are no accounts in the bank."))
@@ -438,7 +439,7 @@ class Economy:
)
)
@commands.group(autohelp=True)
@commands.group()
@guild_only_check()
@check_global_setting_admin()
async def economyset(self, ctx: commands.Context):

View File

@@ -39,7 +39,7 @@ class Filter:
except RuntimeError:
pass
@commands.group(name="filter", autohelp=True)
@commands.group(name="filter")
@commands.guild_only()
@checks.mod_or_permissions(manage_messages=True)
async def _filter(self, ctx: commands.Context):

View File

@@ -26,7 +26,7 @@ class Image:
def __unload(self):
self.session.close()
@commands.group(name="imgur", autohelp=True)
@commands.group(name="imgur")
async def _imgur(self, ctx):
"""Retrieves pictures from imgur

View File

@@ -161,13 +161,13 @@ class Mod:
except RuntimeError:
pass
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def modset(self, ctx: commands.Context):
"""Manages server administration settings."""
if ctx.invoked_subcommand is None:
guild = ctx.guild
# Display current settings
delete_repeats = await self.settings.guild(guild).delete_repeats()
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam()
@@ -628,7 +628,6 @@ class Mod:
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
async def unban(self, ctx: commands.Context, user_id: int, *, reason: str = None):
"""Unbans the target user.
@@ -636,13 +635,17 @@ class Mod:
1. Copy it from the mod log case (if one was created), or
2. enable developer mode, go to Bans in this server's settings, right-
click the user and select 'Copy ID'."""
channel = ctx.channel
if not channel.permissions_for(ctx.guild.me).ban_members:
await ctx.send("I need the Ban Members permission to do this.")
return
guild = ctx.guild
author = ctx.author
user = await self.bot.get_user_info(user_id)
if not user:
await ctx.send(_("Couldn't find a user with that ID!"))
return
reason = get_audit_reason(ctx.author, reason)
audit_reason = get_audit_reason(ctx.author, reason)
bans = await guild.bans()
bans = [be.user for be in bans]
if user not in bans:
@@ -651,7 +654,7 @@ class Mod:
queue_entry = (guild.id, user.id)
self.unban_queue.append(queue_entry)
try:
await guild.unban(user, reason=reason)
await guild.unban(user, reason=audit_reason)
except discord.HTTPException:
self.unban_queue.remove(queue_entry)
await ctx.send(_("Something went wrong while attempting to unban that user"))
@@ -832,7 +835,7 @@ class Mod:
_("I cannot do that, I lack the '{}' permission.").format("Manage Nicknames")
)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.mod_or_permissions(manage_channel=True)
async def mute(self, ctx: commands.Context):
@@ -998,7 +1001,7 @@ class Mod:
await self.settings.member(user).perms_cache.set(perms_cache)
return True, None
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.mod_or_permissions(manage_channel=True)
async def unmute(self, ctx: commands.Context):
@@ -1164,13 +1167,12 @@ class Mod:
await self.settings.member(user).perms_cache.set(perms_cache)
return True, None
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(manage_channels=True)
async def ignore(self, ctx: commands.Context):
"""Adds servers/channels to ignorelist"""
if ctx.invoked_subcommand is None:
await ctx.send_help()
await ctx.send(await self.count_ignored())
@ignore.command(name="channel")
@@ -1187,7 +1189,7 @@ class Mod:
await ctx.send(_("Channel already in ignore list."))
@ignore.command(name="server", aliases=["guild"])
@commands.has_permissions(manage_guild=True)
@checks.admin_or_permissions(manage_guild=True)
async def ignore_guild(self, ctx: commands.Context):
"""Ignores current server"""
guild = ctx.guild
@@ -1197,7 +1199,7 @@ class Mod:
else:
await ctx.send(_("This server is already being ignored."))
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(manage_channels=True)
async def unignore(self, ctx: commands.Context):
@@ -1220,7 +1222,7 @@ class Mod:
await ctx.send(_("That channel is not in the ignore list."))
@unignore.command(name="server", aliases=["guild"])
@commands.has_permissions(manage_guild=True)
@checks.admin_or_permissions(manage_guild=True)
async def unignore_guild(self, ctx: commands.Context):
"""Removes current guild from ignore list"""
guild = ctx.message.guild
@@ -1362,9 +1364,9 @@ class Mod:
names = await self.settings.user(user).past_names()
nicks = await self.settings.member(user).past_nicks()
if names:
names = [escape(name, mass_mentions=True) for name in names]
names = [escape(name, mass_mentions=True) for name in names if name]
if nicks:
nicks = [escape(nick, mass_mentions=True) for nick in nicks]
nicks = [escape(nick, mass_mentions=True) for nick in nicks if nick]
return names, nicks
async def check_tempban_expirations(self):
@@ -1602,18 +1604,22 @@ class Mod:
if entry.target == target:
return entry
async def on_member_update(self, before, after):
async def on_member_update(self, before: discord.Member, after: discord.Member):
if before.name != after.name:
async with self.settings.user(before).past_names() as name_list:
if after.nick in name_list:
while None in name_list: # clean out null entries from a bug
name_list.remove(None)
if after.name in name_list:
# Ensure order is maintained without duplicates occuring
name_list.remove(after.nick)
name_list.append(after.nick)
name_list.remove(after.name)
name_list.append(after.name)
while len(name_list) > 20:
name_list.pop(0)
if before.nick != after.nick and after.nick is not None:
async with self.settings.member(before).past_nicks() as nick_list:
while None in nick_list: # clean out null entries from a bug
nick_list.remove(None)
if after.nick in nick_list:
nick_list.remove(after.nick)
nick_list.append(after.nick)

View File

@@ -15,7 +15,7 @@ class ModLog:
def __init__(self, bot: Red):
self.bot = bot
@commands.group(autohelp=True)
@commands.group()
@checks.guildowner_or_permissions(administrator=True)
async def modlogset(self, ctx: commands.Context):
"""Settings for the mod log"""

View File

@@ -27,3 +27,18 @@ class RuleType(commands.Converter):
raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny"'.format(arg=arg)
)
class ClearableRuleType(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> str:
if arg.lower() in ("allow", "whitelist", "allowed"):
return "allow"
if arg.lower() in ("deny", "blacklist", "denied"):
return "deny"
if arg.lower() in ("clear", "reset"):
return "clear"
raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to remove the rule'
"".format(arg=arg)
)

View File

@@ -0,0 +1,102 @@
from redbot.core import commands
from redbot.core.config import Config
from .resolvers import entries_from_ctx, resolve_lists
# This has optimizations in it that may not hold True if other parts of the permission
# model are changed from the state they are in currently.
# (commit hash ~ 3bcf375204c22271ad3ed1fc059b598b751aa03f)
#
# This is primarily to help with the performance of the help formatter
# This is less efficient if only checking one command,
# but is much faster for checking all of them.
async def mass_resolve(*, ctx: commands.Context, config: Config):
"""
Get's all the permission cog interactions for all loaded commands
in the given context.
"""
owner_settings = await config.owner_models()
guild_owner_settings = await config.guild(ctx.guild).owner_models() if ctx.guild else None
ret = {"allowed": [], "denied": [], "default": []}
for cogname, cog in ctx.bot.cogs.items():
cog_setting = resolve_cog_or_command(
objname=cogname, models=owner_settings, ctx=ctx, typ="cogs"
)
if cog_setting is None and guild_owner_settings:
cog_setting = resolve_cog_or_command(
objname=cogname, models=guild_owner_settings, ctx=ctx, typ="cogs"
)
for command in [c for c in ctx.bot.all_commands.values() if c.instance is cog]:
resolution = recursively_resolve(
com_or_group=command,
o_models=owner_settings,
g_models=guild_owner_settings,
ctx=ctx,
)
for com, resolved in resolution:
if resolved is None:
resolved = cog_setting
if resolved is True:
ret["allowed"].append(com)
elif resolved is False:
ret["denied"].append(com)
else:
ret["default"].append(com)
ret = {k: set(v) for k, v in ret.items()}
return ret
def recursively_resolve(*, com_or_group, o_models, g_models, ctx, override=False):
ret = []
if override:
current = False
else:
current = resolve_cog_or_command(
typ="commands", objname=com_or_group.qualified_name, ctx=ctx, models=o_models
)
if current is None and g_models:
current = resolve_cog_or_command(
typ="commands", objname=com_or_group.qualified_name, ctx=ctx, models=o_models
)
ret.append((com_or_group, current))
if isinstance(com_or_group, commands.Group):
for com in com_or_group.commands:
ret.extend(
recursively_resolve(
com_or_group=com,
o_models=o_models,
g_models=g_models,
ctx=ctx,
override=(current is False),
)
)
return ret
def resolve_cog_or_command(*, typ, ctx, objname, models: dict) -> bool:
"""
Resolves models in order.
"""
resolved = None
if objname in models.get(typ, {}):
blacklist = models[typ][objname].get("deny", [])
whitelist = models[typ][objname].get("allow", [])
resolved = resolve_lists(ctx=ctx, whitelist=whitelist, blacklist=blacklist)
if resolved is not None:
return resolved
resolved = models[typ][objname].get("default", None)
if resolved is not None:
return resolved
return None

View File

@@ -7,16 +7,19 @@ from redbot.core.bot import Red
from redbot.core import checks
from redbot.core.config import Config
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.caching import LRUDict
from .resolvers import val_if_check_is_valid, resolve_models
from .resolvers import val_if_check_is_valid, resolve_models, entries_from_ctx
from .yaml_handler import yamlset_acl, yamlget_acl
from .converters import CogOrCommand, RuleType
from .converters import CogOrCommand, RuleType, ClearableRuleType
from .mass_resolution import mass_resolve
_models = ["owner", "guildowner", "admin", "mod", "all"]
_ = Translator("Permissions", __file__)
REACTS = {"\N{WHITE HEAVY CHECK MARK}": True, "\N{NEGATIVE SQUARED CROSS MARK}": False}
Y_OR_N = {"y": True, "yes": True, "n": False, "no": False}
@cog_i18n(_)
@@ -34,8 +37,32 @@ class Permissions:
self.config = Config.get_conf(self, identifier=78631113035100160, force_registration=True)
self.config.register_global(owner_models={})
self.config.register_guild(owner_models={})
self.cache = LRUDict(size=25000) # This can be tuned later
async def __global_check(self, ctx):
async def get_user_ctx_overrides(self, ctx: commands.Context) -> dict:
"""
This takes a context object, and returns a dict of
allowed: list of commands
denied: list of commands
default: list of commands
representing how permissions interacts with the
user, channel, guild, and (possibly) voice channel
for all commands on the bot (not just the one in the context object)
This mainly exists for use by the help formatter,
but others may find it useful
Unlike the rest of the permission system, if other models are added later,
due to optimizations made for this, this needs to be adjusted accordingly
This does not account for before and after permission hooks,
these need to be checked seperately
"""
return await mass_resolve(ctx=ctx, config=self.config)
async def __global_check(self, ctx: commands.Context) -> bool:
"""
Yes, this is needed on top of hooking into checks.py
to ensure that unchecked commands can still be managed by permissions
@@ -68,12 +95,6 @@ class Permissions:
"""
if await ctx.bot.is_owner(ctx.author):
return True
voice_channel = None
with contextlib.suppress(Exception):
voice_channel = ctx.author.voice.voice_channel
entries = [x for x in (ctx.author, voice_channel, ctx.channel) if x]
roles = sorted(ctx.author.roles, reverse=True) if ctx.guild else []
entries.extend([x.id for x in roles])
before = [
getattr(cog, "_{0.__class__.__name__}__red_permissions_before".format(cog), None)
@@ -86,11 +107,26 @@ class Permissions:
if override is not None:
return override
for model in self.resolution_order[level]:
override_model = getattr(self, model + "_model", None)
override = await override_model(ctx) if override_model else None
# checked ids + configureable to be checked against
cache_tup = entries_from_ctx(ctx) + (
ctx.cog.__class__.__name__,
ctx.command.qualified_name,
)
if cache_tup in self.cache:
override = self.cache[cache_tup]
if override is not None:
return override
else:
for model in self.resolution_order[level]:
if ctx.guild is None and model != "owner":
break
override_model = getattr(self, model + "_model", None)
override = await override_model(ctx) if override_model else None
if override is not None:
self.cache[cache_tup] = override
return override
# This is intentional not being in an else block
self.cache[cache_tup] = None
after = [
getattr(cog, "_{0.__class__.__name__}__red_permissions_after".format(cog), None)
@@ -115,7 +151,8 @@ class Permissions:
"""
Handles guild level overrides
"""
if ctx.guild is None:
return None
async with self.config.guild(ctx.guild).owner_models() as models:
return resolve_models(ctx=ctx, models=models)
@@ -125,7 +162,7 @@ class Permissions:
# async def admin_model(self, ctx: commands.Context) -> bool:
# async def mod_model(self, ctx: commands.Context) -> bool:
@commands.group(aliases=["p"], autohelp=True)
@commands.group(aliases=["p"])
async def permissions(self, ctx: commands.Context):
"""
Permission management tools
@@ -223,6 +260,7 @@ class Permissions:
return await ctx.send(_("Invalid syntax."))
else:
await ctx.send(_("Rules set."))
self.invalidate_cache()
@checks.is_owner()
@permissions.command(name="getglobalacl")
@@ -249,6 +287,7 @@ class Permissions:
return await ctx.send(_("Invalid syntax."))
else:
await ctx.send(_("Rules set."))
self.invalidate_cache(ctx.guild.id)
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
@@ -278,6 +317,7 @@ class Permissions:
return await ctx.send(_("Invalid syntax."))
else:
await ctx.send(_("Rules set."))
self.invalidate_cache(ctx.guild.id)
@checks.is_owner()
@permissions.command(name="updateglobalacl")
@@ -297,6 +337,7 @@ class Permissions:
return await ctx.send(_("Invalid syntax."))
else:
await ctx.send(_("Rules set."))
self.invalidate_cache()
@checks.is_owner()
@permissions.command(name="addglobalrule")
@@ -340,6 +381,7 @@ class Permissions:
data[model_type][type_name][allow_or_deny].append(obj)
models.update(data)
await ctx.send(_("Rule added."))
self.invalidate_cache(type_name, obj)
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
@@ -384,6 +426,7 @@ class Permissions:
data[model_type][type_name][allow_or_deny].append(obj)
models.update(data)
await ctx.send(_("Rule added."))
self.invalidate_cache(type_name, obj)
@checks.is_owner()
@permissions.command(name="removeglobalrule")
@@ -427,6 +470,7 @@ class Permissions:
data[model_type][type_name][allow_or_deny].remove(obj)
models.update(data)
await ctx.send(_("Rule removed."))
self.invalidate_cache(obj, type_name)
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
@@ -471,23 +515,18 @@ class Permissions:
data[model_type][type_name][allow_or_deny].remove(obj)
models.update(data)
await ctx.send(_("Rule removed."))
self.invalidate_cache(obj, type_name)
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
@permissions.command(name="setdefaultguildrule")
async def set_default_guild_rule(
self, ctx: commands.Context, cog_or_command: CogOrCommand, allow_or_deny: RuleType = None
self, ctx: commands.Context, allow_or_deny: ClearableRuleType, cog_or_command: CogOrCommand
):
"""
Sets the default behavior for a cog or command if no rule is set
Use with a cog or command and no setting to clear the default and defer to
normal check logic
"""
if allow_or_deny:
val_to_set = {"allow": True, "deny": False}.get(allow_or_deny)
else:
val_to_set = None
val_to_set = {"allow": True, "deny": False, "clear": None}.get(allow_or_deny)
model_type, type_name = cog_or_command
async with self.config.guild(ctx.guild).owner_models() as models:
@@ -501,23 +540,17 @@ class Permissions:
models.update(data)
await ctx.send(_("Default set."))
self.invalidate_cache(type_name)
@checks.is_owner()
@permissions.command(name="setdefaultglobalrule")
async def set_default_global_rule(
self, ctx: commands.Context, cog_or_command: CogOrCommand, allow_or_deny: RuleType = None
self, ctx: commands.Context, allow_or_deny: ClearableRuleType, cog_or_command: CogOrCommand
):
"""
Sets the default behavior for a cog or command if no rule is set
Use with a cog or command and no setting to clear the default and defer to
normal check logic
"""
if allow_or_deny:
val_to_set = {"allow": True, "deny": False}.get(allow_or_deny)
else:
val_to_set = None
val_to_set = {"allow": True, "deny": False, "clear": None}.get(allow_or_deny)
model_type, type_name = cog_or_command
async with self.config.owner_models() as models:
@@ -531,32 +564,17 @@ class Permissions:
models.update(data)
await ctx.send(_("Default set."))
self.invalidate_cache(type_name)
@commands.bot_has_permissions(add_reactions=True)
@checks.is_owner()
@permissions.command(name="clearglobalsettings")
async def clear_globals(self, ctx: commands.Context):
"""
Clears all global rules.
"""
await self._confirm_then_clear_rules(ctx, is_guild=False)
self.invalidate_cache()
m = await ctx.send("Are you sure?")
for r in REACTS.keys():
await m.add_reaction(r)
try:
reaction, user = await self.bot.wait_for(
"reaction_add", check=lambda r, u: u == ctx.author and str(r) in REACTS, timeout=30
)
except asyncio.TimeoutError:
return await ctx.send(_("Ok, try responding with an emoji next time."))
if REACTS.get(str(reaction)):
await self.config.owner_models.clear()
await ctx.send(_("Global settings cleared."))
else:
await ctx.send(_("Okay."))
@commands.bot_has_permissions(add_reactions=True)
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
@permissions.command(name="clearguildsettings")
@@ -564,23 +582,61 @@ class Permissions:
"""
Clears all guild rules.
"""
await self._confirm_then_clear_rules(ctx, is_guild=True)
self.invalidate_cache(ctx.guild.id)
m = await ctx.send("Are you sure?")
for r in REACTS.keys():
await m.add_reaction(r)
try:
reaction, user = await self.bot.wait_for(
"reaction_add", check=lambda r, u: u == ctx.author and str(r) in REACTS, timeout=30
)
except asyncio.TimeoutError:
return await ctx.send(_("Ok, try responding with an emoji next time."))
async def _confirm_then_clear_rules(self, ctx: commands.Context, is_guild: bool):
if ctx.guild.me.permissions_in(ctx.channel).add_reactions:
m = await ctx.send(_("Are you sure?"))
for r in REACTS.keys():
await m.add_reaction(r)
try:
reaction, user = await self.bot.wait_for(
"reaction_add",
check=lambda r, u: u == ctx.author and str(r) in REACTS,
timeout=30,
)
except asyncio.TimeoutError:
return await ctx.send(_("Ok, try responding with an emoji next time."))
if REACTS.get(str(reaction)):
await self.config.guild(ctx.guild).owner_models.clear()
await ctx.send(_("Guild settings cleared."))
agreed = REACTS.get(str(reaction))
else:
await ctx.send(_("Are you sure? (y/n)"))
try:
message = await self.bot.wait_for(
"message",
check=lambda m: m.author == ctx.author and m.content in Y_OR_N,
timeout=30,
)
except asyncio.TimeoutError:
return await ctx.send(_("Ok, try responding with yes or no next time."))
agreed = Y_OR_N.get(message.content.lower())
if agreed:
if is_guild:
await self.config.guild(ctx.guild).owner_models.clear()
await ctx.send(_("Guild settings cleared."))
else:
await self.config.owner_models.clear()
await ctx.send(_("Global settings cleared."))
else:
await ctx.send(_("Okay."))
def invalidate_cache(self, *to_invalidate):
"""
Either invalidates the entire cache (if given no objects)
or does a partial invalidation based on passed objects
"""
if len(to_invalidate) == 0:
self.cache.clear()
return
# LRUDict inherits from ordered dict, hence the syntax below
stil_valid = [
(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)
def find_object_uniquely(self, info: str) -> int:
"""
Finds an object uniquely, returns it's id or returns None

View File

@@ -7,6 +7,23 @@ from redbot.core import commands
log = logging.getLogger("redbot.cogs.permissions.resolvers")
def entries_from_ctx(ctx: commands.Context) -> tuple:
voice_channel = None
with contextlib.suppress(Exception):
voice_channel = ctx.author.voice.voice_channel
entries = [x.id for x in (ctx.author, voice_channel, ctx.channel) if x]
roles = sorted(ctx.author.roles, reverse=True) if ctx.guild else []
entries.extend([x.id for x in roles])
# entries now contains the following (in order) (if applicable)
# author.id
# author.voice.voice_channel.id
# channel.id
# role.id for each role (highest to lowest)
# (implicitly) guild.id because
# the @everyone role shares an id with the guild
return tuple(entries)
async def val_if_check_is_valid(*, ctx: commands.Context, check: object, level: str) -> bool:
"""
Returns the value from a check if it is valid
@@ -56,23 +73,7 @@ def resolve_lists(*, ctx: commands.Context, whitelist: list, blacklist: list) ->
"""
resolves specific lists
"""
voice_channel = None
with contextlib.suppress(Exception):
voice_channel = ctx.author.voice.voice_channel
entries = [x.id for x in (ctx.author, voice_channel, ctx.channel) if x]
roles = sorted(ctx.author.roles, reverse=True) if ctx.guild else []
entries.extend([x.id for x in roles])
# entries now contains the following (in order) (if applicable)
# author.id
# author.voice.voice_channel.id
# channel.id
# role.id for each role (highest to lowest)
# (implicitly) guild.id because
# the @everyone role shares an id with the guild
for entry in entries:
for entry in entries_from_ctx(ctx):
if entry in whitelist:
return True
if entry in blacklist:

View File

@@ -56,32 +56,32 @@ class Reports:
@checks.admin_or_permissions(manage_guild=True)
@commands.guild_only()
@commands.group(name="reportset", autohelp=True)
@commands.group(name="reportset")
async def reportset(self, ctx: commands.Context):
"""
settings for reports
Settings for the report system.
"""
pass
@checks.admin_or_permissions(manage_guild=True)
@reportset.command(name="output")
async def setoutput(self, ctx: commands.Context, channel: discord.TextChannel):
"""sets the output channel"""
"""Set the channel where reports will show up"""
await self.config.guild(ctx.guild).output_channel.set(channel.id)
await ctx.send(_("Report Channel Set."))
await ctx.send(_("The report channel has been set."))
@checks.admin_or_permissions(manage_guild=True)
@reportset.command(name="toggleactive")
@reportset.command(name="toggle", aliases=["toggleactive"])
async def report_toggle(self, ctx: commands.Context):
"""Toggles whether the Reporting tool is enabled or not"""
"""Enables or Disables reporting for the server"""
active = await self.config.guild(ctx.guild).active()
active = not active
await self.config.guild(ctx.guild).active.set(active)
if active:
await ctx.send(_("Reporting now enabled"))
await ctx.send(_("Reporting is now enabled"))
else:
await ctx.send(_("Reporting disabled."))
await ctx.send(_("Reporting is now disabled."))
async def internal_filter(self, m: discord.Member, mod=False, perms=None):
ret = False
@@ -105,7 +105,7 @@ class Reports:
*,
mod: bool = False,
permissions: Union[discord.Permissions, dict] = None,
prompt: str = ""
prompt: str = "",
):
"""
discovers which of shared guilds between the bot
@@ -175,7 +175,10 @@ class Reports:
if await self.bot.embed_requested(channel, author):
em = discord.Embed(description=report)
em.set_author(
name=_("Report from {0.display_name}").format(author), icon_url=author.avatar_url
name=_("Report from {author}{maybe_nick}").format(
author=author, maybe_nick=(f" ({author.nick})" if author.nick else "")
),
icon_url=author.avatar_url,
)
em.set_footer(text=_("Report #{}").format(ticket_number))
send_content = None
@@ -201,10 +204,10 @@ class Reports:
@commands.group(name="report", invoke_without_command=True)
async def report(self, ctx: commands.Context, *, _report: str = ""):
"""
Follow the prompts to make a report
Send a report.
optionally use with a report message
to use it non interactively
Use without arguments for interactive reporting, or do
[p]report <text> to use it non-interactively.
"""
author = ctx.author
guild = ctx.guild
@@ -224,14 +227,17 @@ class Reports:
if self.antispam[guild.id][author.id].spammy:
return await author.send(
_(
"You've sent a few too many of these recently. "
"Contact a server admin to resolve this, or try again "
"later."
"You've sent too many reports recently. "
"Please contact a server admin if this is important matter, "
"or please wait and try again later."
)
)
if author.id in self.user_cache:
return await author.send(
_("Please finish making your prior report before making an additional one")
_(
"Please finish making your prior report before trying to make an "
"additional one!"
)
)
self.user_cache.append(author.id)
@@ -263,7 +269,9 @@ class Reports:
with contextlib.suppress(discord.Forbidden, discord.HTTPException):
if val is None:
await author.send(_("There was an error sending your report."))
await author.send(
_("There was an error sending your report, please contact a server admin.")
)
else:
await author.send(_("Your report was submitted. (Ticket #{})").format(val))
self.antispam[guild.id][author.id].stamp()
@@ -313,10 +321,12 @@ class Reports:
@report.command(name="interact")
async def response(self, ctx, ticket_number: int):
"""
opens a message tunnel between things you say in this channel
and the ticket opener's direct messages
Open a message tunnel.
This tunnel will forward things you say in this channel
to the ticket opener's direct messages.
tunnels do not persist across bot restarts
Tunnels do not persist across bot restarts.
"""
# note, mod_or_permissions is an implicit guild_only
@@ -342,14 +352,15 @@ class Reports:
)
big_topic = _(
"{who} opened a 2-way communication."
"{who} opened a 2-way communication "
"about ticket number {ticketnum}. Anything you say or upload here "
"(8MB file size limitation on uploads) "
"will be forwarded to them until the communication is closed.\n"
"You can close a communication at any point "
"by reacting with the X to the last message recieved. "
"\nAny message succesfully forwarded will be marked with a check."
"\nTunnels are not persistent across bot restarts."
"You can close a communication at any point by reacting with "
"the \N{NEGATIVE SQUARED CROSS MARK} to the last message recieved.\n"
"Any message succesfully forwarded will be marked with "
"\N{WHITE HEAVY CHECK MARK}.\n"
"Tunnels are not persistent across bot restarts."
)
topic = big_topic.format(
ticketnum=ticket_number, who=_("A moderator in `{guild.name}` has").format(guild=guild)
@@ -357,8 +368,7 @@ class Reports:
try:
m = await tun.communicate(message=ctx.message, topic=topic, skip_message_content=True)
except discord.Forbidden:
await ctx.send(_("User has disabled DMs."))
tun.close()
await ctx.send(_("That user has DMs disabled."))
else:
self.tunnel_store[(guild, ticket_number)] = {"tun": tun, "msgs": m}
await ctx.send(big_topic.format(who=_("You have"), ticketnum=ticket_number))

View File

@@ -75,14 +75,14 @@ class Streams:
@commands.command()
async def twitch(self, ctx: commands.Context, channel_name: str):
"""Checks if a Twitch channel is streaming"""
"""Checks if a Twitch channel is live"""
token = await self.db.tokens.get_raw(TwitchStream.__name__, default=None)
stream = TwitchStream(name=channel_name, token=token)
await self.check_online(ctx, stream)
@commands.command()
async def youtube(self, ctx: commands.Context, channel_id_or_name: str):
"""Checks if a Youtube channel is streaming"""
"""Checks if a Youtube channel is live"""
apikey = await self.db.tokens.get_raw(YoutubeStream.__name__, default=None)
is_name = self.check_name_or_id(channel_id_or_name)
if is_name:
@@ -93,19 +93,19 @@ class Streams:
@commands.command()
async def hitbox(self, ctx: commands.Context, channel_name: str):
"""Checks if a Hitbox channel is streaming"""
"""Checks if a Hitbox channel is live"""
stream = HitboxStream(name=channel_name)
await self.check_online(ctx, stream)
@commands.command()
async def mixer(self, ctx: commands.Context, channel_name: str):
"""Checks if a Mixer channel is streaming"""
"""Checks if a Mixer channel is live"""
stream = MixerStream(name=channel_name)
await self.check_online(ctx, stream)
@commands.command()
async def picarto(self, ctx: commands.Context, channel_name: str):
"""Checks if a Picarto channel is streaming"""
"""Checks if a Picarto channel is live"""
stream = PicartoStream(name=channel_name)
await self.check_online(ctx, stream)
@@ -113,9 +113,9 @@ class Streams:
try:
embed = await stream.is_online()
except OfflineStream:
await ctx.send(_("The stream is offline."))
await ctx.send(_("That user is offline."))
except StreamNotFound:
await ctx.send(_("The channel doesn't seem to exist."))
await ctx.send(_("That channel doesn't seem to exist."))
except InvalidTwitchCredentials:
await ctx.send(
_("The twitch token is either invalid or has not been set. See `{}`.").format(
@@ -124,7 +124,7 @@ class Streams:
)
except InvalidYoutubeCredentials:
await ctx.send(
_("The Youtube API key is either invalid or has not been set. See {}.").format(
_("Your Youtube API key is either invalid or has not been set. See {}.").format(
"`{}streamset youtubekey`".format(ctx.prefix)
)
)
@@ -135,51 +135,50 @@ class Streams:
else:
await ctx.send(embed=embed)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.mod()
async def streamalert(self, ctx: commands.Context):
pass
@streamalert.group(name="twitch", autohelp=True)
@streamalert.group(name="twitch")
async def _twitch(self, ctx: commands.Context):
"""Twitch stream alerts"""
pass
@_twitch.command(name="channel")
async def twitch_alert_channel(self, ctx: commands.Context, channel_name: str):
"""Sets a Twitch stream alert notification in the channel"""
"""Sets a Twitch alert notification in the channel"""
await self.stream_alert(ctx, TwitchStream, channel_name.lower())
@_twitch.command(name="community")
async def twitch_alert_community(self, ctx: commands.Context, community: str):
"""Sets a Twitch stream alert notification in the channel for the specified community."""
"""Sets an alert notification in the channel for the specified twitch community."""
await self.community_alert(ctx, TwitchCommunity, community.lower())
@streamalert.command(name="youtube")
async def youtube_alert(self, ctx: commands.Context, channel_name_or_id: str):
"""Sets a Youtube stream alert notification in the channel"""
"""Sets a Youtube alert notification in the channel"""
await self.stream_alert(ctx, YoutubeStream, channel_name_or_id)
@streamalert.command(name="hitbox")
async def hitbox_alert(self, ctx: commands.Context, channel_name: str):
"""Sets a Hitbox stream alert notification in the channel"""
"""Sets a Hitbox alert notification in the channel"""
await self.stream_alert(ctx, HitboxStream, channel_name)
@streamalert.command(name="mixer")
async def mixer_alert(self, ctx: commands.Context, channel_name: str):
"""Sets a Mixer stream alert notification in the channel"""
"""Sets a Mixer alert notification in the channel"""
await self.stream_alert(ctx, MixerStream, channel_name)
@streamalert.command(name="picarto")
async def picarto_alert(self, ctx: commands.Context, channel_name: str):
"""Sets a Picarto stream alert notification in the channel"""
"""Sets a Picarto alert notification in the channel"""
await self.stream_alert(ctx, PicartoStream, channel_name)
@streamalert.command(name="stop")
async def streamalert_stop(self, ctx: commands.Context, _all: bool = False):
"""Stops all stream notifications in the channel
Adding 'yes' will disable all notifications in the server"""
streams = self.streams.copy()
local_channel_ids = [c.id for c in ctx.guild.channels]
@@ -202,7 +201,7 @@ class Streams:
self.streams = streams
await self.save_streams()
msg = _("All {}'s stream alerts have been disabled.").format(
msg = _("All the alerts in the {} have been disabled.").format(
"server" if _all else "channel"
)
@@ -212,7 +211,7 @@ class Streams:
async def streamalert_list(self, ctx: commands.Context):
streams_list = defaultdict(list)
guild_channels_ids = [c.id for c in ctx.guild.channels]
msg = _("Active stream alerts:\n\n")
msg = _("Active alerts:\n\n")
for stream in self.streams:
for channel_id in stream.channels:
@@ -220,7 +219,7 @@ class Streams:
streams_list[channel_id].append(stream.name.lower())
if not streams_list:
await ctx.send(_("There are no active stream alerts in this server."))
await ctx.send(_("There are no active alerts in this server."))
return
for channel_id, streams in streams_list.items():
@@ -243,16 +242,16 @@ class Streams:
exists = await self.check_exists(stream)
except InvalidTwitchCredentials:
await ctx.send(
_("The twitch token is either invalid or has not been set. See {}.").format(
_("Your twitch token is either invalid or has not been set. See {}.").format(
"`{}streamset twitchtoken`".format(ctx.prefix)
)
)
return
except InvalidYoutubeCredentials:
await ctx.send(
_("The Youtube API key is either invalid or has not been set. See {}.").format(
"`{}streamset youtubekey`".format(ctx.prefix)
)
_(
"Your Youtube API key is either invalid or has not been set. See {}."
).format("`{}streamset youtubekey`".format(ctx.prefix))
)
return
except APIError:
@@ -294,7 +293,7 @@ class Streams:
await self.add_or_remove_community(ctx, community)
@commands.group(autohelp=True)
@commands.group()
@checks.mod()
async def streamset(self, ctx: commands.Context):
pass
@@ -303,7 +302,6 @@ class Streams:
@checks.is_owner()
async def twitchtoken(self, ctx: commands.Context, token: str):
"""Set the Client ID for twitch.
To do this, follow these steps:
1. Go to this page: https://dev.twitch.tv/dashboard/apps.
2. Click *Register Your Application*
@@ -311,7 +309,6 @@ class Streams:
select an Application Category of your choosing.
4. Click *Register*, and on the following page, copy the Client ID.
5. Paste the Client ID into this command. Done!
"""
await self.db.tokens.set_raw("TwitchStream", value=token)
await self.db.tokens.set_raw("TwitchCommunity", value=token)
@@ -321,22 +318,19 @@ class Streams:
@checks.is_owner()
async def youtubekey(self, ctx: commands.Context, key: str):
"""Sets the API key for Youtube.
To get one, do the following:
1. Create a project (see https://support.google.com/googleapi/answer/6251787 for details)
2. Enable the Youtube Data API v3 (see https://support.google.com/googleapi/answer/6158841 for instructions)
3. Set up your API key (see https://support.google.com/googleapi/answer/6158862 for instructions)
4. Copy your API key and paste it into this command. Done!
"""
await self.db.tokens.set_raw("YoutubeStream", value=key)
await ctx.send(_("Youtube key set."))
@streamset.group(autohelp=True)
@streamset.group()
@commands.guild_only()
async def mention(self, ctx: commands.Context):
"""Sets mentions for stream alerts."""
"""Sets mentions for alerts."""
pass
@mention.command(aliases=["everyone"])
@@ -348,15 +342,16 @@ class Streams:
if current_setting:
await self.db.guild(guild).mention_everyone.set(False)
await ctx.send(
_("{} will no longer be mentioned for a stream alert.").format("@\u200beveryone")
_("{} will no longer be mentioned when a stream or community is live").format(
"@\u200beveryone"
)
)
else:
await self.db.guild(guild).mention_everyone.set(True)
await ctx.send(
_(
"When a stream configured for stream alerts "
"comes online, {} will be mentioned."
).format("@\u200beveryone")
_("When a stream or community " "is live, {} will be mentioned.").format(
"@\u200beveryone"
)
)
@mention.command(aliases=["here"])
@@ -367,16 +362,13 @@ class Streams:
current_setting = await self.db.guild(guild).mention_here()
if current_setting:
await self.db.guild(guild).mention_here.set(False)
await ctx.send(
_("{} will no longer be mentioned for a stream alert.").format("@\u200bhere")
)
await ctx.send(_("{} will no longer be mentioned for an alert.").format("@\u200bhere"))
else:
await self.db.guild(guild).mention_here.set(True)
await ctx.send(
_(
"When a stream configured for stream alerts "
"comes online, {} will be mentioned."
).format("@\u200bhere")
_("When a stream or community " "is live, {} will be mentioned.").format(
"@\u200bhere"
)
)
@mention.command()
@@ -390,18 +382,16 @@ class Streams:
if current_setting:
await self.db.role(role).mention.set(False)
await ctx.send(
_("{} will no longer be mentioned for a stream alert.").format(
_("{} will no longer be mentioned for an alert.").format(
"@\u200b{}".format(role.name)
)
)
else:
await self.db.role(role).mention.set(True)
await ctx.send(
_(
"When a stream configured for stream alerts "
"comes online, {} will be mentioned."
""
).format("@\u200b{}".format(role.name))
_("When a stream or community " "is live, {} will be mentioned." "").format(
"@\u200b{}".format(role.name)
)
)
@streamset.command()
@@ -420,7 +410,7 @@ class Streams:
if stream not in self.streams:
self.streams.append(stream)
await ctx.send(
_("I'll send a notification in this channel when {} is online.").format(
_("I'll now send a notification in this channel when {} is live.").format(
stream.name
)
)
@@ -444,7 +434,7 @@ class Streams:
await ctx.send(
_(
"I'll send a notification in this channel when a "
"channel is streaming to the {} community."
"channel is live in the {} community."
""
).format(community.name)
)
@@ -455,7 +445,7 @@ class Streams:
await ctx.send(
_(
"I won't send notifications about channels streaming "
"to the {} community in this channel anymore."
"in the {} community in this channel anymore."
""
).format(community.name)
)
@@ -530,9 +520,9 @@ class Streams:
mention_str = await self._get_mention_str(channel.guild)
if mention_str:
content = "{}, {} is online!".format(mention_str, stream.name)
content = "{}, {} is live!".format(mention_str, stream.name)
else:
content = "{} is online!".format(stream.name)
content = "{} is live!".format(stream.name)
try:
m = await channel.send(content, embed=embed)
@@ -558,7 +548,7 @@ class Streams:
try:
stream_list = await community.get_community_streams()
except CommunityNotFound:
print(_("Community {} not found!").format(community.name))
print(_("The Community {} was not found!").format(community.name))
continue
except OfflineCommunity:
for message in community._messages_cache:

View File

@@ -41,7 +41,7 @@ class Trivia:
self.conf.register_member(wins=0, games=0, total_score=0)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.mod_or_permissions(administrator=True)
async def triviaset(self, ctx: commands.Context):

View File

@@ -22,7 +22,7 @@ async def warning_points_add_check(
act = a
else:
break
if act: # some action needs to be taken
if act and act["exceed_command"] is not None: # some action needs to be taken
await create_and_invoke_context(ctx, act["exceed_command"], user)
@@ -38,7 +38,7 @@ async def warning_points_remove_check(
act = a
else:
break
if act: # some action needs to be taken
if act and act["drop_command"] is not None: # some action needs to be taken
await create_and_invoke_context(ctx, act["drop_command"], user)
@@ -81,10 +81,11 @@ async def get_command_for_exceeded_points(ctx: commands.Context):
the points threshold for the action"""
await ctx.send(
_(
"Enter the command to be run when the user exceeds the points for "
"this action to occur.\nEnter it exactly as you would if you were "
"Enter the command to be run when the user **exceeds the points for "
"this action to occur.**\n**If you do not wish to have a command run, enter** "
"`none`.\n\nEnter it exactly as you would if you were "
"actually trying to run the command, except don't put a prefix and "
"use {user} in place of any user/member arguments\n\n"
"use `{user}` in place of any user/member arguments\n\n"
"WARNING: The command entered will be run without regard to checks or cooldowns. "
"Commands requiring bot owner are not allowed for security reasons.\n\n"
"Please wait 15 seconds before entering your response."
@@ -100,8 +101,10 @@ async def get_command_for_exceeded_points(ctx: commands.Context):
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
return None
else:
if msg.content == "none":
return None
command, m = get_command_from_input(ctx.bot, msg.content)
if command is None:
@@ -121,12 +124,13 @@ async def get_command_for_dropping_points(ctx: commands.Context):
"""
await ctx.send(
_(
"Enter the command to be run when the user returns to a value below "
"the points for this action to occur. Please note that this is "
"Enter the command to be run when the user **returns to a value below "
"the points for this action to occur.** Please note that this is "
"intended to be used for reversal of the action taken when the user "
"exceeded the action's point value\nEnter it exactly as you would "
"exceeded the action's point value.\n**If you do not wish to have a command run "
"on dropping points, enter** `none`.\n\nEnter it exactly as you would "
"if you were actually trying to run the command, except don't put a prefix "
"and use {user} in place of any user/member arguments\n\n"
"and use `{user}` in place of any user/member arguments\n\n"
"WARNING: The command entered will be run without regard to checks or cooldowns. "
"Commands requiring bot owner are not allowed for security reasons.\n\n"
"Please wait 15 seconds before entering your response."
@@ -142,9 +146,10 @@ async def get_command_for_dropping_points(ctx: commands.Context):
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
return None
else:
if msg.content == "none":
return None
command, m = get_command_from_input(ctx.bot, msg.content)
if command is None:
await ctx.send(m)

View File

@@ -14,6 +14,7 @@ from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import is_admin_or_superior
from redbot.core.utils.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
_ = Translator("Warnings", __file__)
@@ -41,7 +42,7 @@ class Warnings:
except RuntimeError:
pass
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warningset(self, ctx: commands.Context):
@@ -58,7 +59,7 @@ class Warnings:
_("Custom reasons have been {}.").format(_("enabled") if allowed else _("disabled"))
)
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnaction(self, ctx: commands.Context):
@@ -74,27 +75,9 @@ class Warnings:
"""
guild = ctx.guild
await ctx.send("Would you like to enter commands to be run? (y/n)")
exceed_command = await get_command_for_exceeded_points(ctx)
drop_command = await get_command_for_dropping_points(ctx)
def same_author_check(m):
return m.author == ctx.author
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
return
if msg.content.lower() == "y":
exceed_command = await get_command_for_exceeded_points(ctx)
if exceed_command is None:
return
drop_command = await get_command_for_dropping_points(ctx)
if drop_command is None:
return
else:
exceed_command = None
drop_command = None
to_add = {
"action_name": name,
"points": points,
@@ -114,7 +97,7 @@ class Warnings:
# Sort in descending order by point count for ease in
# finding the highest possible action to take
registered_actions.sort(key=lambda a: a["points"], reverse=True)
await ctx.tick()
await ctx.send(_("Action {name} has been added.").format(name=name))
@warnaction.command(name="del")
@commands.guild_only()
@@ -134,7 +117,7 @@ class Warnings:
else:
await ctx.send(_("No action named {} exists!").format(action_name))
@commands.group(autohelp=True)
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnreason(self, ctx: commands.Context):
@@ -182,13 +165,20 @@ class Warnings:
msg_list = []
async with guild_settings.reasons() as registered_reasons:
for r, v in registered_reasons.items():
msg_list.append(
"Name: {}\nPoints: {}\nDescription: {}".format(
r, v["points"], v["description"]
if ctx.embed_requested():
em = discord.Embed(
title=_("Reason: {name}").format(name=r), description=v["description"]
)
em.add_field(name=_("Points"), value=str(v["points"]))
msg_list.append(em)
else:
msg_list.append(
"Name: {}\nPoints: {}\nDescription: {}".format(
r, v["points"], v["description"]
)
)
)
if msg_list:
await ctx.send_interactive(msg_list)
await menu(ctx, msg_list, DEFAULT_CONTROLS)
else:
await ctx.send(_("There are no reasons configured!"))
@@ -202,14 +192,21 @@ class Warnings:
msg_list = []
async with guild_settings.actions() as registered_actions:
for r in registered_actions:
msg_list.append(
"Name: {}\nPoints: {}\nExceed command: {}\n"
"Drop command: {}".format(
r["action_name"], r["points"], r["exceed_command"], r["drop_command"]
if await ctx.embed_requested():
em = discord.Embed(title=_("Action: {name}").format(name=r["action_name"]))
em.add_field(name=_("Points"), value="{}".format(r["points"]), inline=False)
em.add_field(name=_("Exceed command"), value=r["exceed_command"], inline=False)
em.add_field(name=_("Drop command"), value=r["drop_command"], inline=False)
msg_list.append(em)
else:
msg_list.append(
"Name: {}\nPoints: {}\nExceed command: {}\n"
"Drop command: {}".format(
r["action_name"], r["points"], r["exceed_command"], r["drop_command"]
)
)
)
if msg_list:
await ctx.send_interactive(msg_list)
await menu(ctx, msg_list, DEFAULT_CONTROLS)
else:
await ctx.send(_("There are no actions configured!"))
@@ -221,6 +218,9 @@ class Warnings:
Reason must be a registered reason, or "custom" if custom reasons are allowed
"""
if user == ctx.author:
await ctx.send(_("You cannot warn yourself."))
return
if reason.lower() == "custom":
custom_allowed = await self.config.guild(ctx.guild).allow_custom_reasons()
if not custom_allowed:
@@ -256,7 +256,27 @@ class Warnings:
await member_settings.total_points.set(current_point_count)
await warning_points_add_check(self.config, ctx, user, current_point_count)
await ctx.tick()
try:
em = discord.Embed(
title=_("Warning from {mod_name}#{mod_discrim}").format(
mod_name=ctx.author.display_name, mod_discrim=ctx.author.discriminator
),
description=reason_type["description"],
)
em.add_field(name=_("Points"), value=str(reason_type["points"]))
await user.send(
_("You have received a warning in {guild_name}.").format(
guild_name=ctx.guild.name
),
embed=em,
)
except discord.HTTPException:
pass
await ctx.send(
_("User {user_name}#{user_discrim} has been warned.").format(
user_name=user.display_name, user_discrim=user.discriminator
)
)
@commands.command()
@commands.guild_only()
@@ -303,6 +323,9 @@ class Warnings:
@checks.admin_or_permissions(ban_members=True)
async def unwarn(self, ctx: commands.Context, user_id: int, warn_id: str):
"""Removes the specified warning from the user specified"""
if user_id == ctx.author.id:
await ctx.send(_("You cannot remove warnings from yourself."))
return
guild = ctx.guild
member = guild.get_member(user_id)
if member is None: # no longer in guild, but need a "member" object

View File

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

View File

@@ -138,6 +138,16 @@ def parse_cli_flags(args):
action="store_true",
help="Enables the built-in RPC server. Please read the docs prior to enabling this!",
)
parser.add_argument("--token", type=str, help="Run Red with the given token.")
parser.add_argument(
"--no-instance",
action="store_true",
help=(
"Run Red without any existing instance. "
"The data will be saved under a temporary folder "
"and deleted on next system restart."
),
)
parser.add_argument(
"instance_name", nargs="?", help="Name of the bot instance created during `redbot-setup`."
)

View File

@@ -137,11 +137,10 @@ class Group(Command, commands.Group):
"""
def __init__(self, *args, **kwargs):
self.autohelp = kwargs.pop("autohelp", False)
self.autohelp = kwargs.pop("autohelp", True)
super().__init__(*args, **kwargs)
async def invoke(self, ctx):
view = ctx.view
previous = view.index
view.skip_ws()
@@ -154,6 +153,7 @@ class Group(Command, commands.Group):
if ctx.invoked_subcommand is None or self == ctx.invoked_subcommand:
if self.autohelp and not self.invoke_without_command:
await self._verify_checks(ctx)
await ctx.send_help()
await super().invoke(ctx)

View File

@@ -152,6 +152,11 @@ class Context(commands.Context):
else:
return self.bot.color
@property
def embed_color(self):
# Rather than double awaiting.
return self.embed_colour
async def embed_requested(self):
"""
Simple helper to call bot.embed_requested

View File

@@ -29,12 +29,15 @@ class _ValueCtxManager:
def __init__(self, value_obj, coro):
self.value_obj = value_obj
self.coro = coro
self.raw_value = None
self.__original_value = None
def __await__(self):
return self.coro.__await__()
async def __aenter__(self):
self.raw_value = await self
self.__original_value = deepcopy(self.raw_value)
if not isinstance(self.raw_value, (list, dict)):
raise TypeError(
"Type of retrieved value must be mutable (i.e. "
@@ -44,7 +47,8 @@ class _ValueCtxManager:
return self.raw_value
async def __aexit__(self, *exc_info):
await self.value_obj.set(self.raw_value)
if self.raw_value != self.__original_value:
await self.value_obj.set(self.raw_value)
class Value:
@@ -335,7 +339,7 @@ class Group(Value):
default = poss_default
try:
return deepcopy(await self.driver.get(*self.identifiers, *path))
return await self.driver.get(*self.identifiers, *path)
except KeyError:
if default is not ...:
return default
@@ -365,7 +369,7 @@ class Group(Value):
"""
if not defaults:
defaults = deepcopy(self.defaults)
defaults = self.defaults
for key, value in current.items():
if isinstance(value, collections.Mapping):
@@ -392,7 +396,7 @@ class Group(Value):
# is equivalent to
data = {"foo": {"bar": None}}
d["foo"]["bar"] = "baz"
data["foo"]["bar"] = "baz"
Parameters
----------

View File

@@ -275,7 +275,7 @@ class Core(CoreLogic):
"".format(red_repo, author_repo, org_repo, support_server_url)
)
embed = discord.Embed(color=discord.Color.red())
embed = discord.Embed(color=(await ctx.embed_colour()))
embed.add_field(name="Instance owned by", value=str(owner))
embed.add_field(name="Python", value=python_version)
embed.add_field(name="discord.py", value=dpy_version)
@@ -321,7 +321,7 @@ class Core(CoreLogic):
return fmt.format(d=days, h=hours, m=minutes, s=seconds)
@commands.group(autohelp=True)
@commands.group()
async def embedset(self, ctx: commands.Context):
"""
Commands for toggling embeds on or off.
@@ -360,6 +360,7 @@ class Core(CoreLogic):
@embedset.command(name="guild")
@checks.guildowner_or_permissions(administrator=True)
@commands.guild_only()
async def embedset_guild(self, ctx: commands.Context, enabled: bool = None):
"""
Toggle the guild's embed setting.
@@ -598,7 +599,7 @@ class Core(CoreLogic):
pass
await ctx.bot.shutdown(restart=True)
@commands.group(name="set", autohelp=True)
@commands.group(name="set")
async def _set(self, ctx):
"""Changes Red's settings"""
if ctx.invoked_subcommand is None:
@@ -698,7 +699,7 @@ class Core(CoreLogic):
"""
Sets a default colour to be used for the bot's embeds.
Acceptable values cor the colour parameter can be found at:
Acceptable values for the colour parameter can be found at:
http://discordpy.readthedocs.io/en/rewrite/ext/commands/api.html#discord.ext.commands.ColourConverter
"""
@@ -981,7 +982,7 @@ class Core(CoreLogic):
ctx.bot.disable_sentry()
await ctx.send(_("Done. Sentry logging is now disabled."))
@commands.group(autohelp=True)
@commands.group()
@checks.is_owner()
async def helpset(self, ctx: commands.Context):
"""Manage settings for the help command."""
@@ -1264,7 +1265,7 @@ class Core(CoreLogic):
else:
await ctx.send(_("Message delivered to {}").format(destination))
@commands.group(autohelp=True)
@commands.group()
@checks.is_owner()
async def whitelist(self, ctx):
"""
@@ -1322,7 +1323,7 @@ class Core(CoreLogic):
await ctx.bot.db.whitelist.set([])
await ctx.send(_("Whitelist has been cleared."))
@commands.group(autohelp=True)
@commands.group()
@checks.is_owner()
async def blacklist(self, ctx):
"""

View File

@@ -2,15 +2,18 @@ import sys
import os
from pathlib import Path
from typing import List
from copy import deepcopy
import hashlib
import shutil
import logging
import appdirs
import tempfile
from .json_io import JsonIO
__all__ = [
"create_temp_config",
"load_basic_configuration",
"cog_data_path",
"core_data_path",
@@ -39,6 +42,26 @@ if not config_dir:
config_file = config_dir / "config.json"
def create_temp_config():
"""
Creates a default instance for Red, so it can be ran
without creating an instance.
.. warning:: The data of this instance will be removed
on next system restart.
"""
name = "temporary_red"
default_dirs = deepcopy(basic_config_default)
default_dirs["DATA_PATH"] = tempfile.mkdtemp()
default_dirs["STORAGE_TYPE"] = "JSON"
default_dirs["STORAGE_DETAILS"] = {}
config = JsonIO(config_file)._load_json()
config[name] = default_dirs
JsonIO(config_file)._save_json(config)
def load_basic_configuration(instance_name_: str):
"""Loads the basic bootstrap configuration necessary for `Config`
to know where to store or look for data.

View File

@@ -1,5 +1,6 @@
from pathlib import Path
from typing import Tuple
import copy
import weakref
import logging
@@ -97,7 +98,7 @@ class JSON(BaseDriver):
full_identifiers = (self.unique_cog_identifier, *identifiers)
for i in full_identifiers:
partial = partial[i]
return partial
return copy.deepcopy(partial)
async def set(self, *identifiers: str, value=None):
partial = self.data
@@ -107,7 +108,7 @@ class JSON(BaseDriver):
partial[i] = {}
partial = partial[i]
partial[full_identifiers[-1]] = value
partial[full_identifiers[-1]] = copy.deepcopy(value)
await self.jsonIO._threadsafe_save_json(self.data)
async def clear(self, *identifiers: str):

View File

@@ -82,10 +82,7 @@ class Help(formatter.HelpFormatter):
if self.pm_check(self.context):
return self.context.bot.color
else:
if await self.context.bot.db.guild(self.context.guild).use_bot_color():
return self.context.bot.color
else:
return self.me.color
return await self.context.embed_colour()
@property
def destination(self):

View File

@@ -0,0 +1,53 @@
import collections
class LRUDict:
"""
dict with LRU-eviction and max-size
This is intended for caching, it may not behave how you want otherwise
This uses collections.OrderedDict under the hood, but does not directly expose
all of it's methods (intentional)
"""
def __init__(self, *keyval_pairs, size):
self.size = size
self._dict = collections.OrderedDict(*keyval_pairs)
def __contains__(self, key):
if key in self._dict:
self._dict.move_to_end(key, last=True)
return True
return False
def __getitem__(self, key):
ret = self._dict.__getitem__(key)
self._dict.move_to_end(key, last=True)
return ret
def __setitem__(self, key, value):
if key in self._dict:
self._dict.move_to_end(key, last=True)
self._dict[key] = value
if len(self._dict) > self.size:
self._dict.popitem(last=False)
def __delitem__(self, key):
return self._dict.__delitem__(key)
def clear(self):
return self._dict.clear()
def pop(self, key):
return self._dict.pop(key)
# all of the below access all of the items, and therefore shouldnt modify the ordering for eviction
def keys(self):
return self._dict.keys()
def items(self):
return self._dict.items()
def values(self):
return self._dict.values()

View File

@@ -135,16 +135,10 @@ async def is_mod_or_superior(bot: Red, obj: Union[discord.Message, discord.Membe
if isinstance(obj, discord.Role):
return obj.id in [admin_role_id, mod_role_id]
mod_roles = [r for r in server.roles if r.id == mod_role_id]
mod_role = mod_roles[0] if len(mod_roles) > 0 else None
admin_roles = [r for r in server.roles if r.id == admin_role_id]
admin_role = admin_roles[0] if len(admin_roles) > 0 else None
if user and user == await bot.is_owner(user):
if await bot.is_owner(user):
return True
elif admin_role and discord.utils.get(user.roles, name=admin_role):
return True
elif mod_role and discord.utils.get(user.roles, name=mod_role):
elif discord.utils.find(lambda r: r.id in (admin_role_id, mod_role_id), user.roles):
return True
else:
return False
@@ -220,17 +214,14 @@ async def is_admin_or_superior(
else:
raise TypeError("Only messages, members or roles may be passed")
server = obj.guild
admin_role_id = await bot.db.guild(server).admin_role()
admin_role_id = await bot.db.guild(obj.guild).admin_role()
if isinstance(obj, discord.Role):
return obj.id == admin_role_id
admin_roles = [r for r in server.roles if r.id == admin_role_id]
admin_role = admin_roles[0] if len(admin_roles) > 0 else None
if user and await bot.is_owner(user):
return True
elif admin_roles and discord.utils.get(user.roles, name=admin_role):
elif discord.utils.get(user.roles, id=admin_role_id):
return True
else:
return False

View File

@@ -5,9 +5,11 @@ import subprocess
import sys
import argparse
import asyncio
import aiohttp
import pkg_resources
from pathlib import Path
from distutils.version import StrictVersion
from redbot.setup import (
basic_setup,
load_existing_config,
@@ -16,13 +18,14 @@ from redbot.setup import (
create_backup,
save_config,
)
from redbot.core import __version__
from redbot.core.utils import safe_delete
from redbot.core.cli import confirm
if sys.platform == "linux":
import distro
PYTHON_OK = sys.version_info >= (3, 5)
PYTHON_OK = sys.version_info >= (3, 6)
INTERACTIVE_MODE = not len(sys.argv) > 1 # CLI flags = non-interactive
INTRO = "==========================\nRed Discord Bot - Launcher\n==========================\n"
@@ -384,12 +387,27 @@ def debug_info():
sys.exit(0)
async def is_outdated():
red_pypi = "https://pypi.python.org/pypi/Red-DiscordBot"
async with aiohttp.ClientSession() as session:
async with session.get("{}/json".format(red_pypi)) as r:
data = await r.json()
new_version = data["info"]["version"]
return StrictVersion(new_version) > StrictVersion(__version__), new_version
def main_menu():
if IS_WINDOWS:
os.system("TITLE Red - Discord Bot V3 Launcher")
clear_screen()
loop = asyncio.get_event_loop()
outdated, new_version = loop.run_until_complete(is_outdated())
while True:
print(INTRO)
print("\033[4mCurrent version:\033[0m {}".format(__version__))
if outdated:
print("Red is outdated. {} is available.".format(new_version))
print("")
print("1. Run Red w/ autorestart in case of issues")
print("2. Run Red")
print("3. Update Red")
@@ -418,13 +436,12 @@ def main_menu():
basic_setup()
wait()
elif choice == "5":
asyncio.get_event_loop().run_until_complete(remove_instance_interaction())
loop.run_until_complete(remove_instance_interaction())
wait()
elif choice == "6":
debug_info()
elif choice == "7":
while True:
loop = asyncio.get_event_loop()
clear_screen()
print("==== Reinstall Red ====")
print(
@@ -455,7 +472,7 @@ def main_menu():
def main():
if not PYTHON_OK:
raise RuntimeError(
"Red requires Python 3.5 or greater. Please install the correct version!"
"Red requires Python 3.6 or greater. Please install the correct version!"
)
if args.debuginfo: # Check first since the function triggers an exit
debug_info()

View File

@@ -0,0 +1 @@
from .core import *

20
redbot/pytest/admin.py Normal file
View File

@@ -0,0 +1,20 @@
from unittest.mock import MagicMock
import pytest
from redbot.cogs.admin import Admin
from redbot.cogs.admin.announcer import Announcer
__all__ = ["admin", "announcer"]
@pytest.fixture()
def admin(config):
return Admin(config)
@pytest.fixture()
def announcer(admin):
a = Announcer(MagicMock(), "Some message", admin.conf)
yield a
a.cancel()

13
redbot/pytest/alias.py Normal file
View File

@@ -0,0 +1,13 @@
import pytest
from redbot.cogs.alias import Alias
from redbot.core import Config
__all__ = ["alias"]
@pytest.fixture()
def alias(config, monkeypatch):
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
return Alias(None)

View File

@@ -0,0 +1,13 @@
import pytest
__all__ = ["cog_mgr", "default_dir"]
@pytest.fixture()
def cog_mgr(red):
return red.cog_mgr
@pytest.fixture()
def default_dir(red):
return red.main_dir

View File

@@ -9,6 +9,26 @@ from redbot.core.bot import Red
from redbot.core.drivers import red_json
__all__ = [
"monkeysession",
"override_data_path",
"coroutine",
"json_driver",
"config",
"config_fr",
"red",
"guild_factory",
"empty_guild",
"empty_channel",
"empty_member",
"empty_message",
"empty_role",
"empty_user",
"member_factory",
"user_factory",
"ctx",
]
@pytest.fixture(scope="session")
def monkeysession(request):

View File

@@ -0,0 +1,24 @@
import pytest
from redbot.core import data_manager
__all__ = ["cleanup_datamanager", "data_mgr_config", "cog_instance"]
@pytest.fixture(autouse=True)
def cleanup_datamanager():
data_manager.basic_config = None
data_manager.jsonio = None
@pytest.fixture()
def data_mgr_config(tmpdir):
default = data_manager.basic_config_default.copy()
default["BASE_DIR"] = str(tmpdir)
return default
@pytest.fixture()
def cog_instance():
thing = type("CogTest", (object,), {})
return thing()

View File

@@ -0,0 +1,12 @@
from pathlib import Path
from redbot.cogs.dataconverter import core_specs
__all__ = ["get_specresolver"]
def get_specresolver(path):
here = Path(path)
resolver = core_specs.SpecResolver(here.parent)
return resolver

103
redbot/pytest/downloader.py Normal file
View File

@@ -0,0 +1,103 @@
from collections import namedtuple
from pathlib import Path
import json
import pytest
from redbot.cogs.downloader.repo_manager import RepoManager, Repo
from redbot.cogs.downloader.installable import Installable
__all__ = [
"patch_relative_to",
"repo_manager",
"repo",
"repo_norun",
"bot_repo",
"INFO_JSON",
"installable",
"fake_run_noprint",
]
async def fake_run(*args, **kwargs):
fake_result_tuple = namedtuple("fake_result", "returncode result")
res = fake_result_tuple(0, (args, kwargs))
print(args[0])
return res
async def fake_run_noprint(*args, **kwargs):
fake_result_tuple = namedtuple("fake_result", "returncode result")
res = fake_result_tuple(0, (args, kwargs))
return res
@pytest.fixture(scope="module", autouse=True)
def patch_relative_to(monkeysession):
def fake_relative_to(self, some_path: Path):
return self
monkeysession.setattr("pathlib.Path.relative_to", fake_relative_to)
@pytest.fixture
def repo_manager(tmpdir_factory):
rm = RepoManager()
# rm.repos_folder = Path(str(tmpdir_factory.getbasetemp())) / 'repos'
return rm
@pytest.fixture
def repo(tmpdir):
repo_folder = Path(str(tmpdir)) / "repos" / "squid"
repo_folder.mkdir(parents=True, exist_ok=True)
return Repo(
url="https://github.com/tekulvw/Squid-Plugins",
name="squid",
branch="rewrite_cogs",
folder_path=repo_folder,
)
@pytest.fixture
def repo_norun(repo):
repo._run = fake_run
return repo
@pytest.fixture
def bot_repo(event_loop):
cwd = Path.cwd()
return Repo(
name="Red-DiscordBot",
branch="WRONG",
url="https://empty.com/something.git",
folder_path=cwd,
loop=event_loop,
)
# Installable
INFO_JSON = {
"author": ("tekulvw",),
"bot_version": (3, 0, 0),
"description": "A long description",
"hidden": False,
"install_msg": "A post-installation message",
"required_cogs": {},
"requirements": ("tabulate"),
"short": "A short description",
"tags": ("tag1", "tag2"),
"type": "COG",
}
@pytest.fixture
def installable(tmpdir):
cog_path = tmpdir.mkdir("test_repo").mkdir("test_cog")
info_path = cog_path.join("info.json")
info_path.write_text(json.dumps(INFO_JSON), "utf-8")
cog_info = Installable(Path(str(cog_path)))
return cog_info

15
redbot/pytest/economy.py Normal file
View File

@@ -0,0 +1,15 @@
import pytest
__all__ = ["bank"]
@pytest.fixture()
def bank(config, monkeypatch):
from redbot.core import Config
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
from redbot.core import bank
bank._register_defaults()
return bank

15
redbot/pytest/mod.py Normal file
View File

@@ -0,0 +1,15 @@
import pytest
__all__ = ["mod"]
@pytest.fixture
def mod(config, monkeypatch):
from redbot.core import Config
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
from redbot.core import modlog
modlog._register_defaults()
return modlog

51
redbot/pytest/rpc.py Normal file
View File

@@ -0,0 +1,51 @@
import pytest
from redbot.core.rpc import RPC, RPCMixin
from unittest.mock import MagicMock
__all__ = ["rpc", "rpcmixin", "cog", "existing_func", "existing_multi_func"]
@pytest.fixture()
def rpc():
return RPC()
@pytest.fixture()
def rpcmixin():
r = RPCMixin()
r.rpc = MagicMock(spec=RPC)
return r
@pytest.fixture()
def cog():
class Cog:
async def cofunc(*args, **kwargs):
pass
async def cofunc2(*args, **kwargs):
pass
async def cofunc3(*args, **kwargs):
pass
def func(*args, **kwargs):
pass
return Cog()
@pytest.fixture()
def existing_func(rpc, cog):
rpc.add_method(cog.cofunc)
return cog.cofunc
@pytest.fixture()
def existing_multi_func(rpc, cog):
funcs = [cog.cofunc, cog.cofunc2, cog.cofunc3]
rpc.add_multi_method(*funcs)
return funcs

View File

@@ -34,7 +34,7 @@ def check_compiler_available():
tfile.write(b"int main(int argc, char** argv) {return 0;}")
tfile.seek(0)
try:
m.compile([tfile.name])
m.compile([tfile.name], output_dir=tdir)
except (CCompilerError, DistutilsPlatformError):
return False
return True
@@ -111,10 +111,10 @@ setup(
classifiers=[
"Development Status :: 4 - Beta",
"Framework :: AsyncIO",
"Framework :: Pytest",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Topic :: Communications :: Chat",
"Topic :: Documentation :: Sphinx",
@@ -124,7 +124,8 @@ setup(
"redbot=redbot.__main__:main",
"redbot-setup=redbot.setup:main",
"redbot-launcher=redbot.launcher:main",
]
],
"pytest11": ["red-discordbot = redbot.pytest"],
},
python_requires=">=3.6,<3.7",
setup_requires=get_requirements(),

View File

@@ -2,20 +2,7 @@ from unittest.mock import MagicMock
import pytest
from redbot.cogs.admin import Admin
from redbot.cogs.admin.announcer import Announcer
@pytest.fixture()
def admin(config):
return Admin(config)
@pytest.fixture()
def announcer(admin):
a = Announcer(MagicMock(), "Some message", admin.conf)
yield a
a.cancel()
from redbot.pytest.admin import *
@pytest.mark.asyncio

View File

@@ -1,8 +1,7 @@
import pytest
from pathlib import Path
from collections import namedtuple
from redbot.cogs.dataconverter import core_specs
from redbot.pytest.dataconverter import *
from redbot.core.utils.data_converter import DataConverter
@@ -14,16 +13,9 @@ def mock_dpy_member(guildid, userid):
return namedtuple("Member", "id guild")(int(userid), mock_dpy_object(guildid))
@pytest.fixture()
def specresolver():
here = Path(__file__)
resolver = core_specs.SpecResolver(here.parent)
return resolver
@pytest.mark.asyncio
async def test_mod_nicknames(red, specresolver: core_specs.SpecResolver):
async def test_mod_nicknames(red):
specresolver = get_specresolver(__file__)
filepath, converter, cogname, attr, _id = specresolver.get_conversion_info("Past Nicknames")
conf = specresolver.get_config_object(red, cogname, attr, _id)

View File

@@ -6,69 +6,12 @@ import pytest
from unittest.mock import MagicMock
from raven.versioning import fetch_git_sha
from redbot.pytest.downloader import *
from redbot.cogs.downloader.repo_manager import RepoManager, Repo
from redbot.cogs.downloader.errors import ExistingGitRepo
async def fake_run(*args, **kwargs):
fake_result_tuple = namedtuple("fake_result", "returncode result")
res = fake_result_tuple(0, (args, kwargs))
print(args[0])
return res
async def fake_run_noprint(*args, **kwargs):
fake_result_tuple = namedtuple("fake_result", "returncode result")
res = fake_result_tuple(0, (args, kwargs))
return res
@pytest.fixture(scope="module", autouse=True)
def patch_relative_to(monkeysession):
def fake_relative_to(self, some_path: Path):
return self
monkeysession.setattr("pathlib.Path.relative_to", fake_relative_to)
@pytest.fixture
def repo_manager(tmpdir_factory):
rm = RepoManager()
# rm.repos_folder = Path(str(tmpdir_factory.getbasetemp())) / 'repos'
return rm
@pytest.fixture
def repo(tmpdir):
repo_folder = Path(str(tmpdir)) / "repos" / "squid"
repo_folder.mkdir(parents=True, exist_ok=True)
return Repo(
url="https://github.com/tekulvw/Squid-Plugins",
name="squid",
branch="rewrite_cogs",
folder_path=repo_folder,
)
@pytest.fixture
def repo_norun(repo):
repo._run = fake_run
return repo
@pytest.fixture
def bot_repo(event_loop):
cwd = Path.cwd()
return Repo(
name="Red-DiscordBot",
branch="WRONG",
url="https://empty.com/something.git",
folder_path=cwd,
loop=event_loop,
)
def test_existing_git_repo(tmpdir):
repo_folder = Path(str(tmpdir)) / "repos" / "squid" / ".git"
repo_folder.mkdir(parents=True, exist_ok=True)

View File

@@ -3,31 +3,9 @@ from pathlib import Path
import pytest
from redbot.pytest.downloader import *
from redbot.cogs.downloader.installable import Installable, InstallableType
INFO_JSON = {
"author": ("tekulvw",),
"bot_version": (3, 0, 0),
"description": "A long description",
"hidden": False,
"install_msg": "A post-installation message",
"required_cogs": {},
"requirements": ("tabulate"),
"short": "A short description",
"tags": ("tag1", "tag2"),
"type": "COG",
}
@pytest.fixture
def installable(tmpdir):
cog_path = tmpdir.mkdir("test_repo").mkdir("test_cog")
info_path = cog_path.join("info.json")
info_path.write_text(json.dumps(INFO_JSON), "utf-8")
cog_info = Installable(Path(str(cog_path)))
return cog_info
def test_process_info_file(installable):
for k, v in INFO_JSON.items():

View File

@@ -1,14 +1,5 @@
import pytest
from redbot.cogs.alias import Alias
from redbot.core import Config
@pytest.fixture()
def alias(config, monkeypatch):
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
return Alias(None)
from redbot.pytest.alias import *
def test_is_valid_alias_name(alias):

View File

@@ -1,16 +1,5 @@
import pytest
@pytest.fixture()
def bank(config, monkeypatch):
from redbot.core import Config
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
from redbot.core import bank
bank._register_defaults()
return bank
from redbot.pytest.economy import *
@pytest.mark.asyncio

View File

@@ -1,16 +1,6 @@
import pytest
@pytest.fixture
def mod(config, monkeypatch):
from redbot.core import Config
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
from redbot.core import modlog
modlog._register_defaults()
return modlog
from redbot.pytest.mod import *
@pytest.mark.asyncio

View File

@@ -2,19 +2,10 @@ from pathlib import Path
import pytest
from redbot.pytest.cog_manager import *
from redbot.core import cog_manager
@pytest.fixture()
def cog_mgr(red):
return red.cog_mgr
@pytest.fixture()
def default_dir(red):
return red.main_dir
@pytest.mark.skip
@pytest.mark.asyncio
async def test_ensure_cogs_in_paths(cog_mgr, default_dir):

View File

@@ -1,3 +1,4 @@
from unittest.mock import patch
import pytest
@@ -379,13 +380,9 @@ async def test_value_ctxmgr_saves(config):
async def test_value_ctxmgr_immutable(config):
config.register_global(foo=True)
try:
with pytest.raises(TypeError):
async with config.foo() as foo:
foo = False
except TypeError:
pass
else:
raise AssertionError
foo = await config.foo()
assert foo is True
@@ -401,3 +398,35 @@ async def test_ctxmgr_no_shared_default(config, member_factory):
foo.append(1)
assert 1 not in await config.member(m2).foo()
@pytest.mark.asyncio
async def test_ctxmgr_no_unnecessary_write(config):
config.register_global(foo=[])
foo_value_obj = config.foo
with patch.object(foo_value_obj, "set") as set_method:
async with foo_value_obj() as foo:
pass
set_method.assert_not_called()
@pytest.mark.asyncio
async def test_get_then_mutate(config):
"""Tests that mutating an object after getting it as a value doesn't mutate the data store."""
config.register_global(list1=[])
await config.list1.set([])
list1 = await config.list1()
list1.append("foo")
list1 = await config.list1()
assert "foo" not in list1
@pytest.mark.asyncio
async def test_set_then_mutate(config):
"""Tests that mutating an object after setting it as a value doesn't mutate the data store."""
config.register_global(list1=[])
list1 = []
await config.list1.set(list1)
list1.append("foo")
list1 = await config.list1()
assert "foo" not in list1

View File

@@ -3,28 +3,10 @@ from pathlib import Path
import pytest
from redbot.pytest.data_manager import *
from redbot.core import data_manager
@pytest.fixture(autouse=True)
def cleanup_datamanager():
data_manager.basic_config = None
data_manager.jsonio = None
@pytest.fixture()
def data_mgr_config(tmpdir):
default = data_manager.basic_config_default.copy()
default["BASE_DIR"] = str(tmpdir)
return default
@pytest.fixture()
def cog_instance():
thing = type("CogTest", (object,), {})
return thing()
def test_no_basic(cog_instance):
with pytest.raises(RuntimeError):
data_manager.core_data_path()

View File

@@ -1,52 +1,7 @@
import pytest
from redbot.core.rpc import RPC, RPCMixin, get_name
from unittest.mock import MagicMock
@pytest.fixture()
def rpc():
return RPC()
@pytest.fixture()
def rpcmixin():
r = RPCMixin()
r.rpc = MagicMock(spec=RPC)
return r
@pytest.fixture()
def cog():
class Cog:
async def cofunc(*args, **kwargs):
pass
async def cofunc2(*args, **kwargs):
pass
async def cofunc3(*args, **kwargs):
pass
def func(*args, **kwargs):
pass
return Cog()
@pytest.fixture()
def existing_func(rpc, cog):
rpc.add_method(cog.cofunc)
return cog.cofunc
@pytest.fixture()
def existing_multi_func(rpc, cog):
funcs = [cog.cofunc, cog.cofunc2, cog.cofunc3]
rpc.add_multi_method(*funcs)
return funcs
from redbot.pytest.rpc import *
from redbot.core.rpc import get_name
def test_get_name(cog):