Compare commits

...

121 Commits

Author SHA1 Message Date
Toby Harradine
32bd47e105 Bump version to 3.0.0rc3.post1
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-11 13:09:40 +11:00
Toby Harradine
1bb5d698cc Make Travis only do py36 tox when deploying
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-11 13:04:00 +11:00
Toby Harradine
9752a9c719 Bump version to 3.0.0rc3 (#2367)
Also updated some dependencies, including discord.py.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-11 11:10:01 +11:00
Toby Harradine
7973babe4b Catch exceptions in [p]backup (#2363)
Resolves #2354.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-11 09:07:37 +11:00
Michael H
78e4b578e2 [Utils] Tunnel minor fixes (#2366)
- Tunnel uses a safe max size (Max size is related to maximum payload, not maximum file size)
  - Checks attachment sizes prior to download
2019-01-10 19:46:49 +11:00
Toby Harradine
8eb8848898 [Mod] Context-based voice checks (#2351)
- Removed `redbot.cogs.mod.checks` module
- Moved logic for formatting a user-friendly list of permissions to `redbot.core.utils.chat_formatting`
- `[p]voice(un)ban` and `[p](un)mute voice` now check permissions in the user's voice channel

Resolves #2296.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-10 11:35:37 +11:00
Toby Harradine
aac1460240 [Utils] Exit menu silently when message is deleted (#2344)
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-10 11:33:38 +11:00
Toby Harradine
dde5582669 [CogManager] Removal of implicit paths and general cleanup (#2345)
- Removed memory-sided `CogManager._paths` attribute, as it has no practical use.
- `[p]removepath` now removes the actual path displayed with the index specified in `[p]paths`.
- New method for retreiving a deduplicated list of user-defined paths as `Path` objects
- General cleanup so we don't have to do so much head-scratching next time an issue arises here

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2019-01-06 11:02:58 +11:00
Toby Harradine
aa854cf1f9 [CustomCom] Insert space before newline separating CC previews (#2350)
Resolves #2295.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-12-29 16:54:32 +01:00
Toby Harradine
2bd05a5a04 Fix JSON to Mongo migration (#2346)
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-12-29 10:51:31 +11:00
Toby Harradine
3a8da1f82b [Cleanup] Fix cleanup after
Resolves #2343.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-12-22 09:19:25 +11:00
Toby Harradine
811634a2b0 [Permissions] Help menu respects rules (#2339)
Resolves #2249.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-12-21 13:30:13 +11:00
Toby Harradine
2512320b30 [DataManager] Don't copy bundled data to cog data folder (#2342)
- `load_bundled_data` is now deprecated and obselete
- Bundled data is now no longer copied to the cog's data folder, greatly simplifying its operations under the hood. Instead, `bundled_data_path` will now return the path to the folder from which the files would have previously been copied.

Resolves #2329.

Resolves #2280.
2018-12-21 13:26:00 +11:00
Michael H
db03faf042 Make webhooks automod immune (#2337)
Resolves #2336.
2018-12-21 11:43:46 +11:00
Toby Harradine
701259158f [Permissions] Always respect default rules (#2341)
Resolves #2340.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-12-21 10:35:17 +11:00
Kowlin
a5efdc6492 [Mod] Handle invalid nicknames in [p]rename (#2311) 2018-12-16 11:21:36 +11:00
Kowlin
38b15ded87 [Mod] Fixed loud RuntimeError on modlog cases (#2331) 2018-12-16 10:26:48 +11:00
Michael H
351749dff6 [Permissions] Find things uniquely for models (#2258)
This is a safety measure to prevent accidentally passing a model which has the same name as another model, potentially modifying rules for the unwanted one.
2018-12-16 10:09:18 +11:00
Michael H
2d9912cea7 prevent locking out owner (#2317) 2018-12-13 18:38:03 +01:00
NNTin
c4ab34a049 V3: regex extension on java -version (#2316)
* regex extension on java -version

* make it a non capturing group

* alphanumeric matching

* Match specification: Style, line length

* Update manager.py
2018-12-13 18:28:00 +01:00
Michael H
985e7b3c6d swap unsafe yaml.load usage for yaml.safe_load (#2324)
Related to #2323

Recommend additionally adding a step in CI
ensuring use of `yaml.load` is prevented from existing in the code base.
2018-12-13 18:19:27 +01:00
palmtree5
7546c50226 [Streams] Toggle mentions on for roles being alerted (#2252)
Resolves #1831.
2018-11-24 10:46:38 +11:00
aikaterna
6435f6b882 [Audio] Match openJDK 11 on Ubuntu (#2270)
Using the OpenJDK 11 from java.net on Ubuntu 18 reports "11" as the version, which failed the Java version check on loading audio on a new instance. This change will return "11 0" as the version, passing the check, instead of just "11".
2018-11-24 10:45:37 +11:00
aikaterna
bbccb671b8 [Audio] Disallow seek during active vote (#2290)
Users were able to use seek to skip songs while voteskip was active. This change prevents users from doing so.
2018-11-24 10:43:23 +11:00
Twentysix
8abb24bc01 [Core] [p]leave: Fix incorrect response handling (#2302) 2018-11-24 10:42:05 +11:00
aikaterna
419008f644 [Downloader] Findcog fix with no repo installed (#2304)
The findcog command errored out if a repo was removed. This change informs the user that the repo is not installed instead of erroring out.
2018-11-24 10:39:54 +11:00
odinair
d17c2430d7 [Streams] Fix alerts with no configured mentions (#2305) 2018-11-24 10:14:43 +11:00
Michael H
ca533f8937 [JsonIO] race condition fix (#2308)
* race condition fix

* style fix
2018-11-24 10:11:59 +11:00
Michael H
9d22d5b7b5 [Cleanup] Correct handling of command message (#2310)
Resolves #2307
2018-11-24 10:04:08 +11:00
Kowlin
2846dce6ea [Mod] [p]modset: Fix KeyError (#2279)
Co-authored-by: aikaterna <20862007+aikaterna@users.noreply.github.com>
2018-11-14 23:35:56 +01:00
aikaterna
9973b2e3b8 [General] [p]urban: Handle no embeds (#2285) 2018-11-14 23:21:11 +01:00
aikaterna
d008a2559a [General] Clearer error message for [p]rps (#2284) 2018-11-14 22:56:08 +01:00
aikaterna
7f3a0b8a88 [Streams] Add help for streamset (#2287) 2018-11-06 10:26:55 +11:00
aikaterna
d0fca373ba [Audio] Local track verify on playlist start (#2271) 2018-11-06 09:44:26 +11:00
FixedThink
451c4c9d54 [Trivia] Fix text formatting issue (#2269)
The "Question number {}" message was being emboldened twice from the left side, making the bot send messages with two redundant asterisks.
2018-11-06 08:58:10 +11:00
zephyrkul
a59002275d [Economy] Fix TypeError in [p]payouts (#2263) 2018-11-06 08:55:29 +11:00
Toby Harradine
99bbde7be9 Reject bad perm kwargs in check decorators (#2289)
Also fixed a misspelled kwarg in reports.

Also now raising TypeError for an empty `@checks.has_permissions()` decorator.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-11-05 15:07:56 +11:00
Kowlin
92dbd14006 Fixes typos in permissions (#2288)
causing the checks to be thrown out of the window.
2018-11-05 04:19:54 +01:00
palmtree5
6e9243f6e9 Drop unneeded .format in [p]triviaset override (#2268) 2018-10-23 17:40:51 -08:00
Toby Harradine
8bba860f85 Bump version to 3.0.0rc2
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-16 09:37:23 +11:00
Toby Harradine
d2d26835c3 [Economy] Detect max balance and prevent OverflowError (#2211)
Resolves #2091.

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

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

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

Resolves #2104.

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

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

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

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

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

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

Resolves #2106.

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

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

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

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

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

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

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

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

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

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

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

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

i18n addditions: DJ Role toggle, Thumbnail display toggle

`audioset settings`: added missed end of line return

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

`local play` and `local folder` now display the appropriate menu when DJ role is on.
2018-10-07 08:42:13 +11:00
aikaterna
4369095a51 [Economy] Fix for bank set (#2192)
i18n variable was wrong.
2018-10-07 08:26:33 +11:00
Toby Harradine
1c706e8c45 Bump version to 3.0.0rc1.post1
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 19:25:14 +10:00
Toby Harradine
91029b73e5 Use our own redbot.core.VersionInfo over distutils.StrictVersion (#2188)
* Implements our required subset of PEP 440 in redbot.core.VersionInfo
* Added unit tests for version string parsing and comparisons

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 19:11:05 +10:00
zephyrkul
de4b42a11e [mongo] fix missing URI variable assignment (#2186) 2018-10-06 14:48:03 +10:00
Toby Harradine
03230b6386 Remove branch condition from Travis deployment
So we can automatically deploy from release branches
2018-10-06 14:07:27 +10:00
Toby Harradine
4dbf2796c0 Bump version to 3.0.0rc1
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 10:26:59 +10:00
Twentysix
03892f5ef1 [Utils] Markdown helpers escape special chars (#2182)
Escaping italics (`*`) that get passed to `italics()` doesn't work, the string still won't show up properly.
2018-10-06 10:14:11 +10:00
bobloy
fdf3f86ab0 [Utils] Allow menu() to be used DM (#2183)
`ctx.me` handles using `ctx.guild.me` if `ctx.guild is not None`
`ctx.guild.me` directly errors in DMs.
2018-10-06 10:02:09 +10:00
Toby Harradine
7b15ad5989 Merge branch V3/feature/i18n_pass into V3/develop (#2024)
[i18n] Improves the coverage and quality of extracted user-facing string literals in the `redbot.core` package and makes them less ambiguous for the translator.
2018-10-06 09:58:45 +10:00
Toby Harradine
443f2ca556 [i18n] Pass over modlog, permissions, reports, streams, trivia, warnings
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 09:01:04 +10:00
Toby Harradine
fa692ccc0b [i18n] Pass over economy, filter, general, image, mod
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 09:00:31 +10:00
Toby Harradine
0c3d8af8f4 [i18n] Pass over bank, cleanup, customcom, dataconverter, downloader
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 08:57:49 +10:00
Toby Harradine
3a20c11331 [i18n] User-facing string pass over admin, alias and audio 2018-10-06 08:43:19 +10:00
Michael H
aa8c9c350e [i18n] Start work on named format arguments (#1795) 2018-10-06 08:42:38 +10:00
Michael H
139329233a [Utils/Trivia] Handle smart quotes (#2162)
Adds a new filter function for substituting out smart-quotes.

Makes trivia use it.
2018-10-06 08:39:52 +10:00
Michael H
d79996aeea [Downloader] Better user facing feedback on cog update (#2165) 2018-10-06 08:24:55 +10:00
Toby Harradine
fb839084fe Merge branch V3/feature/predicates into V3/develop (#1986)
Predicate utility to replace common boilerplate predicates for event waiting.
2018-10-06 08:15:25 +10:00
Toby Harradine
dea9dde637 [Utils] Finish and Refactor Predicate Utility (#2169)
* Uses classmethods to create predicates
* Classmethods allow using a combination of different parameters to describe context
* Some predicates assign a captured `result` to the predicate object on success
* Added `ReactionPredicate` equivalent to `MessagePredicate`
* Added `utils.menus.start_adding_reactions`, a non-blocking method for adding reactions asynchronously
* Added documentation
* Uses these new utils throughout the core bot
Happened to also find some bugs in places, and places where we were waiting for events without catching `asyncio.TimeoutError`

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-06 08:07:09 +10:00
zephyrkul
ebc657dcc6 [Config] Add Group.clear_raw method (#2178)
Adds a `clear_raw` method to Group objects, similar to the existing `get_raw` and `set_raw` methods.
Documentation included.
2018-10-05 13:03:27 +10:00
zephyrkul
80506856fb [Checks] Preserve backwards compatibility in deprecated functions (#2176)
* [checks] preserve backwards compatability

* [checks] use correct ctx.author
2018-10-04 10:26:44 +10:00
palmtree5
93a0e45c34 [Filter] implement exempting channels from the filter (#2064) 2018-10-03 08:43:04 +10:00
bobloy
3cb2b95121 [V3 Cleanup] Cleanup Before command (#2171)
Adds the ability to cleanup before a specified message id. Requires passing a number of messages to delete to keep with syntax of cleanup self/user
2018-10-03 08:28:10 +10:00
bobloy
a04869ab27 [Trivia] Fix misuse of list.append (#2172) 2018-10-03 08:22:42 +10:00
Twentysix
c69b1185d3 [Black] Don't normalize underscores in numeric literals (#2174) 2018-10-03 08:19:30 +10:00
Toby Harradine
ad7466a026 Dependency Update (#2175)
##### Core requirements
* _discord.py_ Rapptz/discord.py@77239e4 -> Rapptz/discord.py@836ae73
* _aiohttp-json-rpc_ 0.11.1 -> 0.11.2
* _aiohttp_ 3.3.2 -> 3.4.4

##### [test]
* _pytest_ 3.8.1 -> 3.8.2

##### [docs]
* _packaging_ 17.1 -> 18.0
* _pyparsing_ 2.2.1 -> 2.2.2
* _six_ Removed duplicate entry

##### [style]
* _black_ 18.6b4 -> 18.9b0
* _click_ 6.7 -> 7.0

### Notes
- `extra_requires` in setup.py is now a module-level global
- Some style changes have occurred after the _black_ update

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-03 08:10:13 +10:00
zephyrkul
54dad2a604 [Mod] Use list.copy() for tempban expirations (#2166)
Fixes an error left behind from #2161 (modifying a list while iterating over it)
2018-10-02 18:46:11 +10:00
bobloy
d5899fae83 [RPC] Add missing await in rpc_load (#2167) 2018-10-02 18:44:38 +10:00
Redjumpman
5d44bfabed [Utils] Initial Work on Predicate Utility (#1985)
* Add files via upload

* Update predicates.py

Changed sender from a discord.Member object to ctx.
Added a channel check.
Combined the same method and channel method into a validator and applied through-out.
valid_role and has_role methods now check for either an id or a name.
contained now uses string.lower() when testing for membership in a collection.

Signed-off-by: Redjumpman <redjumpman@users.noreply.github.com>
2018-10-02 16:19:40 +10:00
Ryan
b6c8be5f43 [MongoDB] Support mongodb+srv protocol (#2159) 2018-10-01 16:49:29 +10:00
aikaterna
b2abfc5710 [Audio] Playlist list and notify msg changes (#2155) 2018-10-01 16:44:46 +10:00
zephyrkul
a9b328ff3c [Mod] Remove tempban from data after unbanning (#2161)
Resolves #2160.

Also resolves another issue where the bot will error out when getting the guild's invites when it doesn't have the Manage Server permission.
2018-10-01 14:15:51 +10:00
Toby Harradine
0870403299 Permissions redesign (#2149)
API changes:
- Cogs must now inherit from `commands.Cog` (see #2151 for discussion and more details)
- All functions which are not decorators in the `redbot.core.checks` module are now deprecated in favour of their counterparts in `redbot.core.utils.mod`. This is to make this module more consistent and end the confusing naming convention.
- `redbot.core.checks.check_overrides` function is now gone, overrideable checks can now be created with the `@commands.permissions_check` decorator
- Command, Group, Cog and Context have some new attributes and methods, but they are for internal use so shouldn't concern cog creators (unless they're making a permissions cog!).
- `__permissions_check_before` and `__permissions_check_after` have been replaced:  A cog method named `__permissions_hook` will be evaluated as permissions hooks in the same way `__permissions_check_before` previously was. Permissions hooks can also be added/removed/verified through the new `*_permissions_hook()` methods on the bot object, and they will be verified even when permissions is unloaded.
- New utility method `redbot.core.utils.chat_formatting.humanize_list`
- New dependency [`schema`](https://github.com/keleshev/schema)

User-facing changes:
- When a `@bot_has_permissions` check fails, the bot will respond saying what permissions were actually missing.
- All YAML-related `[p]permissions` subcommands now reside under the `[p]permissions acl` sub-group (tbh I still think the whole cog has too many top-level commands)
- The YAML schema for these commands has been changed
- A rule cannot be set as allow and deny at the same time (previously this would just default to allow)

Documentation:
- New documentation for `redbot.core.commands.requires` and `redbot.core.checks` modules
- Renewed documentation for the permissions cog
- `sphinx.ext.doctest` is now enabled

Note: standard discord.py checks will still behave exactly the same way, in fact they are checked before `Requires` is looked at, so they are not overrideable. 

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-10-01 13:19:25 +10:00
Toby Harradine
f07b78bd0d Fix help command with cogs (#2156)
This bug was introduced in #2122 (whoops)

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-30 14:19:25 +10:00
Toby Harradine
b2497386bb Update dependencies (#2148)
Core dependencies:
- discord.py: Rapptz/discord.py@00a659c6 -> Rapptz/discord.py@00a659c6
- multidict: 4.4.0 -> 4.4.2

[docs]
- pyparsing: 2.2.0 -> 2.2.1
- sphinx: 1.7.8 -> 1.7.9

[test]
- pytest: 3.7.4 -> 3.8.1

[style]
- toml: 0.9.4 -> 0.9.6

----

I've also replaced usages of `discord.utils.get(guild.roles, id=id)` with the new O(1) `guild.get_role(id)` method.

Signed-off-by: Toby <tobyharradine@gmail.com>
2018-09-25 16:09:36 +10:00
Michael H
f8558b98c1 Automated mod action immunity settings (#2129)
Refactors, and fixes some logic in filter which was encountered while
applying the settings to core
2018-09-25 11:30:28 +10:00
Michael H
84ac5f3952 JsonIO atomic write improvements (#2132)
Use `async with Lock` instead of deprecated `with await lock` usage.

Forces a file fsync prior and a directory fsync (where available) after rename to prevent issues with left behind temp files.

Also should clarify: this is not threadsafe. Comments were clarified, function names remain misleading.
2018-09-24 18:29:14 +10:00
Michael H
404800c556 Hackban fixes (#2128)
If the member is in the guild, delegates to existing ban logic.

Additionally, check that we have ban perms prior to attempting to fetch the existing ban list.

Fixes #2127.
2018-09-24 18:21:21 +10:00
Toby Harradine
415385b747 [Downloader] Fix git pull command (#2146)
I somehow managed to botch this in #2121

Signed-off-by: Toby Harradine <t.harradine@student.unsw.edu.au>
2018-09-24 13:20:30 +10:00
Toby Harradine
f7dbaca340 Refactor fuzzy help and clean up help command (#2122)
What's changed:
- Fixed issues mentioned on #2031
- Fuzzy help displays more like help manual
- Fuzzy help is easier and more flexible to use
- Fuzzy help string-matching ratio lowered to 80
- Help formatter is more extendable
- Help command has been optimized, cleaned up and better incorporates fuzzy help
- Added async_filter and async_enumerate utility functions because I was using them for this PR, then no longer needed them, but decided they might be useful anyway.
- Added `Context.me` property which is a shortcut to `Context.guild.me` or `Context.bot.user`, depending on the channel type.
2018-09-24 10:34:39 +10:00
bobloy
32b4c6ce86 [Image] Remove V2 relics from command decorators (#2138) 2018-09-23 23:44:01 +10:00
zephyrkul
a3c36d4bde NoneType check for module in should_log_sentry (#2139)
Allows for a lack of module (which returns False) for `should_log_sentry`. This allows for, say, commands to be added by the Dev cog. ( ͡ಠ ʖ̯ ͡ಠ)
2018-09-23 23:42:25 +10:00
bobloy
fc4703ef80 [Launcher] Don't ask for CLI flags when no instances (#2142)
Seems to have been fixed in #1497 but accidentally reverted at a later date.
2018-09-23 23:39:32 +10:00
zephyrkul
a301b2c758 Add missing @staticmethod to Red.send_filtered (#2143)
Method was previously missing `self` argument but was missing static decorator.
2018-09-23 23:34:57 +10:00
Toby Harradine
e27682abd3 [Downloader] More robust repo loading (#2121)
Previously, when downloader was loaded, the RepoManager would spawn a task to load available repos. If one repo failed loading for some reason, the function would raise and the remaining repos would never be loaded, however downloader would still appear to load correctly.

This change handles exceptions better during repo loading, but also, if an unhandled exception is raised, downloader will fail to load as it should.

Also included, as requested in #1968, is the --recurse-submodules flag in cloning/pulling repositories.

This change resolves #1950.

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-22 15:05:42 +10:00
Toby Harradine
df922a0e3e [Audio] More robust startup and unload (#2118)
* More robust cleanup for audio and streams

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>

* Remove copied code from streams

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-22 15:04:46 +10:00
aikaterna
51c83d958c [Audio] Check if player is playing when pausing (#2130) 2018-09-18 23:54:32 +10:00
Toby Harradine
17139ce439 Fix compiler detection on Windows (#2136)
Signed-off-by: Toby Harradine <t.harradine@student.unsw.edu.au>
2018-09-18 14:07:50 +10:00
zephyrkul
61652a0306 [V3 CustomCommands] Cooldowns (#2124)
* customcom cooldowns

allows you to set multiple different cooldowns for custom commands

* black formatting

* [docs] cooldowns
2018-09-17 17:47:45 +02:00
Michael H
113b97b9c9 Fix role check in local blacklist/whitelist (#2134)
fixes #2133 , 

`discord.Role.is_default` is a method, not a property.
2018-09-17 17:30:56 +10:00
zephyrkul
2784730f7f [V3] Utilize shorten_by (#2131)
* utilize shorten_by

otherwise you get errors

* trivia shorten_by

* warnings shorten_by

max username length is 32 characters

* black formatting
2018-09-15 01:48:31 +02:00
Toby Harradine
1a9216b522 Set 3.6.6 as minimum python version on Windows (#2117)
* Set 3.6.6 as minimum python version on Windows

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>

* Conditional python_requires in setup.py

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>

* Should probably add the comment too

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-14 21:22:43 +10:00
Toby Harradine
08fc732b7b [Downloader] Fix URL parsing for non-GitHub/GitLab links (#2123)
Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-12 11:01:54 +10:00
Toby Harradine
54baf687ec [Downloader] Parse tree URLs when cloning repos (#2119)
* [Downloader] Parse tree URLs when cloning repos

Signed-off-by: Toby <tobyharradine@gmail.com>

* Only match GitHub and GitLab URLs

Signed-off-by: Toby Harradine <tobyharradine@gmail.com>
2018-09-12 10:03:15 +10:00
96 changed files with 8336 additions and 4415 deletions

View File

@@ -53,7 +53,7 @@ Red's repository is configured to follow a particular development workflow, usin
### 4.1 Setting up your development environment
The following requirements must be installed prior to setting up:
- Python 3.6.2 or greater
- Python 3.6.2 or greater (3.6.6 or greater on Windows)
- git
- pip
- pipenv
@@ -92,7 +92,7 @@ Your PR will not be merged until all of these tests pass.
### 4.3 Style
Our style checker of choice, [black](https://github.com/ambv/black), actually happens to be an auto-formatter. The checking functionality simply detects whether or not it would try to reformat something in your code, should you run the formatter on it. For this reason, we recommend using this tool as a formatter, regardless of any disagreements you might have with the style it enforces.
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>`.
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 -N <src>`.
### 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:

View File

@@ -28,12 +28,13 @@ jobs:
- python: 3.6.6
env: TOXENV=style
# These jobs only occur on tag creation for V3/develop if the prior ones succeed
# These jobs only occur on tag creation if the prior ones succeed
- stage: PyPi Deployment
if: tag IS present
python: 3.6.6
env:
- DEPLOYING=true
- TOXENV=py36
deploy:
- provider: pypi
user: Red-DiscordBot
@@ -42,7 +43,6 @@ jobs:
skip_cleanup: true
on:
repo: Cog-Creators/Red-DiscordBot
branch: V3/develop
python: 3.6.6
tags: true
- stage: Crowdin Deployment
@@ -50,18 +50,18 @@ jobs:
python: 3.6.6
env:
- DEPLOYING=true
- TOXENV=py36
before_deploy:
- curl https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | sudo apt-key add -
- echo "deb https://artifacts.crowdin.com/repo/deb/ /" | sudo tee -a /etc/apt/sources.list
- sudo apt-get update -qq
- sudo apt-get install -y crowdin
- pip install redgettext==2.1
- pip install redgettext==2.2
deploy:
- provider: script
script: make gettext
skip_cleanup: true
on:
repo: Cog-Creators/Red-DiscordBot
branch: V3/develop
python: 3.6.6
tags: true

View File

@@ -1,7 +1,7 @@
reformat:
black -l 99 `git ls-files "*.py"`
black -l 99 -N `git ls-files "*.py"`
stylecheck:
black --check -l 99 `git ls-files "*.py"`
black --check -l 99 -N `git ls-files "*.py"`
gettext:
redgettext --command-docstrings --verbose --recursive redbot --exclude-files "redbot/pytest/**/*"
crowdin upload

575
Pipfile.lock generated
View File

@@ -16,30 +16,37 @@
"default": {
"aiohttp": {
"hashes": [
"sha256:1a112a1fdf3802b7f2b182e22e51d71e4a8fa7387d0d38e79a268921b869e384",
"sha256:33aa7c937ebaf063a860cbb0c263a771b33333a84965c6148eeafe64fb4e29ca",
"sha256:550b4a0788500f6d00f41b7fdd9fcce6d78f99706a7b2f6f81d4d331c7ca468e",
"sha256:601e8e83123b4d423a9dfddf7d6943f4f520651a78ffcd50c99d065136c7ff7b",
"sha256:620f19ba7628b70b177f5c2e6a55a6fd6e7c8591cde38c3f8f52551733d31b66",
"sha256:70d56c784da1239c89d39fefa166fd429306dada641178389be4184a9c04e501",
"sha256:7de2c9e445a5d257935011268202338538abef1aaff341a4733eca56419ca6f6",
"sha256:96bb80b659cc2bafa160f3f0c346ce7fc10de1ffec4908d7f9690797f155f658",
"sha256:ae7501cc6a6c37b8d4774bf2218c37be47fe42019a2570e8510fc2044e59d573",
"sha256:c833aa6f4c9ac3e3eb843e3d999bae51339ad33a937303f43ce78064e61cb4b6",
"sha256:dd81d85a342edf3d2a388e2f24d9facebc9c04550043888f970ee2f228c93059",
"sha256:f20deec7a3fbaec7b5eb7ad99878427ad2ee4cc16a46732b705e8121cbb3cc12",
"sha256:f52e7287eb9286a1e91e4c67c207c2573147fbaddc68f70efb5aeee5d1992f2e",
"sha256:fe7b2972ff7e779e812f974aa5695edc328ecf559ceeea887ac46f06f090ad4c",
"sha256:ff1447c84a02b9cd5dd3a9332d1fb181a4386c3625765bb5caf1cfbc210ab3f9"
"sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b",
"sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08",
"sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd",
"sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac",
"sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650",
"sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa",
"sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95",
"sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330",
"sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc",
"sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b",
"sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de",
"sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4",
"sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7",
"sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b",
"sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8",
"sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd",
"sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2",
"sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698",
"sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95",
"sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6",
"sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0",
"sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07"
],
"version": "==3.3.2"
"version": "==3.4.4"
},
"aiohttp-json-rpc": {
"hashes": [
"sha256:970806a3b9887c389095d2bde84e2b540fefeddd0bae0efcae03c65f092ce00e",
"sha256:d6f365067676e6089ac043ad31bcbabbf33d0343c42b57c36751a562fbe64fb6"
"sha256:00d72f40edfc7271578d545a8c47874c0e23cc5d3201ed8128481f6a4af47e32",
"sha256:02d83b6998f8a0b7e59b46f0cb8a96b475bbf82600b1f9527df47135353f1ca8"
],
"version": "==0.11.1"
"version": "==0.11.2"
},
"appdirs": {
"hashes": [
@@ -50,11 +57,10 @@
},
"async-timeout": {
"hashes": [
"sha256:474d4bc64cee20603e225eb1ece15e248962958b45a3648a9f5cc29e827a610c",
"sha256:b3c0ddc416736619bd4a95ca31de8da6920c3b9a140c64dbef2b2fa7bf521287"
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
"markers": "python_version >= '3.5.3'",
"version": "==3.0.0"
"version": "==3.0.1"
},
"attrs": {
"hashes": [
@@ -72,15 +78,20 @@
},
"colorama": {
"hashes": [
"sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
"sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
],
"version": "==0.3.9"
"version": "==0.4.1"
},
"discord-py": {
"editable": true,
"git": "git://github.com/Rapptz/discord.py",
"ref": "7f4c57dd5ad20b7fa10aea485f674a4bc24b9547"
},
"discord.py": {
"editable": true,
"git": "git://github.com/Rapptz/discord.py",
"ref": "00a659c6526b2445162b52eaf970adbd22c6d35d"
"ref": "rewrite"
},
"distro": {
"hashes": [
@@ -89,6 +100,13 @@
],
"version": "==1.3.0"
},
"dnspython": {
"hashes": [
"sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01",
"sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"
],
"version": "==1.16.0"
},
"e1839a8": {
"editable": true,
"extras": [
@@ -106,10 +124,10 @@
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.7"
"version": "==2.8"
},
"idna-ssl": {
"hashes": [
@@ -126,72 +144,76 @@
},
"multidict": {
"hashes": [
"sha256:112eeeddd226af681dc82b756ed34aa7b6d98f9c4a15760050298c21d715473d",
"sha256:13b64ecb692effcabc5e29569ba9b5eb69c35112f990a16d6833ec3a9d9f8ec0",
"sha256:1725373fb8f18c2166f8e0e5789851ccf98453c849b403945fa4ef59a16ca44e",
"sha256:2061a50b7cae60a1f987503a995b2fc38e47027a937a355a124306ed9c629041",
"sha256:35b062288a9a478f627c520fd27983160fc97591017d170f966805b428d17e07",
"sha256:467b134bcc227b91b8e2ef8d2931f28b50bf7eb7a04c0403d102ded22e66dbfc",
"sha256:475a3ece8bb450e49385414ebfae7f8fdb33f62f1ac0c12935c1cfb1b7c1076a",
"sha256:49b885287e227a24545a1126d9ac17ae43138610713dc6219b781cc0ad5c6dfc",
"sha256:4c95b2725592adb5c46642be2875c1234c32af841732c5504c17726b92082021",
"sha256:4ea7ed00f4be0f7335c9a2713a65ac3d986be789ce5ebc10821da9664cbe6b85",
"sha256:5e2d5e1d999e941b4a626aea46bdc4206877cf727107fdaa9d46a8a773a6e49b",
"sha256:8039c520ef7bb9ec7c3db3df14c570be6362f43c200ae9854d2422d4ffe175a4",
"sha256:81459a0ebcca09c1fcb8fe887ed13cf267d9b60fe33718fc5fd1a2a1ab49470a",
"sha256:847c3b7b9ca3268e883685dc1347a4d09f84de7bd7597310044d847590447492",
"sha256:8551d1db45f0ca4e8ec99130767009a29a4e0dc6558a4a6808491bcd3472d325",
"sha256:8fa7679ffe615e0c1c7b80946ab4194669be74848719adf2d7867b5e861eb073",
"sha256:a42a36f09f0f907579ff0fde547f2fde8a739a69efe4a2728835979d2bb5e17b",
"sha256:a5fcad0070685c5b2d04b468bf5f4c735f5c176432f495ad055fcc4bc0a79b23",
"sha256:ae22195b2a7494619b73c01129ddcddc0dfaa9e42727404b1d9a77253da3f420",
"sha256:b360e82bdbbd862e1ce2a41cc3bbd0ab614350e813ca74801b34aac0f73465aa",
"sha256:b96417899344c5e96bef757f4963a72d02e52653a4e0f99bbea3a531cedac59f",
"sha256:b9e921140b797093edfc13ac08dc2a4fd016dd711dc42bb0e1aaf180e48425a7",
"sha256:c5022b94fc330e6d177f3eb38097fb52c7df96ca0e04842c068cf0d9fc38b1e6",
"sha256:cf2b117f2a8d951638efc7592fb72d3eeb2d38cc2194c26ba7f00e7190451d92",
"sha256:d79620b542d9d0e23ae9790ca2fe44f1af40ffad9936efa37bd14954bc3e2818",
"sha256:e2860691c11d10dac7c91bddae44f6211b3da4122d9a2ebb509c2247674d6070",
"sha256:e3a293553715afecf7e10ea02da40593f9d7f48fe48a74fc5dd3ce08a0c46188",
"sha256:e465be3fe7e992e5a6e16731afa6f41cb6ca53afccb4f28ea2fa6457783edf15",
"sha256:e6d27895ef922bc859d969452f247bfbe5345d9aba69b9c8dbe1ea7704f0c5d9"
"sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
"sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3",
"sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef",
"sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b",
"sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73",
"sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc",
"sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3",
"sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd",
"sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351",
"sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941",
"sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d",
"sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1",
"sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b",
"sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a",
"sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3",
"sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7",
"sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0",
"sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0",
"sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014",
"sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5",
"sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036",
"sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d",
"sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a",
"sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce",
"sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1",
"sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a",
"sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9",
"sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7",
"sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b"
],
"version": "==4.4.0"
"version": "==4.5.2"
},
"pymongo": {
"hashes": [
"sha256:08dea6dbff33363419af7af3bf2e9a373ff71eb22833dd7063f9b953f09a0bdf",
"sha256:0949110db76eb1b54cecfc0c0f8468a8b9a7fd42ba23fd0d4a37d97e0b4ca203",
"sha256:0c31a39f440801cc8603547ccaacf4cb1f02b81af6ba656621c13677b27f4426",
"sha256:1e10b3fda5677d360440ebd12a1185944dc81d9ea9acf0c6b0681013b3fb9bc2",
"sha256:1f59440b993666a417ba1954cfb1b7fb11cb4dea1a1d2777897009f688d000ee",
"sha256:2b5a3806d9f656c14e9d9b693a344fc5684fdd045155594be0c505c6e9410a94",
"sha256:4a14e2d7c2c0e07b5affcfbfc5c395d767f94bb1a822934a41a3b5371cde1458",
"sha256:4cb50541225208b37786fdb0de632e475c4f00ec4792579df551ef48d6999d69",
"sha256:52999666ad01de885653e1f74a86c2a6520d1004afec475180bebf3d7393a8fc",
"sha256:562c353079e8ce7e2ad611fd7436a72f5df97be72bca59ae9ebf789a724afd5c",
"sha256:5ce2a71f473f4703daa8d6c61a00b35ce625a7f5015b4371e3af728dafca296a",
"sha256:6613e633676168a4500e5e6bb6e3e64d3fdb96d2dc472eb4b99235fb4141adb1",
"sha256:8330406f294df118399c721f80979f2516447bcc73e4262826687872c864751e",
"sha256:8e939dfa7d16609b99eb4d1fd2fc74f7a90f4fd0aaf31d611822daaff456236f",
"sha256:8fa4303e1f50d9f0c8f2f7833b5a370a94d19d41449def62b34ae072126b4dfd",
"sha256:966d987975aa3b4cfcdf1495930ff6ecb152fafe8e544e40633e41b24ca3e1c5",
"sha256:aec4ea43a1b8e9782246a259410f66692f2d3aa0f03c54477e506193b0781cb6",
"sha256:b73f889f032fbef05863f5056b46468a8262ae83628898e20b10bbbb79a3617e",
"sha256:b752088a2f819f163d11dfdbbe627b27eef9d8478c7e57d42c5e7c600fee434e",
"sha256:c8669f96277f140797e0ff99f80bd706271674942672a38ed694e2bfa66f3900",
"sha256:ccf00549efaf6f8d5b35b654beb9aed2b788a5b33b05606eb818ddaa4e924ea3",
"sha256:ce7c91463ad21ac72fc795188292b01c8366cf625e2d1e5ed473ce127b844f60",
"sha256:d776d8d47884e6ad39ff8a301f1ae6b7d2186f209218cf024f43334dbba79c64",
"sha256:dab0f63841aebb2b421fadb31f3c7eef27898f21274a8e5b45c4f2bccb40f9ed",
"sha256:daedcfbf3b24b2b687e35b33252a9315425c2dd06a085a36906d516135bdd60e",
"sha256:e7ad1ec621db2c5ad47924f63561f75abfd4fff669c62c8cc99c169c90432f59",
"sha256:f14fb6c4058772a0d74d82874d3b89d7264d89b4ed7fa0413ea0ef8112b268b9",
"sha256:f16c7b6b98bc400d180f05e65e2236ef4ee9d71f3815280558582670e1e67536",
"sha256:f2d9eb92b26600ae6e8092f66da4bcede1b61a647c9080d6b44c148aff3a8ea4",
"sha256:ffe94f9d17800610dda5282d7f6facfc216d79a93dd728a03d2f21cff3af7cc6"
"sha256:025f94fc1e1364f00e50badc88c47f98af20012f23317234e51a11333ef986e6",
"sha256:02aa7fb282606331aefbc0586e2cf540e9dbe5e343493295e7f390936ad2738e",
"sha256:057210e831573e932702cf332012ed39da78edf0f02d24a3f0b213264a87a397",
"sha256:0d946b79c56187fe139276d4c8ed612a27a616966c8b9779d6b79e2053587c8b",
"sha256:104790893b928d310aae8a955e0bdbaa442fb0ac0a33d1bbb0741c791a407778",
"sha256:15527ef218d95a8717486106553b0d54ff2641e795b65668754e17ab9ca6e381",
"sha256:1826527a0b032f6e20e7ac7f72d7c26dd476a5e5aa82c04aa1c7088a59fded7d",
"sha256:22e3aa4ce1c3eebc7f70f9ca7fd4ce1ea33e8bdb7b61996806cd312f08f84a3a",
"sha256:244e1101e9a48615b9a16cbd194f73c115fdfefc96894803158608115f703b26",
"sha256:24b8c04fdb633a84829d03909752c385faef249c06114cc8d8e1700b95aae5c8",
"sha256:2c276696350785d3104412cbe3ac70ab1e3a10c408e7b20599ee41403a3ed630",
"sha256:2d8474dc833b1182b651b184ace997a7bd83de0f51244de988d3c30e49f07de3",
"sha256:3119b57fe1d964781e91a53e81532c85ed1701baaddec592e22f6b77a9fdf3df",
"sha256:3bee8e7e0709b0fcdaa498a3e513bde9ffc7cd09dbceb11e425bd91c89dbd5b6",
"sha256:436c071e01a464753d30dbfc8768dd93aecf2a8e378e5314d130b95e77b4d612",
"sha256:46635e3f19ad04d5a7d7cf23d232388ddbfccf46d9a3b7436b6abadda4e84813",
"sha256:4772e0b679717e7ac4608d996f57b6f380748a919b457cb05bb941467b888b22",
"sha256:4e2cd80e16f481a62c3175b607373200e714ed29025f21559ebf7524f295689f",
"sha256:52732960efa0e003ca1c092dc0a3c65276e897681287a788a01ca78dda3b41f0",
"sha256:55a7de51ec7d1731b2431886d0349146645f2816e5b8eb982d7c49f89472c9f3",
"sha256:5f8ed5934197a2d4b2087646e98de3e099a237099dcf498b9e38dd3465f74ef4",
"sha256:64b064124fcbc8eb04a155117dc4d9a336e3cda3f069958fbc44fe70c3c3d1e9",
"sha256:65958b8e4319f992e85dad59d8081888b97fcdbde5f0d14bc28f2848b92d3ef1",
"sha256:7683428862e20c6a790c19e64f8ccf487f613fbc83d47e3d532df9c81668d451",
"sha256:78566d5570c75a127c2491e343dc006798a384f06be588fe9b0cbe5595711559",
"sha256:7d1cb00c093dbf1d0b16ccf123e79dee3b82608e4a2a88947695f0460eef13ff",
"sha256:8c74e2a9b594f7962c62cef7680a4cb92a96b4e6e3c2f970790da67cc0213a7e",
"sha256:8e60aa7699170f55f4b0f56ee6f8415229777ac7e4b4b1aa41fc61eec08c1f1d",
"sha256:9447b561529576d89d3bf973e5241a88cf76e45bd101963f5236888713dea774",
"sha256:970055bfeb0be373f2f5299a3db8432444bad3bc2f198753ee6c2a3a781e0959",
"sha256:a6344b8542e584e140dc3c651d68bde51270e79490aa9320f9e708f9b2c39bd5",
"sha256:ce309ca470d747b02ba6069d286a17b7df8e9c94d10d727d9cf3a64e51d85184",
"sha256:cfbd86ed4c2b2ac71bbdbcea6669bf295def7152e3722ddd9dda94ac7981f33d",
"sha256:d7929c513732dff093481f4a0954ed5ff16816365842136b17caa0b4992e49d3"
],
"version": "==3.7.1"
"version": "==3.7.2"
},
"python-levenshtein": {
"hashes": [
@@ -217,10 +239,10 @@
},
"raven": {
"hashes": [
"sha256:3fd787d19ebb49919268f06f19310e8112d619ef364f7989246fc8753d469888",
"sha256:95f44f3ea2c1b176d5450df4becdb96c15bf2632888f9ab193e9dd22300ce46a"
"sha256:3fa6de6efa2493a7c827472e984ce9b020797d0da16f1db67197bcc23c8fae54",
"sha256:44a13f87670836e153951af9a3c80405d36b43097db869a36e92809673692ce4"
],
"version": "==6.9.0"
"version": "==6.10.0"
},
"raven-aiohttp": {
"hashes": [
@@ -235,6 +257,13 @@
],
"version": "==0.1.2"
},
"schema": {
"hashes": [
"sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687",
"sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74"
],
"version": "==0.6.8"
},
"websockets": {
"hashes": [
"sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136",
@@ -259,59 +288,66 @@
"sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff",
"sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
],
"markers": "python_version >= '3.4'",
"version": "==6.0"
},
"yarl": {
"hashes": [
"sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9",
"sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee",
"sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308",
"sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357",
"sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78",
"sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8",
"sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1",
"sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4",
"sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7"
"sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
"sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
"sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
"sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
"sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
"sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
"sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
"sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
"sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
"sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
"sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
],
"markers": "python_version >= '3.4.1'",
"version": "==1.2.6"
"version": "==1.3.0"
}
},
"develop": {
"aiohttp": {
"hashes": [
"sha256:1a112a1fdf3802b7f2b182e22e51d71e4a8fa7387d0d38e79a268921b869e384",
"sha256:33aa7c937ebaf063a860cbb0c263a771b33333a84965c6148eeafe64fb4e29ca",
"sha256:550b4a0788500f6d00f41b7fdd9fcce6d78f99706a7b2f6f81d4d331c7ca468e",
"sha256:601e8e83123b4d423a9dfddf7d6943f4f520651a78ffcd50c99d065136c7ff7b",
"sha256:620f19ba7628b70b177f5c2e6a55a6fd6e7c8591cde38c3f8f52551733d31b66",
"sha256:70d56c784da1239c89d39fefa166fd429306dada641178389be4184a9c04e501",
"sha256:7de2c9e445a5d257935011268202338538abef1aaff341a4733eca56419ca6f6",
"sha256:96bb80b659cc2bafa160f3f0c346ce7fc10de1ffec4908d7f9690797f155f658",
"sha256:ae7501cc6a6c37b8d4774bf2218c37be47fe42019a2570e8510fc2044e59d573",
"sha256:c833aa6f4c9ac3e3eb843e3d999bae51339ad33a937303f43ce78064e61cb4b6",
"sha256:dd81d85a342edf3d2a388e2f24d9facebc9c04550043888f970ee2f228c93059",
"sha256:f20deec7a3fbaec7b5eb7ad99878427ad2ee4cc16a46732b705e8121cbb3cc12",
"sha256:f52e7287eb9286a1e91e4c67c207c2573147fbaddc68f70efb5aeee5d1992f2e",
"sha256:fe7b2972ff7e779e812f974aa5695edc328ecf559ceeea887ac46f06f090ad4c",
"sha256:ff1447c84a02b9cd5dd3a9332d1fb181a4386c3625765bb5caf1cfbc210ab3f9"
"sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b",
"sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08",
"sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd",
"sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac",
"sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650",
"sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa",
"sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95",
"sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330",
"sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc",
"sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b",
"sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de",
"sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4",
"sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7",
"sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b",
"sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8",
"sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd",
"sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2",
"sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698",
"sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95",
"sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6",
"sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0",
"sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07"
],
"version": "==3.3.2"
"version": "==3.4.4"
},
"aiohttp-json-rpc": {
"hashes": [
"sha256:970806a3b9887c389095d2bde84e2b540fefeddd0bae0efcae03c65f092ce00e",
"sha256:d6f365067676e6089ac043ad31bcbabbf33d0343c42b57c36751a562fbe64fb6"
"sha256:00d72f40edfc7271578d545a8c47874c0e23cc5d3201ed8128481f6a4af47e32",
"sha256:02d83b6998f8a0b7e59b46f0cb8a96b475bbf82600b1f9527df47135353f1ca8"
],
"version": "==0.11.1"
"version": "==0.11.2"
},
"alabaster": {
"hashes": [
"sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456",
"sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7"
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
],
"version": "==0.7.11"
"version": "==0.7.12"
},
"appdirs": {
"hashes": [
@@ -322,18 +358,16 @@
},
"async-timeout": {
"hashes": [
"sha256:474d4bc64cee20603e225eb1ece15e248962958b45a3648a9f5cc29e827a610c",
"sha256:b3c0ddc416736619bd4a95ca31de8da6920c3b9a140c64dbef2b2fa7bf521287"
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
"markers": "python_version >= '3.5.3'",
"version": "==3.0.0"
"version": "==3.0.1"
},
"atomicwrites": {
"hashes": [
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0",
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"
],
"markers": "python_version != '3.2.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.1.*'",
"version": "==1.2.1"
},
"attrs": {
@@ -352,17 +386,17 @@
},
"black": {
"hashes": [
"sha256:22158b89c1a6b4eb333a1e65e791a3f8b998cf3b11ae094adb2570f31f769a44",
"sha256:4b475bbd528acce094c503a3d2dbc2d05a4075f6d0ef7d9e7514518e14cc5191"
"sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739",
"sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"
],
"version": "==18.6b4"
"version": "==18.9b0"
},
"certifi": {
"hashes": [
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
],
"version": "==2018.8.24"
"version": "==2018.11.29"
},
"chardet": {
"hashes": [
@@ -373,17 +407,17 @@
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
"version": "==6.7"
"version": "==7.0"
},
"colorama": {
"hashes": [
"sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
"sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
],
"version": "==0.3.9"
"version": "==0.4.1"
},
"distro": {
"hashes": [
@@ -409,6 +443,13 @@
],
"path": "."
},
"filelock": {
"hashes": [
"sha256:b8d5ca5ca1c815e1574aee746650ea7301de63d87935b3463d26368b76e31633",
"sha256:d610c1bb404daf85976d7a82eb2ada120f04671007266b708606565dd03b5be6"
],
"version": "==3.0.10"
},
"fuzzywuzzy": {
"hashes": [
"sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254",
@@ -418,10 +459,10 @@
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
],
"version": "==2.7"
"version": "==2.8"
},
"idna-ssl": {
"hashes": [
@@ -434,7 +475,6 @@
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
"sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
],
"markers": "python_version != '3.2.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.1.*'",
"version": "==1.1.0"
},
"jinja2": {
@@ -446,103 +486,127 @@
},
"markupsafe": {
"hashes": [
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
],
"version": "==1.0"
"version": "==1.1.0"
},
"more-itertools": {
"hashes": [
"sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092",
"sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e",
"sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d"
"sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4",
"sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc",
"sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"
],
"version": "==4.3.0"
"version": "==5.0.0"
},
"multidict": {
"hashes": [
"sha256:112eeeddd226af681dc82b756ed34aa7b6d98f9c4a15760050298c21d715473d",
"sha256:13b64ecb692effcabc5e29569ba9b5eb69c35112f990a16d6833ec3a9d9f8ec0",
"sha256:1725373fb8f18c2166f8e0e5789851ccf98453c849b403945fa4ef59a16ca44e",
"sha256:2061a50b7cae60a1f987503a995b2fc38e47027a937a355a124306ed9c629041",
"sha256:35b062288a9a478f627c520fd27983160fc97591017d170f966805b428d17e07",
"sha256:467b134bcc227b91b8e2ef8d2931f28b50bf7eb7a04c0403d102ded22e66dbfc",
"sha256:475a3ece8bb450e49385414ebfae7f8fdb33f62f1ac0c12935c1cfb1b7c1076a",
"sha256:49b885287e227a24545a1126d9ac17ae43138610713dc6219b781cc0ad5c6dfc",
"sha256:4c95b2725592adb5c46642be2875c1234c32af841732c5504c17726b92082021",
"sha256:4ea7ed00f4be0f7335c9a2713a65ac3d986be789ce5ebc10821da9664cbe6b85",
"sha256:5e2d5e1d999e941b4a626aea46bdc4206877cf727107fdaa9d46a8a773a6e49b",
"sha256:8039c520ef7bb9ec7c3db3df14c570be6362f43c200ae9854d2422d4ffe175a4",
"sha256:81459a0ebcca09c1fcb8fe887ed13cf267d9b60fe33718fc5fd1a2a1ab49470a",
"sha256:847c3b7b9ca3268e883685dc1347a4d09f84de7bd7597310044d847590447492",
"sha256:8551d1db45f0ca4e8ec99130767009a29a4e0dc6558a4a6808491bcd3472d325",
"sha256:8fa7679ffe615e0c1c7b80946ab4194669be74848719adf2d7867b5e861eb073",
"sha256:a42a36f09f0f907579ff0fde547f2fde8a739a69efe4a2728835979d2bb5e17b",
"sha256:a5fcad0070685c5b2d04b468bf5f4c735f5c176432f495ad055fcc4bc0a79b23",
"sha256:ae22195b2a7494619b73c01129ddcddc0dfaa9e42727404b1d9a77253da3f420",
"sha256:b360e82bdbbd862e1ce2a41cc3bbd0ab614350e813ca74801b34aac0f73465aa",
"sha256:b96417899344c5e96bef757f4963a72d02e52653a4e0f99bbea3a531cedac59f",
"sha256:b9e921140b797093edfc13ac08dc2a4fd016dd711dc42bb0e1aaf180e48425a7",
"sha256:c5022b94fc330e6d177f3eb38097fb52c7df96ca0e04842c068cf0d9fc38b1e6",
"sha256:cf2b117f2a8d951638efc7592fb72d3eeb2d38cc2194c26ba7f00e7190451d92",
"sha256:d79620b542d9d0e23ae9790ca2fe44f1af40ffad9936efa37bd14954bc3e2818",
"sha256:e2860691c11d10dac7c91bddae44f6211b3da4122d9a2ebb509c2247674d6070",
"sha256:e3a293553715afecf7e10ea02da40593f9d7f48fe48a74fc5dd3ce08a0c46188",
"sha256:e465be3fe7e992e5a6e16731afa6f41cb6ca53afccb4f28ea2fa6457783edf15",
"sha256:e6d27895ef922bc859d969452f247bfbe5345d9aba69b9c8dbe1ea7704f0c5d9"
"sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
"sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3",
"sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef",
"sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b",
"sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73",
"sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc",
"sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3",
"sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd",
"sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351",
"sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941",
"sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d",
"sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1",
"sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b",
"sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a",
"sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3",
"sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7",
"sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0",
"sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0",
"sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014",
"sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5",
"sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036",
"sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d",
"sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a",
"sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce",
"sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1",
"sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a",
"sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9",
"sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7",
"sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b"
],
"version": "==4.4.0"
"version": "==4.5.2"
},
"packaging": {
"hashes": [
"sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
"sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
"sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
"sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
],
"version": "==17.1"
"version": "==18.0"
},
"pluggy": {
"hashes": [
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
"sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616",
"sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"
],
"markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==0.7.1"
"version": "==0.8.1"
},
"py": {
"hashes": [
"sha256:06a30435d058473046be836d3fc4f27167fd84c45b99704f2fb5509ef61f9af1",
"sha256:50402e9d1c9005d759426988a492e0edaadb7f4e68bcddfea586bc7432d009c6"
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694",
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"
],
"markers": "python_version != '3.2.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.1.*'",
"version": "==1.6.0"
"version": "==1.7.0"
},
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
"sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
"sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
],
"version": "==2.2.0"
"version": "==2.3.1"
},
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
"sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b",
"sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592"
],
"version": "==2.2.0"
"version": "==2.3.0"
},
"pytest": {
"hashes": [
"sha256:2d7c49e931316cc7d1638a3e5f54f5d7b4e5225972b3c9838f3584788d27f349",
"sha256:ad0c7db7b5d4081631e0155f5c61b80ad76ce148551aaafe3a718d65a7508b18"
"sha256:3e65a22eb0d4f1bdbc1eacccf4a3198bf8d4049dea5112d70a0c61b00e748d02",
"sha256:5924060b374f62608a078494b909d341720a050b5224ff87e17e12377486a71d"
],
"version": "==3.7.4"
"version": "==4.1.0"
},
"pytest-asyncio": {
"hashes": [
"sha256:a962e8e1b6ec28648c8fe214edab4e16bacdb37b52df26eb9d63050af309b2a9",
"sha256:fbd92c067c16111174a1286bfb253660f1e564e5146b39eeed1133315cf2c2cf"
"sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf",
"sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"
],
"markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==0.9.0"
"version": "==0.10.0"
},
"python-levenshtein": {
"hashes": [
@@ -552,10 +616,10 @@
},
"pytz": {
"hashes": [
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
"sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9",
"sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c"
],
"version": "==2018.5"
"version": "==2018.9"
},
"pyyaml": {
"hashes": [
@@ -575,10 +639,10 @@
},
"raven": {
"hashes": [
"sha256:3fd787d19ebb49919268f06f19310e8112d619ef364f7989246fc8753d469888",
"sha256:95f44f3ea2c1b176d5450df4becdb96c15bf2632888f9ab193e9dd22300ce46a"
"sha256:3fa6de6efa2493a7c827472e984ce9b020797d0da16f1db67197bcc23c8fae54",
"sha256:44a13f87670836e153951af9a3c80405d36b43097db869a36e92809673692ce4"
],
"version": "==6.9.0"
"version": "==6.10.0"
},
"raven-aiohttp": {
"hashes": [
@@ -589,17 +653,24 @@
},
"requests": {
"hashes": [
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
"sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
"sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
],
"version": "==2.19.1"
"version": "==2.21.0"
},
"schema": {
"hashes": [
"sha256:d994b0dc4966000037b26898df638e3e2a694cc73636cb2050e652614a350687",
"sha256:fa1a53fe5f3b6929725a4e81688c250f46838e25d8c1885a10a590c8c01a7b74"
],
"version": "==0.6.8"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
],
"version": "==1.11.0"
"version": "==1.12.0"
},
"snowballstemmer": {
"hashes": [
@@ -610,17 +681,17 @@
},
"sphinx": {
"hashes": [
"sha256:a07050845cc9a2f4026a6035cc8ed795a5ce7be6528bbc82032385c10807dfe7",
"sha256:d719de667218d763e8fd144b7fcfeefd8d434a6201f76bf9f0f0c1fa6f47fcdb"
"sha256:429e3172466df289f0f742471d7e30ba3ee11f3b5aecd9a840480d03f14bcfe5",
"sha256:c4cb17ba44acffae3d3209646b6baec1e215cad3065e852c68cc569d4df1b9f8"
],
"version": "==1.7.8"
"version": "==1.8.3"
},
"sphinx-rtd-theme": {
"hashes": [
"sha256:3b49758a64f8a1ebd8a33cb6cc9093c3935a908b716edfaa5772fd86aac27ef6",
"sha256:80e01ec0eb711abacb1fa507f3eae8b805ae8fa3e8b057abfdf497e3f644c82c"
"sha256:02f02a676d6baabb758a20c7a479d58648e0f64f13e07d1b388e9bb2afe86a09",
"sha256:d0f6bc70f98961145c5b0e26a992829363a197321ba571b31b24ea91879e0c96"
],
"version": "==0.4.1"
"version": "==0.4.2"
},
"sphinxcontrib-asyncio": {
"hashes": [
@@ -633,38 +704,36 @@
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
],
"markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==1.1.0"
},
"toml": {
"hashes": [
"sha256:8e86bd6ce8cc11b9620cb637466453d94f5d57ad86f17e98a98d1f73e3baab2d"
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
],
"version": "==0.9.4"
"version": "==0.10.0"
},
"tox": {
"hashes": [
"sha256:37cf240781b662fb790710c6998527e65ca6851eace84d1595ee71f7af4e85f7",
"sha256:eb61aa5bcce65325538686f09848f04ef679b5cd9b83cc491272099b28739600"
"sha256:2a8d8a63660563e41e64e3b5b677e81ce1ffa5e2a93c2c565d3768c287445800",
"sha256:edfca7809925f49bdc110d0a2d9966bbf35a0c25637216d9586e7a5c5de17bfb"
],
"index": "pypi",
"version": "==3.2.1"
"version": "==3.6.1"
},
"urllib3": {
"hashes": [
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
"markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version != '3.2.*' and python_version < '4' and python_version >= '2.6' and python_version != '3.1.*'",
"version": "==1.23"
"version": "==1.24.1"
},
"virtualenv": {
"hashes": [
"sha256:2ce32cd126117ce2c539f0134eb89de91a8413a29baac49cbab3eb50e2026669",
"sha256:ca07b4c0b54e14a91af9f34d0919790b016923d157afda5efdde55c96718f752"
"sha256:34b9ae3742abed2f95d3970acf4d80533261d6061b51160b197f84e5b4c98b4c",
"sha256:fa736831a7b18bd2bfeef746beb622a92509e9733d645952da136b0639cd40cd"
],
"markers": "python_version != '3.0.*' and python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*'",
"version": "==16.0.0"
"version": "==16.2.0"
},
"websockets": {
"hashes": [
@@ -690,23 +759,23 @@
"sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff",
"sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
],
"markers": "python_version >= '3.4'",
"version": "==6.0"
},
"yarl": {
"hashes": [
"sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9",
"sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee",
"sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308",
"sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357",
"sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78",
"sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8",
"sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1",
"sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4",
"sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7"
"sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
"sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
"sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
"sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
"sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
"sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
"sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
"sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
"sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
"sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
"sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
],
"markers": "python_version >= '3.4.1'",
"version": "==1.2.6"
"version": "==1.3.0"
}
}
}

View File

@@ -1 +1 @@
https://github.com/Rapptz/discord.py/tarball/00a659c6526b2445162b52eaf970adbd22c6d35d#egg=discord.py-1.0.0a0
https://github.com/Rapptz/discord.py/tarball/7f4c57dd5ad20b7fa10aea485f674a4bc24b9547#egg=discord.py-1.0.0a0

View File

@@ -12,6 +12,14 @@ CustomCommands allows you to create simple commands for your bot without requiri
If the command you attempt to create shares a name with an already loaded command, you cannot overwrite it with this cog.
---------
Cooldowns
---------
You can set cooldowns for your custom commands. If a command is on cooldown, it will not be triggered.
You can set cooldowns per member or per channel, or set a cooldown guild-wide. You can also set multiple types of cooldown on a single custom command. All cooldowns must pass before the command will trigger.
------------------
Context Parameters
------------------

View File

@@ -8,100 +8,90 @@ Permissions Cog Reference
How it works
------------
When loaded, the permissions cog will allow you
to define extra custom rules for who can use a command
When loaded, the permissions cog will allow you to define extra custom rules for who can use a
command.
If no applicable rules are found, the command will behave as if
the cog was not loaded.
If no applicable rules are found, the command will behave normally.
Rules can also be added to cogs, which will affect all commands from that cog. The cog name can be
found from the help menu.
-------------
Rule priority
-------------
Rules set will be checked in the following order
Rules set for subcommands will take precedence over rules set for the parent commands, which
lastly take precedence over rules set for the cog. So for example, if a user is denied the Core
cog, but allowed the ``[p]set token`` command, the user will not be able to use any command in the
Core cog except for ``[p]set token``.
In terms of scope, global rules will be checked first, then server rules.
1. Owner level command specific settings
2. Owner level cog specific settings
3. Server level command specific settings
4. Server level cog specific settings
For each of those, the first rule pertaining to one of the following models will be used:
For each of those, settings have varying priorities (listed below, highest to lowest priority)
1. User
2. Voice channel
3. Text channel
4. Channel category
5. Roles, highest to lowest
6. Server (can only be in global rules)
7. Default rules
1. User whitelist
2. User blacklist
3. Voice Channel whitelist
4. Voice Channel blacklist
5. Text Channel whitelist
6. Text Channel blacklist
7. Role settings (see below)
8. Server whitelist
9. Server blacklist
10. Default settings
For the role whitelist and blacklist settings,
roles will be checked individually in order from highest to lowest role the user has
Each role will be checked for whitelist, then blacklist. The first role with a setting
found will be the one used.
In private messages, only global rules about a user will be checked.
-------------------------
Setting Rules from a file
Setting Rules From a File
-------------------------
The permissions cog can set rules from a yaml file:
All entries are based on ID.
An example of the expected format is shown below.
The permissions cog can also set, display or update rules with a YAML file with the
``[p]permissions yaml`` command. Models must be represented by ID. Rules must be ``true`` for
allow, or ``false`` for deny. Here is an example:
.. code-block:: yaml
cogs:
COG:
Admin:
allow:
- 78631113035100160
deny:
- 96733288462286848
78631113035100160: true
96733288462286848: false
Audio:
allow:
- 133049272517001216
default: deny
commands:
133049272517001216: true
default: false
COMMAND:
cleanup bot:
allow:
- 78631113035100160
default: deny
78631113035100160: true
default: false
ping:
deny:
- 96733288462286848
default: allow
96733288462286848: false
default: true
----------------------
Example configurations
----------------------
Locking Audio cog to approved server(s) as a bot owner
Locking the ``[p]play`` command to approved server(s) as a bot owner:
.. code-block:: none
[p]permissions setglobaldefault Audio deny
[p]permissions addglobalrule allow Audio [server ID or name]
[p]permissions setglobaldefault play deny
[p]permissions addglobalrule allow play [server ID or name]
Locking Audio to specific voice channel(s) as a serverowner or admin:
Locking the ``[p]play`` command to specific voice channel(s) as a serverowner or admin:
.. code-block:: none
[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]
[p]permissions setserverdefault deny play
[p]permissions setserverdefault deny "playlist start"
[p]permissions addserverrule allow play [voice channel ID or name]
[p]permissions addserverrule allow "playlist start" [voice channel ID or name]
Allowing extra roles to use cleanup
Allowing extra roles to use ``[p]cleanup``:
.. code-block:: none
[p]permissions addguildrule allow Cleanup [role ID]
[p]permissions addserverrule allow cleanup [role ID]
Preventing cleanup from being used in channels where message history is important:
Preventing ``[p]cleanup`` from being used in channels where message history is important:
.. code-block:: none
[p]permissions addguildrule deny Cleanup [channel ID or mention]
[p]permissions addserverrule deny cleanup [channel ID or mention]

View File

@@ -39,6 +39,7 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon",
"sphinx.ext.doctest",
"sphinxcontrib.asyncio",
]
@@ -197,9 +198,16 @@ texinfo_documents = [
linkcheck_ignore = [r"https://java.com*"]
# Example configuration for intersphinx: refer to the Python standard library.
# -- Options for extensions -----------------------------------------------
# Intersphinx
intersphinx_mapping = {
"python": ("https://docs.python.org/3.6", None),
"dpy": ("https://discordpy.readthedocs.io/en/rewrite/", None),
"motor": ("https://motor.readthedocs.io/en/stable/", None),
}
# Doctest
# If this string is non-empty, all blocks with ``>>>`` in them will be
# tested, not just the ones explicitly marked with ``.. doctest::``
doctest_test_doctest_blocks = ""

11
docs/framework_checks.rst Normal file
View File

@@ -0,0 +1,11 @@
.. _checks:
========================
Command Check Decorators
========================
The following are all decorators for commands, which add restrictions to where and when they can be
run.
.. automodule:: redbot.core.checks
:members:

View File

@@ -21,3 +21,6 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Context
:members:
.. automodule:: redbot.core.commands.requires
:members: PrivilegeLevel, PermState, Requires

View File

@@ -187,6 +187,7 @@ This usage guide will cover the following features:
- :py:meth:`Group.get_raw`
- :py:meth:`Group.set_raw`
- :py:meth:`Group.clear_raw`
For this example let's suppose that we're creating a cog that allows users to buy and own multiple pets using
the built-in Economy credits::
@@ -290,6 +291,37 @@ We're responsible pet owners here, so we've also got to have a way to feed our p
await ctx.send("Your pet is now at {}/100 hunger!".format(new_hunger)
Of course, if we're less than responsible pet owners, there are consequences::
#continued
@commands.command()
async def adopt(self, ctx, pet_name: str, *, member: discord.Member):
try:
pet = await self.conf.user(member).pets.get_raw(pet_name)
except KeyError:
await ctx.send("That person doesn't own that pet!")
return
hunger = pet.get("hunger")
if hunger < 80:
await ctx.send("That pet is too well taken care of to be adopted.")
return
await self.conf.user(member).pets.clear_raw(pet_name)
# this is equivalent to doing the following
pets = await self.conf.user(member).pets()
del pets[pet_name]
await self.conf.user(member).pets.set(pets)
await self.conf.user(ctx.author).pets.set_raw(pet_name, value=pet)
await ctx.send(
"Your request to adopt this pet has been granted due to "
"how poorly it was taken care of."
)
*************
V2 Data Usage
*************
@@ -342,6 +374,21 @@ API Reference
inside the bot itself! Simply take a peek inside of the :code:`tests/core/test_config.py` file for examples of using
Config in all kinds of ways.
.. important::
When getting, setting or clearing values in Config, all keys are casted to `str` for you. This
includes keys within a `dict` when one is being set, as well as keys in nested dictionaries
within that `dict`. For example::
>>> conf = Config.get_conf(self, identifier=999)
>>> conf.register_global(foo={})
>>> await conf.foo.set_raw(123, value=True)
>>> await conf.foo()
{'123': True}
>>> await conf.foo.set({123: True, 456: {789: False}}
>>> await conf.foo()
{'123': True, '456': {'789': False}}
.. automodule:: redbot.core.config
Config

View File

@@ -22,12 +22,18 @@ Embed Helpers
.. automodule:: redbot.core.utils.embed
:members:
Menu Helpers
============
Reaction Menus
==============
.. automodule:: redbot.core.utils.menus
:members:
Event Predicates
================
.. automodule:: redbot.core.utils.predicates
:members:
Mod Helpers
===========

View File

@@ -17,8 +17,8 @@ you in the process.
Getting started
---------------
To start off, be sure that you have installed Python 3.6.2 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]`
To start off, be sure that you have installed Python 3.6.2 or higher (3.6.6 or higher on Windows).
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.

View File

@@ -33,14 +33,15 @@ Welcome to Red - Discord Bot's documentation!
guide_data_conversion
framework_bank
framework_bot
framework_checks
framework_cogmanager
framework_commands
framework_config
framework_datamanager
framework_downloader
framework_events
framework_i18n
framework_modlog
framework_commands
framework_rpc
framework_utils

View File

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

View File

@@ -14,11 +14,11 @@ for /F "tokens=* USEBACKQ" %%A in (`git ls-files "*.py"`) do (
goto %1
:reformat
black -l 99 !PYFILES!
black -l 99 -N !PYFILES!
exit /B %ERRORLEVEL%
:stylecheck
black -l 99 --check !PYFILES!
black -l 99 -N --check !PYFILES!
exit /B %ERRORLEVEL%
:help

View File

@@ -1,18 +1,34 @@
import sys
import warnings
import discord
from colorama import init
import colorama
init()
# Let's do all the dumb version checking in one place.
if sys.platform == "win32":
# Due to issues with ProactorEventLoop prior to 3.6.6 (bpo-26819)
MIN_PYTHON_VERSION = (3, 6, 6)
else:
MIN_PYTHON_VERSION = (3, 6, 2)
if sys.version_info < MIN_PYTHON_VERSION:
print(
f"Python {'.'.join(map(str, MIN_PYTHON_VERSION))} is required to run Red, but you have "
f"{sys.version}! Please update Python."
)
sys.exit(1)
if discord.version_info.major < 1:
print(
"You are not running the rewritten version of discord.py.\n\n"
"In order to use Red v3 you MUST be running d.py version"
" >= 1.0.0."
"In order to use Red V3 you MUST be running d.py version "
"1.0.0 or greater."
)
sys.exit(1)
colorama.init()
# Filter fuzzywuzzy slow sequence matcher warning
warnings.filterwarnings("ignore", module=r"fuzzywuzzy.*")
# Prevent discord PyNaCl missing warning
discord.voice_client.VoiceClient.warn_nacl = False

View File

@@ -1,45 +1,52 @@
import logging
from typing import Tuple
import discord
from redbot.core import Config, checks, commands
import logging
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box
from .announcer import Announcer
from .converters import MemberDefaultAuthor, SelfRole
log = logging.getLogger("red.admin")
GENERIC_FORBIDDEN = (
T_ = Translator("Admin", __file__)
_ = lambda s: s
GENERIC_FORBIDDEN = _(
"I attempted to do something that Discord denied me permissions for."
" Your command failed to successfully complete."
)
HIERARCHY_ISSUE = (
HIERARCHY_ISSUE = _(
"I tried to add {role.name} to {member.display_name} but that role"
" is higher than my highest role in the Discord hierarchy so I was"
" unable to successfully add it. Please give me a higher role and "
"try again."
)
USER_HIERARCHY_ISSUE = (
USER_HIERARCHY_ISSUE = _(
"I tried to add {role.name} to {member.display_name} but that role"
" is higher than your highest role in the Discord hierarchy so I was"
" unable to successfully add it. Please get a higher role and "
"try again."
)
RUNNING_ANNOUNCEMENT = (
RUNNING_ANNOUNCEMENT = _(
"I am already announcing something. If you would like to make a"
" different announcement please use `{prefix}announce cancel`"
" first."
)
_ = T_
class Admin:
@cog_i18n(_)
class Admin(commands.Cog):
"""A collection of server administration utilities."""
def __init__(self, config=Config):
super().__init__()
self.conf = config.get_conf(self, 8237492837454039, force_registration=True)
self.conf.register_global(serverlocked=False)
@@ -97,13 +104,14 @@ class Admin:
await member.add_roles(role)
except discord.Forbidden:
if not self.pass_hierarchy_check(ctx, role):
await self.complain(ctx, HIERARCHY_ISSUE, role=role, member=member)
await self.complain(ctx, T_(HIERARCHY_ISSUE), role=role, member=member)
else:
await self.complain(ctx, GENERIC_FORBIDDEN)
await self.complain(ctx, T_(GENERIC_FORBIDDEN))
else:
await ctx.send(
"I successfully added {role.name} to"
" {member.display_name}".format(role=role, member=member)
_("I successfully added {role.name} to {member.display_name}").format(
role=role, member=member
)
)
async def _removerole(self, ctx: commands.Context, member: discord.Member, role: discord.Role):
@@ -111,13 +119,14 @@ class Admin:
await member.remove_roles(role)
except discord.Forbidden:
if not self.pass_hierarchy_check(ctx, role):
await self.complain(ctx, HIERARCHY_ISSUE, role=role, member=member)
await self.complain(ctx, T_(HIERARCHY_ISSUE), role=role, member=member)
else:
await self.complain(ctx, GENERIC_FORBIDDEN)
await self.complain(ctx, T_(GENERIC_FORBIDDEN))
else:
await ctx.send(
"I successfully removed {role.name} from"
" {member.display_name}".format(role=role, member=member)
_("I successfully removed {role.name} from {member.display_name}").format(
role=role, member=member
)
)
@commands.command()
@@ -126,8 +135,8 @@ class Admin:
async def addrole(
self, ctx: commands.Context, rolename: discord.Role, *, user: MemberDefaultAuthor = None
):
"""
Adds a role to a user.
"""Add a role to a user.
If user is left blank it defaults to the author of the command.
"""
if user is None:
@@ -136,7 +145,7 @@ class Admin:
# noinspection PyTypeChecker
await self._addrole(ctx, user, rolename)
else:
await self.complain(ctx, USER_HIERARCHY_ISSUE, member=ctx.author, role=rolename)
await self.complain(ctx, T_(USER_HIERARCHY_ISSUE), member=ctx.author, role=rolename)
@commands.command()
@commands.guild_only()
@@ -144,8 +153,8 @@ class Admin:
async def removerole(
self, ctx: commands.Context, rolename: discord.Role, *, user: MemberDefaultAuthor = None
):
"""
Removes a role from a user.
"""Remove a role from a user.
If user is left blank it defaults to the author of the command.
"""
if user is None:
@@ -154,50 +163,54 @@ class Admin:
# noinspection PyTypeChecker
await self._removerole(ctx, user, rolename)
else:
await self.complain(ctx, USER_HIERARCHY_ISSUE)
await self.complain(ctx, T_(USER_HIERARCHY_ISSUE))
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(manage_roles=True)
async def editrole(self, ctx: commands.Context):
"""Edits roles settings"""
"""Edit role settings."""
pass
@editrole.command(name="colour", aliases=["color"])
async def editrole_colour(
self, ctx: commands.Context, role: discord.Role, value: discord.Colour
):
"""Edits a role's colour
"""Edit a role's colour.
Use double quotes if the role contains spaces.
Colour must be in hexadecimal format.
\"http://www.w3schools.com/colors/colors_picker.asp\"
[Online colour picker](http://www.w3schools.com/colors/colors_picker.asp)
Examples:
!editrole colour \"The Transistor\" #ff0000
!editrole colour Test #ff9900"""
`[p]editrole colour "The Transistor" #ff0000`
`[p]editrole colour Test #ff9900`
"""
author = ctx.author
reason = "{}({}) changed the colour of role '{}'".format(author.name, author.id, role.name)
if not self.pass_user_hierarchy_check(ctx, role):
await self.complain(ctx, USER_HIERARCHY_ISSUE)
await self.complain(ctx, T_(USER_HIERARCHY_ISSUE))
return
try:
await role.edit(reason=reason, color=value)
except discord.Forbidden:
await self.complain(ctx, GENERIC_FORBIDDEN)
await self.complain(ctx, T_(GENERIC_FORBIDDEN))
else:
log.info(reason)
await ctx.send("Done.")
await ctx.send(_("Done."))
@editrole.command(name="name")
@checks.admin_or_permissions(administrator=True)
async def edit_role_name(self, ctx: commands.Context, role: discord.Role, *, name: str):
"""Edits a role's name
"""Edit a role's name.
Use double quotes if the role or the name contain spaces.
Examples:
!editrole name \"The Transistor\" Test"""
`[p]editrole name \"The Transistor\" Test`
"""
author = ctx.message.author
old_name = role.name
reason = "{}({}) changed the name of role '{}' to '{}'".format(
@@ -205,73 +218,74 @@ class Admin:
)
if not self.pass_user_hierarchy_check(ctx, role):
await self.complain(ctx, USER_HIERARCHY_ISSUE)
await self.complain(ctx, T_(USER_HIERARCHY_ISSUE))
return
try:
await role.edit(reason=reason, name=name)
except discord.Forbidden:
await self.complain(ctx, GENERIC_FORBIDDEN)
await self.complain(ctx, T_(GENERIC_FORBIDDEN))
else:
log.info(reason)
await ctx.send("Done.")
await ctx.send(_("Done."))
@commands.group(invoke_without_command=True)
@checks.is_owner()
async def announce(self, ctx: commands.Context, *, message: str):
"""
Announces a message to all servers the bot is in.
"""
"""Announce a message to all servers the bot is in."""
if not self.is_announcing():
announcer = Announcer(ctx, message, config=self.conf)
announcer.start()
self.__current_announcer = announcer
await ctx.send("The announcement has begun.")
await ctx.send(_("The announcement has begun."))
else:
prefix = ctx.prefix
await self.complain(ctx, RUNNING_ANNOUNCEMENT, prefix=prefix)
await self.complain(ctx, T_(RUNNING_ANNOUNCEMENT), prefix=prefix)
@announce.command(name="cancel")
@checks.is_owner()
async def announce_cancel(self, ctx):
"""
Cancels a running announce.
"""
"""Cancel a running announce."""
try:
self.__current_announcer.cancel()
except AttributeError:
pass
await ctx.send("The current announcement has been cancelled.")
await ctx.send(_("The current announcement has been cancelled."))
@announce.command(name="channel")
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def announce_channel(self, ctx, *, channel: discord.TextChannel = None):
"""
Changes the channel on which the bot makes announcements.
"""
"""Change the channel to which the bot makes announcements."""
if channel is None:
channel = ctx.channel
await self.conf.guild(ctx.guild).announce_channel.set(channel.id)
await ctx.send("The announcement channel has been set to {}".format(channel.mention))
await ctx.send(
_("The announcement channel has been set to {channel.mention}").format(channel=channel)
)
@announce.command(name="ignore")
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def announce_ignore(self, ctx):
"""
Toggles whether the announcements will ignore the current server.
"""
"""Toggle announcements being enabled this server."""
ignored = await self.conf.guild(ctx.guild).announce_ignore()
await self.conf.guild(ctx.guild).announce_ignore.set(not ignored)
verb = "will" if ignored else "will not"
await ctx.send(f"The server {ctx.guild.name} {verb} receive announcements.")
if ignored: # Keeping original logic....
await ctx.send(
_("The server {guild.name} will receive announcements.").format(guild=ctx.guild)
)
else:
await ctx.send(
_("The server {guild.name} will not receive announcements.").format(
guild=ctx.guild
)
)
async def _valid_selfroles(self, guild: discord.Guild) -> Tuple[discord.Role]:
"""
@@ -294,8 +308,9 @@ class Admin:
@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.
Server admins must have configured the role as user settable.
NOTE: The role is case sensitive!
"""
@@ -304,8 +319,7 @@ class Admin:
@selfrole.command(name="remove")
async def selfrole_remove(self, ctx: commands.Context, *, selfrole: SelfRole):
"""
Removes a selfrole from yourself.
"""Remove a selfrole from yourself.
NOTE: The role is case sensitive!
"""
@@ -315,8 +329,7 @@ class Admin:
@selfrole.command(name="add")
@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.
"""Add a role to the list of available selfroles.
NOTE: The role is case sensitive!
"""
@@ -324,20 +337,19 @@ class Admin:
if role.id not in curr_selfroles:
curr_selfroles.append(role.id)
await ctx.send("The selfroles list has been successfully modified.")
await ctx.send(_("The selfroles list has been successfully modified."))
@selfrole.command(name="delete")
@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.
"""Remove a role from the list of available selfroles.
NOTE: The role is case sensitive!
"""
async with self.conf.guild(ctx.guild).selfroles() as curr_selfroles:
curr_selfroles.remove(role.id)
await ctx.send("The selfroles list has been successfully modified.")
await ctx.send(_("The selfroles list has been successfully modified."))
@selfrole.command(name="list")
async def selfrole_list(self, ctx: commands.Context):
@@ -347,7 +359,7 @@ class Admin:
selfroles = await self._valid_selfroles(ctx.guild)
fmt_selfroles = "\n".join(["+ " + r.name for r in selfroles])
msg = "Available Selfroles:\n{}".format(fmt_selfroles)
msg = _("Available Selfroles:\n{selfroles}").format(selfroles=fmt_selfroles)
await ctx.send(box(msg, "diff"))
async def _serverlock_check(self, guild: discord.Guild) -> bool:
@@ -364,15 +376,14 @@ class Admin:
@commands.command()
@checks.is_owner()
async def serverlock(self, ctx: commands.Context):
"""
Locks a bot to its current servers only.
"""
"""Lock a bot to its current servers only."""
serverlocked = await self.conf.serverlocked()
await self.conf.serverlocked.set(not serverlocked)
verb = "is now" if not serverlocked else "is no longer"
await ctx.send("The bot {} serverlocked.".format(verb))
if serverlocked:
await ctx.send(_("The bot is no longer serverlocked."))
else:
await ctx.send(_("The bot is now serverlocked."))
# region Event Handlers
async def on_guild_join(self, guild: discord.Guild):

View File

@@ -2,6 +2,9 @@ import asyncio
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
_ = Translator("Announcer", __file__)
class Announcer:
@@ -63,7 +66,9 @@ class Announcer:
try:
await channel.send(self.message)
except discord.Forbidden:
await bot_owner.send("I could not announce to server: {}".format(g.id))
await bot_owner.send(
_("I could not announce to server: {server.id}").format(server=g)
)
await asyncio.sleep(0.5)
self.active = False

View File

@@ -1,5 +1,8 @@
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
_ = Translator("AdminConverters", __file__)
class MemberDefaultAuthor(commands.Converter):
@@ -19,7 +22,7 @@ class SelfRole(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> discord.Role:
admin = ctx.command.instance
if admin is None:
raise commands.BadArgument("Admin is not loaded.")
raise commands.BadArgument(_("The Admin cog is not loaded."))
conf = admin.conf
selfroles = await conf.guild(ctx.guild).selfroles()
@@ -28,5 +31,5 @@ class SelfRole(commands.Converter):
role = await role_converter.convert(ctx, arg)
if role.id not in selfroles:
raise commands.BadArgument("The provided role is not a valid selfrole.")
raise commands.BadArgument(_("The provided role is not a valid selfrole."))
return role

View File

@@ -1,6 +1,6 @@
from copy import copy
from re import search
from typing import Generator, Tuple, Iterable
from typing import Generator, Tuple, Iterable, Optional
import discord
from redbot.core import Config, commands, checks
@@ -14,16 +14,15 @@ _ = Translator("Alias", __file__)
@cog_i18n(_)
class Alias:
"""
Alias
Aliases are per server shortcuts for commands. They
can act as both a lambda (storing arguments for repeated use)
or as simply a shortcut to saying "x y z".
class Alias(commands.Cog):
"""Create aliases for commands.
Aliases are alternative names shortcuts for commands. They
can act as both a lambda (storing arguments for repeated use)
or as simply a shortcut to saying "x y z".
When run, aliases will accept any additional arguments
and append them to the stored alias
and append them to the stored alias.
"""
default_global_settings = {"entries": []}
@@ -31,6 +30,7 @@ class Alias:
default_guild_settings = {"enabled": False, "entries": []} # Going to be a list of dicts
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self._aliases = Config.get_conf(self, 8927348724)
@@ -53,10 +53,13 @@ class Alias:
return (AliasEntry.from_json(d, bot=self.bot) for d in (await self._aliases.entries()))
async def is_alias(
self, guild: discord.Guild, alias_name: str, server_aliases: Iterable[AliasEntry] = ()
) -> (bool, AliasEntry):
self,
guild: Optional[discord.Guild],
alias_name: str,
server_aliases: Iterable[AliasEntry] = (),
) -> Tuple[bool, Optional[AliasEntry]]:
if not server_aliases:
if not server_aliases and guild is not None:
server_aliases = await self.unloaded_aliases(guild)
global_aliases = await self.unloaded_global_aliases()
@@ -173,32 +176,28 @@ class Alias:
@commands.group()
@commands.guild_only()
async def alias(self, ctx: commands.Context):
"""Manage per-server aliases for commands"""
"""Manage command aliases."""
pass
@alias.group(name="global")
async def global_(self, ctx: commands.Context):
"""
Manage global aliases.
"""
"""Manage global aliases."""
pass
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="add")
@commands.guild_only()
async def _add_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""
Add an alias for a command.
"""
"""Add an alias for a command."""
# region Alias Add Validity Checking
is_command = self.is_command(alias_name)
if is_command:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {} but that"
" with the name {name} but that"
" name is already a command on this bot."
).format(alias_name)
).format(name=alias_name)
)
return
@@ -207,9 +206,9 @@ class Alias:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {} but that"
" with the name {name} but that"
" alias already exists on this server."
).format(alias_name)
).format(name=alias_name)
)
return
@@ -218,10 +217,10 @@ class Alias:
await ctx.send(
_(
"You attempted to create a new alias"
" with the name {} but that"
" with the name {name} but that"
" name is an invalid alias name. Alias"
" names may not contain spaces."
).format(alias_name)
).format(name=alias_name)
)
return
# endregion
@@ -231,23 +230,23 @@ class Alias:
await self.add_alias(ctx, alias_name, command)
await ctx.send(_("A new alias with the trigger `{}` has been created.").format(alias_name))
await ctx.send(
_("A new alias with the trigger `{name}` has been created.").format(name=alias_name)
)
@checks.is_owner()
@global_.command(name="add")
async def _add_global_alias(self, ctx: commands.Context, alias_name: str, *, command):
"""
Add a global alias for a command.
"""
"""Add a global alias for a command."""
# region Alias Add Validity Checking
is_command = self.is_command(alias_name)
if is_command:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {} but that"
" with the name {name} but that"
" name is already a command on this bot."
).format(alias_name)
).format(name=alias_name)
)
return
@@ -256,9 +255,9 @@ class Alias:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {} but that"
" with the name {name} but that"
" alias already exists on this server."
).format(alias_name)
).format(name=alias_name)
)
return
@@ -267,10 +266,10 @@ class Alias:
await ctx.send(
_(
"You attempted to create a new global alias"
" with the name {} but that"
" with the name {name} but that"
" name is an invalid alias name. Alias"
" names may not contain spaces."
).format(alias_name)
).format(name=alias_name)
)
return
# endregion
@@ -278,63 +277,68 @@ class Alias:
await self.add_alias(ctx, alias_name, command, global_=True)
await ctx.send(
_("A new global alias with the trigger `{}` has been created.").format(alias_name)
_("A new global alias with the trigger `{name}` has been created.").format(
name=alias_name
)
)
@alias.command(name="help")
@commands.guild_only()
async def _help_alias(self, ctx: commands.Context, alias_name: str):
"""Tries to execute help for the base command of the alias"""
"""Try to execute help for the base command of the alias."""
is_alias, alias = await self.is_alias(ctx.guild, alias_name=alias_name)
if is_alias:
base_cmd = alias.command[0]
if self.is_command(alias.command):
base_cmd = alias.command
else:
base_cmd = alias.command.rsplit(" ", 1)[0]
new_msg = copy(ctx.message)
new_msg.content = "{}help {}".format(ctx.prefix, base_cmd)
new_msg.content = _("{prefix}help {command}").format(
prefix=ctx.prefix, command=base_cmd
)
await self.bot.process_commands(new_msg)
else:
ctx.send(_("No such alias exists."))
await ctx.send(_("No such alias exists."))
@alias.command(name="show")
@commands.guild_only()
async def _show_alias(self, ctx: commands.Context, alias_name: str):
"""Shows what command the alias executes."""
"""Show what command the alias executes."""
is_alias, alias = await self.is_alias(ctx.guild, alias_name)
if is_alias:
await ctx.send(
_("The `{}` alias will execute the command `{}`").format(alias_name, alias.command)
_("The `{alias_name}` alias will execute the command `{command}`").format(
alias_name=alias_name, command=alias.command
)
)
else:
await ctx.send(_("There is no alias with the name `{}`").format(alias_name))
await ctx.send(_("There is no alias with the name `{name}`").format(name=alias_name))
@checks.mod_or_permissions(manage_guild=True)
@alias.command(name="del")
@commands.guild_only()
async def _del_alias(self, ctx: commands.Context, alias_name: str):
"""
Deletes an existing alias on this server.
"""
"""Delete an existing alias on this server."""
aliases = await self.unloaded_aliases(ctx.guild)
try:
next(aliases)
except StopIteration:
await ctx.send(_("There are no aliases on this guild."))
await ctx.send(_("There are no aliases on this server."))
return
if await self.delete_alias(ctx, alias_name):
await ctx.send(
_("Alias with the name `{}` was successfully deleted.").format(alias_name)
_("Alias with the name `{name}` was successfully deleted.").format(name=alias_name)
)
else:
await ctx.send(_("Alias with name `{}` was not found.").format(alias_name))
await ctx.send(_("Alias with name `{name}` was not found.").format(name=alias_name))
@checks.is_owner()
@global_.command(name="del")
async def _del_global_alias(self, ctx: commands.Context, alias_name: str):
"""
Deletes an existing global alias.
"""
"""Delete an existing global alias."""
aliases = await self.unloaded_global_aliases()
try:
next(aliases)
@@ -344,17 +348,15 @@ class Alias:
if await self.delete_alias(ctx, alias_name, global_=True):
await ctx.send(
_("Alias with the name `{}` was successfully deleted.").format(alias_name)
_("Alias with the name `{name}` was successfully deleted.").format(name=alias_name)
)
else:
await ctx.send(_("Alias with name `{}` was not found.").format(alias_name))
await ctx.send(_("Alias with name `{name}` was not found.").format(name=alias_name))
@alias.command(name="list")
@commands.guild_only()
async def _list_alias(self, ctx: commands.Context):
"""
Lists the available aliases on this server.
"""
"""List the available aliases on this server."""
names = [_("Aliases:")] + sorted(
["+ " + a.name for a in (await self.unloaded_aliases(ctx.guild))]
)
@@ -365,9 +367,7 @@ class Alias:
@global_.command(name="list")
async def _list_global_alias(self, ctx: commands.Context):
"""
Lists the available global aliases on this bot.
"""
"""List the available global aliases on this bot."""
names = [_("Aliases:")] + sorted(
["+ " + a.name for a in await self.unloaded_global_aliases()]
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -71,13 +71,19 @@ async def get_java_version(loop) -> _JavaVersion:
# ... version "MAJOR.MINOR.PATCH[_BUILD]" ...
# ...
# We only care about the major and minor parts though.
version_line_re = re.compile(r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?"')
version_line_re = re.compile(
r'version "(?P<major>\d+).(?P<minor>\d+).\d+(?:_\d+)?(?:-[A-Za-z0-9]+)?"'
)
short_version_re = re.compile(r'version "(?P<major>\d+)"')
lines = version_info.splitlines()
for line in lines:
match = version_line_re.search(line)
short_match = short_version_re.search(line)
if match:
return int(match["major"]), int(match["minor"])
elif short_match:
return int(short_match["major"]), 0
raise RuntimeError(
"The output of `java -version` was unexpected. Please report this issue on Red's "

View File

@@ -54,10 +54,11 @@ def check_global_setting_admin():
@cog_i18n(_)
class Bank:
class Bank(commands.Cog):
"""Bank"""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
# SECTION commands
@@ -66,7 +67,7 @@ class Bank:
@checks.guildowner_or_permissions(administrator=True)
@commands.group(autohelp=True)
async def bankset(self, ctx: commands.Context):
"""Base command for bank settings"""
"""Base command for bank settings."""
if ctx.invoked_subcommand is None:
if await bank.is_global():
bank_name = await bank._conf.bank_name()
@@ -80,42 +81,47 @@ class Bank:
default_balance = await bank._conf.guild(ctx.guild).default_balance()
settings = _(
"Bank settings:\n\nBank name: {}\nCurrency: {}\nDefault balance: {}"
).format(bank_name, currency_name, default_balance)
"Bank settings:\n\nBank name: {bank_name}\nCurrency: {currency_name}\n"
"Default balance: {default_balance}"
).format(
bank_name=bank_name, currency_name=currency_name, default_balance=default_balance
)
await ctx.send(box(settings))
@bankset.command(name="toggleglobal")
@checks.is_owner()
async def bankset_toggleglobal(self, ctx: commands.Context, confirm: bool = False):
"""Toggles whether the bank is global or not
If the bank is global, it will become per-server
If the bank is per-server, it will become global"""
"""Toggle whether the bank is global or not.
If the bank is global, it will become per-server.
If the bank is per-server, it will become global.
"""
cur_setting = await bank.is_global()
word = _("per-server") if cur_setting else _("global")
if confirm is False:
await ctx.send(
_(
"This will toggle the bank to be {}, deleting all accounts "
"in the process! If you're sure, type `{}`"
).format(word, "{}bankset toggleglobal yes".format(ctx.prefix))
"This will toggle the bank to be {banktype}, deleting all accounts "
"in the process! If you're sure, type `{command}`"
).format(banktype=word, command="{}bankset toggleglobal yes".format(ctx.prefix))
)
else:
await bank.set_global(not cur_setting)
await ctx.send(_("The bank is now {}.").format(word))
await ctx.send(_("The bank is now {banktype}.").format(banktype=word))
@bankset.command(name="bankname")
@check_global_setting_guildowner()
async def bankset_bankname(self, ctx: commands.Context, *, name: str):
"""Set the bank's name"""
"""Set the bank's name."""
await bank.set_bank_name(name, ctx.guild)
await ctx.send(_("Bank's name has been set to {}").format(name))
await ctx.send(_("Bank name has been set to: {name}").format(name=name))
@bankset.command(name="creditsname")
@check_global_setting_guildowner()
async def bankset_creditsname(self, ctx: commands.Context, *, name: str):
"""Set the name for the bank's currency"""
"""Set the name for the bank's currency."""
await bank.set_currency_name(name, ctx.guild)
await ctx.send(_("Currency name has been set to {}").format(name))
await ctx.send(_("Currency name has been set to: {name}").format(name=name))
# ENDSECTION

View File

@@ -1,6 +1,6 @@
import re
from datetime import datetime, timedelta
from typing import Union, List, Callable
from typing import Union, List, Callable, Set
import discord
@@ -9,15 +9,17 @@ from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import slow_deletion, mass_purge
from redbot.cogs.mod.log import log
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("Cleanup", __file__)
@cog_i18n(_)
class Cleanup:
"""Commands for cleaning messages"""
class Cleanup(commands.Cog):
"""Commands for cleaning up messages."""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
@staticmethod
@@ -30,19 +32,16 @@ class Cleanup:
Tries its best to cleanup after itself if the response is positive.
"""
def author_check(message):
return message.author == ctx.author
prompt = await ctx.send(
_("Are you sure you want to delete {} messages? (y/n)").format(number)
_("Are you sure you want to delete {number} messages? (y/n)").format(number=number)
)
response = await ctx.bot.wait_for("message", check=author_check)
response = await ctx.bot.wait_for("message", check=MessagePredicate.same_context(ctx))
if response.content.lower().startswith("y"):
await prompt.delete()
try:
await response.delete()
except:
except discord.HTTPException:
pass
return True
else:
@@ -95,7 +94,7 @@ class Cleanup:
):
if message.created_at < two_weeks_ago:
break
if check(message):
if message_filter(message):
collected.append(message)
if number and number <= len(collected):
break
@@ -105,25 +104,24 @@ class Cleanup:
@commands.group()
@checks.mod_or_permissions(manage_messages=True)
async def cleanup(self, ctx: commands.Context):
"""Deletes messages."""
"""Delete messages."""
pass
@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
):
"""Deletes last X messages matching the specified text.
"""Delete the last X messages matching the specified text.
Example:
cleanup text \"test\" 5
`[p]cleanup text "test" 5`
Remember to use double quotes."""
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
@@ -135,8 +133,6 @@ class Cleanup:
def check(m):
if text in m.content:
return True
elif m == ctx.message:
return True
else:
return False
@@ -147,6 +143,7 @@ class Cleanup:
before=ctx.message,
delete_pinned=delete_pinned,
)
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages containing '{}' in channel {}.".format(
author.name, author.id, len(to_delete), text, channel.id
@@ -157,22 +154,21 @@ 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
):
"""Deletes last X messages from specified user.
"""Delete the last X messages from a specified user.
Examples:
cleanup user @\u200bTwentysix 2
cleanup user Red 6"""
`[p]cleanup user @\u200bTwentysix 2`
`[p]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:
member = await commands.converter.MemberConverter().convert(ctx, user)
member = await commands.MemberConverter().convert(ctx, user)
except commands.BadArgument:
try:
_id = int(user)
@@ -191,8 +187,6 @@ class Cleanup:
def check(m):
if m.author.id == _id:
return True
elif m == ctx.message:
return True
else:
return False
@@ -203,6 +197,8 @@ class Cleanup:
before=ctx.message,
delete_pinned=delete_pinned,
)
to_delete.append(ctx.message)
reason = (
"{}({}) deleted {} messages "
" made by {}({}) in channel {}."
@@ -214,20 +210,16 @@ 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.
"""Delete all messages after a specified message.
To get a message id, enable developer mode in Discord's
settings, 'appearance' tab. Then right click a message
and copy its id.
This command only works on bots running as bot accounts.
"""
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
try:
@@ -248,16 +240,48 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
async def messages(self, ctx: commands.Context, number: int, delete_pinned: bool = False):
"""Deletes last X messages.
@commands.bot_has_permissions(manage_messages=True)
async def before(
self, ctx: commands.Context, message_id: int, number: int, delete_pinned: bool = False
):
"""Deletes X messages before specified message.
Example:
cleanup messages 26"""
To get a message id, enable developer mode in Discord's
settings, 'appearance' tab. Then right click a message
and copy its id.
"""
channel = ctx.channel
author = ctx.author
try:
before = await channel.get_message(message_id)
except discord.NotFound:
return await ctx.send(_("Message not found."))
to_delete = await self.get_messages_for_deletion(
channel=channel, number=number, before=before, delete_pinned=delete_pinned
)
to_delete.append(ctx.message)
reason = "{}({}) deleted {} messages in channel {}.".format(
author.name, author.id, len(to_delete), channel.name
)
log.info(reason)
await mass_purge(to_delete, channel)
@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):
"""Delete the last X messages.
Example:
`[p]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
if number > 100:
@@ -279,13 +303,11 @@ 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."""
"""Clean up command messages and messages from the bot."""
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
if number > 100:
@@ -301,15 +323,35 @@ class Cleanup:
if "" in prefixes:
prefixes.remove("")
cc_cog = self.bot.get_cog("CustomCommands")
if cc_cog is not None:
command_names: Set[str] = await cc_cog.get_command_names(ctx.guild)
is_cc = lambda name: name in command_names
else:
is_cc = lambda name: False
alias_cog = self.bot.get_cog("Alias")
if alias_cog is not None:
alias_names: Set[str] = (
set((a.name for a in await alias_cog.unloaded_global_aliases()))
| set(a.name for a in await alias_cog.unloaded_aliases(ctx.guild))
)
is_alias = lambda name: name in alias_names
else:
is_alias = lambda name: False
bot_id = self.bot.user.id
def check(m):
if m.author.id == self.bot.user.id:
if m.author.id == bot_id:
return True
elif m == ctx.message:
return True
p = discord.utils.find(m.content.startswith, prefixes)
if p and len(p) > 0:
cmd_name = m.content[len(p) :].split(" ")[0]
return bool(self.bot.get_command(cmd_name))
return (
bool(self.bot.get_command(cmd_name)) or is_alias(cmd_name) or is_cc(cmd_name)
)
return False
to_delete = await self.get_messages_for_deletion(
@@ -338,7 +380,7 @@ class Cleanup:
match_pattern: str = None,
delete_pinned: bool = False,
):
"""Cleans up messages owned by the bot.
"""Clean up messages owned by the bot.
By default, all messages are cleaned. If a third argument is specified,
it is used for pattern matching: If it begins with r( and ends with ),

View File

@@ -1,16 +1,17 @@
import os
import re
import random
from datetime import datetime
from datetime import datetime, timedelta
from inspect import Parameter
from collections import OrderedDict
from typing import Mapping
from typing import Mapping, Tuple, Dict, Set
import discord
from redbot.core import Config, checks, commands
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils import menus
from redbot.core.utils.chat_formatting import box, pagify, escape
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("CustomCommands", __file__)
@@ -19,10 +20,6 @@ class CCError(Exception):
pass
class NotFound(CCError):
pass
class AlreadyExists(CCError):
pass
@@ -31,6 +28,14 @@ class ArgParseError(CCError):
pass
class NotFound(CCError):
pass
class OnCooldown(CCError):
pass
class CommandObj:
def __init__(self, **kwargs):
config = kwargs.get("config")
@@ -39,29 +44,23 @@ class CommandObj:
@staticmethod
async def get_commands(config) -> dict:
commands = await config.commands()
customcommands = {k: v for k, v in commands.items() if commands[k]}
if len(customcommands) == 0:
return None
return customcommands
_commands = await config.commands()
return {k: v for k, v in _commands.items() if _commands[k]}
async def get_responses(self, ctx):
intro = _(
"Welcome to the interactive random {} maker!\n"
"Welcome to the interactive random {cc} maker!\n"
"Every message you send will be added as one of the random "
"responses to choose from once this {} is "
"triggered. To exit this interactive menu, type `{}`"
).format("customcommand", "customcommand", "exit()")
"responses to choose from once this {cc} is "
"triggered. To exit this interactive menu, type `{quit}`"
).format(cc="customcommand", quit="exit()")
await ctx.send(intro)
def check(m):
return m.channel == ctx.channel and m.author == ctx.message.author
responses = []
args = None
while True:
await ctx.send(_("Add a random response:"))
msg = await self.bot.wait_for("message", check=check)
msg = await self.bot.wait_for("message", check=MessagePredicate.same_context(ctx))
if msg.content.lower() == "exit()":
break
@@ -78,19 +77,20 @@ class CommandObj:
responses.append(msg.content)
return responses
def get_now(self) -> str:
@staticmethod
def get_now() -> str:
# Get current time as a string, for 'created_at' and 'edited_at' fields
# in the ccinfo dict
return "{:%d/%m/%Y %H:%M:%S}".format(datetime.utcnow())
async def get(self, message: discord.Message, command: str) -> str:
async def get(self, message: discord.Message, command: str) -> Tuple[str, Dict]:
ccinfo = await self.db(message.guild).commands.get_raw(command, default=None)
if not ccinfo:
raise NotFound()
else:
return ccinfo["response"]
return ccinfo["response"], ccinfo.get("cooldowns", {})
async def create(self, ctx: commands.Context, command: str, response):
async def create(self, ctx: commands.Context, command: str, *, response):
"""Create a custom command"""
# Check if this command is already registered as a customcommand
if await self.db(ctx.guild).commands.get_raw(command, default=None):
@@ -101,45 +101,71 @@ class CommandObj:
ccinfo = {
"author": {"id": author.id, "name": author.name},
"command": command,
"cooldowns": {},
"created_at": self.get_now(),
"editors": [],
"response": response,
}
await self.db(ctx.guild).commands.set_raw(command, value=ccinfo)
async def edit(self, ctx: commands.Context, command: str, response: None):
async def edit(
self,
ctx: commands.Context,
command: str,
*,
response=None,
cooldowns: Mapping[str, int] = None,
ask_for: bool = True,
):
"""Edit an already existing custom command"""
ccinfo = await self.db(ctx.guild).commands.get_raw(command, default=None)
# Check if this command is registered
if not await self.db(ctx.guild).commands.get_raw(command, default=None):
if not ccinfo:
raise NotFound()
author = ctx.message.author
ccinfo = await self.db(ctx.guild).commands.get_raw(command, default=None)
def check(m):
return m.channel == ctx.channel and m.author == ctx.message.author
if ask_for and not response:
await ctx.send(_("Do you want to create a 'randomized' custom command? (y/n)"))
if not response:
await ctx.send(_("Do you want to create a 'randomized' cc? {}").format("y/n"))
msg = await self.bot.wait_for("message", check=check)
if msg.content.lower() == "y":
pred = MessagePredicate.yes_or_no(ctx)
try:
await self.bot.wait_for("message", check=pred, timeout=30)
except TimeoutError:
await ctx.send(_("Response timed out, please try again later."))
return
if pred.result is True:
response = await self.get_responses(ctx=ctx)
else:
await ctx.send(_("What response do you want?"))
response = (await self.bot.wait_for("message", check=check)).content
try:
resp = await self.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=180
)
except TimeoutError:
await ctx.send(_("Response timed out, please try again later."))
return
response = resp.content
# test to raise
ctx.cog.prepare_args(response if isinstance(response, str) else response[0])
if response:
# test to raise
ctx.cog.prepare_args(response if isinstance(response, str) else response[0])
ccinfo["response"] = response
ccinfo["response"] = response
ccinfo["edited_at"] = self.get_now()
if cooldowns:
ccinfo.setdefault("cooldowns", {}).update(cooldowns)
for key, value in ccinfo["cooldowns"].copy().items():
if value <= 0:
del ccinfo["cooldowns"][key]
if author.id not in ccinfo["editors"]:
# Add the person who invoked the `edit` coroutine to the list of
# editors, if the person is not yet in there
ccinfo["editors"].append(author.id)
ccinfo["edited_at"] = self.get_now()
await self.db(ctx.guild).commands.set_raw(command, value=ccinfo)
async def delete(self, ctx: commands.Context, command: str):
@@ -151,135 +177,173 @@ class CommandObj:
@cog_i18n(_)
class CustomCommands:
"""Custom commands
Creates commands used to display text"""
class CustomCommands(commands.Cog):
"""Creates commands used to display text."""
def __init__(self, bot):
super().__init__()
self.bot = bot
self.key = 414589031223512
self.config = Config.get_conf(self, self.key)
self.config.register_guild(commands={})
self.commandobj = CommandObj(config=self.config, bot=self.bot)
self.cooldowns = {}
@commands.group(aliases=["cc"])
@commands.guild_only()
async def customcom(self, ctx: commands.Context):
"""Custom commands management"""
"""Custom commands management."""
pass
@customcom.group(name="add")
@customcom.group(name="create", aliases=["add"])
@checks.mod_or_permissions(administrator=True)
async def cc_add(self, ctx: commands.Context):
"""
Adds a new custom command
async def cc_create(self, ctx: commands.Context):
"""Create custom commands.
CCs can be enhanced with arguments:
https://red-discordbot.readthedocs.io/en/v3-develop/cog_customcom.html
CCs can be enhanced with arguments, see the guide
[here](https://red-discordbot.readthedocs.io/en/v3-develop/cog_customcom.html).
"""
pass
@cc_add.command(name="random")
@cc_create.command(name="random")
@checks.mod_or_permissions(administrator=True)
async def cc_add_random(self, ctx: commands.Context, command: str):
"""
Create a CC where it will randomly choose a response!
async def cc_create_random(self, ctx: commands.Context, command: str.lower):
"""Create a CC where it will randomly choose a response!
Note: This is interactive
Note: This command is interactive.
"""
responses = []
responses = await self.commandobj.get_responses(ctx=ctx)
try:
await self.commandobj.create(ctx=ctx, command=command, response=responses)
await ctx.send(_("Custom command successfully added."))
except AlreadyExists:
await ctx.send(
_("This command already exists. Use `{}` to edit it.").format(
"{}customcom edit".format(ctx.prefix)
_("This command already exists. Use `{command}` to edit it.").format(
command="{}customcom edit".format(ctx.prefix)
)
)
# await ctx.send(str(responses))
@cc_add.command(name="simple")
@cc_create.command(name="simple")
@checks.mod_or_permissions(administrator=True)
async def cc_add_simple(self, ctx, command: str, *, text):
"""Adds a simple custom command
async def cc_create_simple(self, ctx, command: str.lower, *, text: str):
"""Add a simple custom command.
Example:
[p]customcom add simple yourcommand Text you want
- `[p]customcom create simple yourcommand Text you want`
"""
command = command.lower()
if command in self.bot.all_commands:
await ctx.send(_("That command is already a standard command."))
await ctx.send(_("There already exists a bot command with the same name."))
return
try:
await self.commandobj.create(ctx=ctx, command=command, response=text)
await ctx.send(_("Custom command successfully added."))
except AlreadyExists:
await ctx.send(
_("This command already exists. Use `{}` to edit it.").format(
"{}customcom edit".format(ctx.prefix)
_("This command already exists. Use `{command}` to edit it.").format(
command="{}customcom edit".format(ctx.prefix)
)
)
except ArgParseError as e:
await ctx.send(e.args[0])
@customcom.command(name="edit")
@customcom.command(name="cooldown")
@checks.mod_or_permissions(administrator=True)
async def cc_edit(self, ctx, command: str, *, text=None):
"""Edits a custom command
async def cc_cooldown(
self, ctx, command: str.lower, cooldown: int = None, *, per: str.lower = "member"
):
"""Set, edit, or view the cooldown for a custom command.
You may set cooldowns per member, channel, or guild. Multiple
cooldowns may be set. All cooldowns must be cooled to call the
custom command.
Example:
[p]customcom edit yourcommand Text you want
- `[p]customcom cooldown yourcommand 30`
"""
command = command.lower()
if cooldown is None:
try:
cooldowns = (await self.commandobj.get(ctx.message, command))[1]
except NotFound:
return await ctx.send(_("That command doesn't exist."))
if cooldowns:
cooldown = []
for per, rate in cooldowns.items():
cooldown.append(
_("A {} may call this command every {} seconds").format(per, rate)
)
return await ctx.send("\n".join(cooldown))
else:
return await ctx.send(_("This command has no cooldown."))
per = {"server": "guild", "user": "member"}.get(per, per)
allowed = ("guild", "member", "channel")
if per not in allowed:
return await ctx.send(_("{} must be one of {}").format("per", ", ".join(allowed)))
cooldown = {per: cooldown}
try:
await self.commandobj.edit(ctx=ctx, command=command, response=text)
await ctx.send(_("Custom command successfully edited."))
await self.commandobj.edit(ctx=ctx, command=command, cooldowns=cooldown, ask_for=False)
await ctx.send(_("Custom command cooldown successfully edited."))
except NotFound:
await ctx.send(
_("That command doesn't exist. Use `{}` to add it.").format(
"{}customcom add".format(ctx.prefix)
_("That command doesn't exist. Use `{command}` to add it.").format(
command="{}customcom create".format(ctx.prefix)
)
)
except ArgParseError as e:
await ctx.send(e.args[0])
@customcom.command(name="delete")
@checks.mod_or_permissions(administrator=True)
async def cc_delete(self, ctx, command: str):
"""Deletes a custom command
async def cc_delete(self, ctx, command: str.lower):
"""Delete a custom command
.
Example:
[p]customcom delete yourcommand"""
command = command.lower()
- `[p]customcom delete yourcommand`
"""
try:
await self.commandobj.delete(ctx=ctx, command=command)
await ctx.send(_("Custom command successfully deleted."))
except NotFound:
await ctx.send(_("That command doesn't exist."))
@customcom.command(name="edit")
@checks.mod_or_permissions(administrator=True)
async def cc_edit(self, ctx, command: str.lower, *, text: str = None):
"""Edit a custom command.
Example:
- `[p]customcom edit yourcommand Text you want`
"""
try:
await self.commandobj.edit(ctx=ctx, command=command, response=text)
await ctx.send(_("Custom command successfully edited."))
except NotFound:
await ctx.send(
_("That command doesn't exist. Use `{command}` to add it.").format(
command="{}customcom create".format(ctx.prefix)
)
)
except ArgParseError as e:
await ctx.send(e.args[0])
@customcom.command(name="list")
async def cc_list(self, ctx):
"""Shows custom commands list"""
@checks.bot_has_permissions(add_reactions=True)
async def cc_list(self, ctx: commands.Context):
"""List all available custom commands.
response = await CommandObj.get_commands(self.config.guild(ctx.guild))
The list displays a preview of each command's response, with
markdown escaped and newlines replaced with spaces.
"""
cc_dict = await CommandObj.get_commands(self.config.guild(ctx.guild))
if not response:
if not cc_dict:
await ctx.send(
_(
"There are no custom commands in this server."
" Use `{}` to start adding some."
).format("{}customcom add".format(ctx.prefix))
" Use `{command}` to start adding some."
).format(command="{}customcom create".format(ctx.prefix))
)
return
results = []
for command, body in response.items():
for command, body in sorted(cc_dict.items(), key=lambda t: t[0]):
responses = body["response"]
if isinstance(responses, list):
result = ", ".join(responses)
@@ -287,23 +351,41 @@ class CustomCommands:
result = responses
else:
continue
results.append("{command:<15} : {result}".format(command=command, result=result))
# Cut preview to 52 characters max
if len(result) > 52:
result = result[:49] + "..."
# Replace newlines with spaces
result = result.replace("\n", " ")
# Escape markdown and mass mentions
result = escape(result, formatting=True, mass_mentions=True)
results.append((f"{ctx.clean_prefix}{command}", result))
commands = "\n".join(results)
if len(commands) < 1500:
await ctx.send(box(commands))
if await ctx.embed_requested():
# We need a space before the newline incase the CC preview ends in link (GH-2295)
content = " \n".join(map("**{0[0]}** {0[1]}".format, results))
pages = list(pagify(content, page_length=1024))
embed_pages = []
for idx, page in enumerate(pages, start=1):
embed = discord.Embed(
title=_("Custom Command List"),
description=page,
colour=await ctx.embed_colour(),
)
embed.set_footer(text=_("Page {num}/{total}").format(num=idx, total=len(pages)))
embed_pages.append(embed)
await menus.menu(ctx, embed_pages, menus.DEFAULT_CONTROLS)
else:
for page in pagify(commands, delims=[" ", "\n"]):
await ctx.author.send(box(page))
content = "\n".join(map("{0[0]:<12} : {0[1]}".format, results))
pages = list(map(box, pagify(content, page_length=2000, shorten_by=10)))
await menus.menu(ctx, pages, menus.DEFAULT_CONTROLS)
async def on_message(self, message):
is_private = isinstance(message.channel, discord.abc.PrivateChannel)
# user_allowed check, will be replaced with self.bot.user_allowed or
# something similar once it's added
user_allowed = True
if len(message.content) < 2 or is_private or not user_allowed or message.author.bot:
return
@@ -313,22 +395,25 @@ class CustomCommands:
return
try:
raw_response = await self.commandobj.get(message=message, command=ctx.invoked_with)
raw_response, cooldowns = await self.commandobj.get(
message=message, command=ctx.invoked_with
)
if isinstance(raw_response, list):
raw_response = random.choice(raw_response)
elif isinstance(raw_response, str):
pass
else:
raise NotFound()
except NotFound:
if cooldowns:
self.test_cooldowns(ctx, ctx.invoked_with, cooldowns)
except CCError:
return
await self.call_cc_command(ctx, raw_response, message)
async def call_cc_command(self, ctx, raw_response, message) -> None:
# wrap the command here so it won't register with the bot
fake_cc = commands.Command(ctx.invoked_with, self.cc_callback)
fake_cc.params = self.prepare_args(raw_response)
ctx.command = fake_cc
await self.bot.invoke(ctx)
if not ctx.command_failed:
await self.cc_command(*ctx.args, **ctx.kwargs, raw_response=raw_response)
@@ -344,11 +429,11 @@ class CustomCommands:
async def cc_command(self, ctx, *cc_args, raw_response, **cc_kwargs) -> None:
cc_args = (*cc_args, *cc_kwargs.values())
results = re.findall(r"\{([^}]+)\}", raw_response)
results = re.findall(r"{([^}]+)\}", raw_response)
for result in results:
param = self.transform_parameter(result, ctx.message)
raw_response = raw_response.replace("{" + result + "}", param)
results = re.findall(r"\{((\d+)[^\.}]*(\.[^:}]+)?[^}]*)\}", raw_response)
results = re.findall(r"{((\d+)[^.}]*(\.[^:}]+)?[^}]*)\}", raw_response)
if results:
low = min(int(result[1]) for result in results)
for result in results:
@@ -357,9 +442,10 @@ class CustomCommands:
raw_response = raw_response.replace("{" + result[0] + "}", arg)
await ctx.send(raw_response)
def prepare_args(self, raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"\{(\d+)[^:}]*(:[^\.}]*)?[^}]*\}", raw_response)
default = [["ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD)]]
@staticmethod
def prepare_args(raw_response) -> Mapping[str, Parameter]:
args = re.findall(r"{(\d+)[^:}]*(:[^.}]*)?[^}]*\}", raw_response)
default = [("ctx", Parameter("ctx", Parameter.POSITIONAL_OR_KEYWORD))]
if not args:
return OrderedDict(default)
allowed_builtins = {
@@ -382,9 +468,8 @@ class CustomCommands:
gaps = set(indices).symmetric_difference(range(high + 1))
if gaps:
raise ArgParseError(
_("Arguments must be sequential. Missing arguments: {}.").format(
", ".join(str(i + low) for i in gaps)
)
_("Arguments must be sequential. Missing arguments: ")
+ ", ".join(str(i + low) for i in gaps)
)
fin = [Parameter("_" + str(i), Parameter.POSITIONAL_OR_KEYWORD) for i in range(high + 1)]
for arg in args:
@@ -400,7 +485,7 @@ class CustomCommands:
try:
anno = getattr(discord, anno)
# force an AttributeError if there's no discord.py converter
getattr(commands.converter, anno.__name__ + "Converter")
getattr(commands, anno.__name__ + "Converter")
except AttributeError:
anno = allowed_builtins.get(anno.lower(), Parameter.empty)
if (
@@ -409,8 +494,12 @@ class CustomCommands:
and anno != fin[index].annotation
):
raise ArgParseError(
_('Conflicting colon notation for argument {}: "{}" and "{}".').format(
index + low, fin[index].annotation.__name__, anno.__name__
_(
'Conflicting colon notation for argument {index}: "{name1}" and "{name2}".'
).format(
index=index + low,
name1=fin[index].annotation.__name__,
name2=anno.__name__,
)
)
if anno is not Parameter.empty:
@@ -429,7 +518,29 @@ class CustomCommands:
fin = default + [(p.name, p) for p in fin]
return OrderedDict(fin)
def transform_arg(self, result, attr, obj) -> str:
def test_cooldowns(self, ctx, command, cooldowns):
now = datetime.utcnow()
new_cooldowns = {}
for per, rate in cooldowns.items():
if per == "guild":
key = (command, ctx.guild)
elif per == "channel":
key = (command, ctx.guild, ctx.channel)
elif per == "member":
key = (command, ctx.guild, ctx.author)
else:
raise ValueError(per)
cooldown = self.cooldowns.get(key)
if cooldown:
cooldown += timedelta(seconds=rate)
if cooldown > now:
raise OnCooldown()
new_cooldowns[key] = now
# only update cooldowns if the command isn't on cooldown
self.cooldowns.update(new_cooldowns)
@staticmethod
def transform_arg(result, attr, obj) -> str:
attr = attr[1:] # strip initial dot
if not attr:
return str(obj)
@@ -439,7 +550,8 @@ class CustomCommands:
return raw_result
return str(getattr(obj, attr, raw_result))
def transform_parameter(self, result, message) -> str:
@staticmethod
def transform_parameter(result, message) -> str:
"""
For security reasons only specific objects are allowed
Internals are ignored
@@ -463,3 +575,14 @@ class CustomCommands:
else:
return raw_result
return str(getattr(first, second, raw_result))
async def get_command_names(self, guild: discord.Guild) -> Set[str]:
"""Get all custom command names in a guild.
Returns
--------
Set[str]
A set of all custom command names.
"""
return set(await CommandObj.get_commands(self.config.guild(guild)))

View File

@@ -6,29 +6,26 @@ from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.cogs.dataconverter.core_specs import SpecResolver
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("DataConverter", __file__)
@cog_i18n(_)
class DataConverter:
"""
Cog for importing Red v2 Data
"""
class DataConverter(commands.Cog):
"""Import Red V2 data to your V3 instance."""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
@checks.is_owner()
@commands.command(name="convertdata")
async def dataconversioncommand(self, ctx: commands.Context, v2path: str):
"""
Interactive prompt for importing data from Red v2
"""Interactive prompt for importing data from Red V2.
Takes the path where the v2 install is
Overwrites values which have entries in both v2 and v3,
use with caution.
Takes the path where the V2 install is, and overwrites
values which have entries in both V2 and v3; use with caution.
"""
resolver = SpecResolver(Path(v2path.strip()))
@@ -47,13 +44,12 @@ class DataConverter:
menu_message = await ctx.send(box(menu))
def pred(m):
return m.channel == ctx.channel and m.author == ctx.author
try:
message = await self.bot.wait_for("message", check=pred, timeout=60)
message = await self.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=60
)
except asyncio.TimeoutError:
return await ctx.send(_("Try this again when you are more ready"))
return await ctx.send(_("Try this again when you are ready."))
else:
if message.content.strip().lower() in ["quit", "exit", "-1", "q", "cancel"]:
return await ctx.tick()
@@ -71,7 +67,7 @@ class DataConverter:
else:
return await ctx.send(
_(
"There isn't anything else I know how to convert here."
"\nThere might be more things I can convert in the future."
"There isn't anything else I know how to convert here.\n"
"There might be more things I can convert in the future."
)
)

View File

@@ -1,6 +1,7 @@
from redbot.core.bot import Red
from .downloader import Downloader
def setup(bot: Red):
bot.add_cog(Downloader(bot))
async def setup(bot):
cog = Downloader(bot)
await cog.initialize()
bot.add_cog(cog)

View File

@@ -1,11 +1,15 @@
import asyncio
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
from redbot.core.utils.predicates import MessagePredicate
__all__ = ["do_install_agreement"]
REPO_INSTALL_MSG = (
T_ = Translator("DownloaderChecks", __file__)
_ = lambda s: s
REPO_INSTALL_MSG = _(
"You're about to add a 3rd party repository. The creator of Red"
" and its community have no responsibility for any potential "
"damage that the content of 3rd party repositories might cause."
@@ -14,6 +18,7 @@ REPO_INSTALL_MSG = (
"shown again until the next reboot.\n\nYou have **30** seconds"
" to reply to this message."
)
_ = T_
async def do_install_agreement(ctx: commands.Context):
@@ -21,15 +26,14 @@ async def do_install_agreement(ctx: commands.Context):
if downloader is None or downloader.already_agreed:
return True
def does_agree(msg: discord.Message):
return ctx.author == msg.author and ctx.channel == msg.channel and msg.content == "I agree"
await ctx.send(REPO_INSTALL_MSG)
await ctx.send(T_(REPO_INSTALL_MSG))
try:
await ctx.bot.wait_for("message", check=does_agree, timeout=30)
await ctx.bot.wait_for(
"message", check=MessagePredicate.lower_equal_to("i agree", ctx), timeout=30
)
except asyncio.TimeoutError:
await ctx.send("Your response has timed out, please try again.")
await ctx.send(_("Your response has timed out, please try again."))
return False
downloader.already_agreed = True

View File

@@ -1,16 +1,20 @@
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
from .installable import Installable
_ = Translator("Koala", __file__)
class InstalledCog(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> Installable:
class InstalledCog(Installable):
@classmethod
async def convert(cls, ctx: commands.Context, arg: str) -> Installable:
downloader = ctx.bot.get_cog("Downloader")
if downloader is None:
raise commands.CommandError("Downloader not loaded.")
raise commands.CommandError(_("No Downloader cog found."))
cog = discord.utils.get(await downloader.installed_cogs(), name=arg)
if cog is None:
raise commands.BadArgument("That cog is not installed")
raise commands.BadArgument(_("That cog is not installed"))
return cog

View File

@@ -1,23 +1,24 @@
import asyncio
import contextlib
import os
import shutil
import sys
from pathlib import Path
from sys import path as syspath
from typing import Tuple, Union
from typing import Tuple, Union, Iterable
import discord
import sys
from redbot.core import Config
from redbot.core import checks
from redbot.core import checks, commands, Config
from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core import commands
from redbot.core.utils.chat_formatting import box, pagify, humanize_list, inline
from redbot.core.utils.menus import start_adding_reactions
from redbot.core.utils.predicates import MessagePredicate, ReactionPredicate
from redbot.core.bot import Red
from . import errors
from .checks import do_install_agreement
from .converters import InstalledCog
from .errors import CloningError, ExistingGitRepo
from .installable import Installable
from .log import log
from .repo_manager import RepoManager, Repo
@@ -26,8 +27,9 @@ _ = Translator("Downloader", __file__)
@cog_i18n(_)
class Downloader:
class Downloader(commands.Cog):
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.conf = Config.get_conf(self, identifier=998240343, force_registration=True)
@@ -51,6 +53,9 @@ class Downloader:
self._repo_manager = RepoManager()
async def initialize(self):
await self._repo_manager.initialize()
async def cog_install_path(self):
"""Get the current cog install path.
@@ -107,7 +112,7 @@ class Downloader:
installed.remove(cog_json)
await self.conf.installed.set(installed)
async def _reinstall_cogs(self, cogs: Tuple[Installable]) -> Tuple[Installable]:
async def _reinstall_cogs(self, cogs: Iterable[Installable]) -> Tuple[Installable]:
"""
Installs a list of cogs, used when updating.
:param cogs:
@@ -121,7 +126,7 @@ class Downloader:
# noinspection PyTypeChecker
return tuple(failed)
async def _reinstall_libraries(self, cogs: Tuple[Installable]) -> Tuple[Installable]:
async def _reinstall_libraries(self, cogs: Iterable[Installable]) -> Tuple[Installable]:
"""
Reinstalls any shared libraries from the repos of cogs that
were updated.
@@ -141,7 +146,7 @@ class Downloader:
# noinspection PyTypeChecker
return tuple(failed)
async def _reinstall_requirements(self, cogs: Tuple[Installable]) -> bool:
async def _reinstall_requirements(self, cogs: Iterable[Installable]) -> bool:
"""
Reinstalls requirements for given cogs that have been updated.
Returns a bool that indicates if all requirement installations
@@ -188,9 +193,7 @@ class Downloader:
@commands.command()
@checks.is_owner()
async def pipinstall(self, ctx, *deps: str):
"""
Installs a group of dependencies using pip.
"""
"""Install a group of dependencies using pip."""
repo = Repo("", "", "", Path.cwd(), loop=ctx.bot.loop)
success = await repo.install_raw_requirements(deps, self.LIB_PATH)
@@ -207,18 +210,15 @@ class Downloader:
@commands.group()
@checks.is_owner()
async def repo(self, ctx):
"""
Command group for managing Downloader repos.
"""
"""Repo management commands."""
pass
@repo.command(name="add")
async def _repo_add(self, ctx, name: str, repo_url: str, branch: str = None):
"""
Add a new repo to Downloader.
"""Add a new repo.
Name can only contain characters A-z, numbers and underscore
Branch will default to master if not specified
The name can only contain characters A-z, numbers and underscores.
The branch will be the default branch if not specified.
"""
agreed = await do_install_agreement(ctx)
if not agreed:
@@ -226,30 +226,33 @@ class Downloader:
try:
# noinspection PyTypeChecker
repo = await self._repo_manager.add_repo(name=name, url=repo_url, branch=branch)
except ExistingGitRepo:
except errors.ExistingGitRepo:
await ctx.send(_("That git repo has already been added under another name."))
except CloningError:
except errors.CloningError as err:
await ctx.send(_("Something went wrong during the cloning process."))
log.exception(_("Something went wrong during the cloning process."))
log.exception(
"Something went wrong whilst cloning %s (to revision: %s)",
repo_url,
branch,
exc_info=err,
)
else:
await ctx.send(_("Repo `{}` successfully added.").format(name))
await ctx.send(_("Repo `{name}` successfully added.").format(name=name))
if repo.install_msg is not None:
await ctx.send(repo.install_msg.replace("[p]", ctx.prefix))
@repo.command(name="delete")
async def _repo_del(self, ctx, repo_name: Repo):
"""
Removes a repo from Downloader and its' files.
"""
await self._repo_manager.delete_repo(repo_name.name)
@repo.command(name="delete", aliases=["remove"], usage="<repo_name>")
async def _repo_del(self, ctx, repo: Repo):
"""Remove a repo and its files."""
await self._repo_manager.delete_repo(repo.name)
await ctx.send(_("The repo `{}` has been deleted successfully.").format(repo_name.name))
await ctx.send(
_("The repo `{repo.name}` has been deleted successfully.").format(repo=repo)
)
@repo.command(name="list")
async def _repo_list(self, ctx):
"""
Lists all installed repos.
"""
"""List all installed repos."""
repos = self._repo_manager.get_all_repo_names()
repos = sorted(repos, key=str.lower)
joined = _("Installed Repos:\n\n")
@@ -260,124 +263,159 @@ class Downloader:
for page in pagify(joined, ["\n"], shorten_by=16):
await ctx.send(box(page.lstrip(" "), lang="diff"))
@repo.command(name="info")
async def _repo_info(self, ctx, repo_name: Repo):
"""
Lists information about a single repo
"""
if repo_name is None:
await ctx.send(_("There is no repo `{}`").format(repo_name.name))
@repo.command(name="info", usage="<repo_name>")
async def _repo_info(self, ctx, repo: Repo):
"""Show information about a repo."""
if repo is None:
await ctx.send(_("Repo `{repo.name}` not found.").format(repo=repo))
return
msg = _("Information on {}:\n{}").format(repo_name.name, repo_name.description or "")
msg = _("Information on {repo.name}:\n{description}").format(
repo=repo, description=repo.description or ""
)
await ctx.send(box(msg))
@commands.group()
@checks.is_owner()
async def cog(self, ctx):
"""
Command group for managing installable Cogs.
"""
"""Cog installation management commands."""
pass
@cog.command(name="install")
async def _cog_install(self, ctx, repo_name: Repo, cog_name: str):
"""
Installs a cog from the given repo.
"""
cog = discord.utils.get(repo_name.available_cogs, name=cog_name) # type: Installable
@cog.command(name="install", usage="<repo_name> <cog_name>")
async def _cog_install(self, ctx, repo: Repo, cog_name: str):
"""Install a cog from the given repo."""
cog: Installable = discord.utils.get(repo.available_cogs, name=cog_name)
if cog is None:
await ctx.send(
_("Error, there is no cog by the name of `{}` in the `{}` repo.").format(
cog_name, repo_name.name
)
_(
"Error: there is no cog by the name of `{cog_name}` in the `{repo.name}` repo."
).format(cog_name=cog_name, repo=repo)
)
return
elif cog.min_python_version > sys.version_info:
await ctx.send(
_("This cog requires at least python version {}, aborting install.").format(
".".join([str(n) for n in cog.min_python_version])
_("This cog requires at least python version {version}, aborting install.").format(
version=".".join([str(n) for n in cog.min_python_version])
)
)
return
if not await repo_name.install_requirements(cog, self.LIB_PATH):
if not await repo.install_requirements(cog, self.LIB_PATH):
await ctx.send(
_("Failed to install the required libraries for `{}`: `{}`").format(
cog.name, cog.requirements
)
_(
"Failed to install the required libraries for `{cog_name}`: `{libraries}`"
).format(cog_name=cog.name, libraries=cog.requirements)
)
return
await repo_name.install_cog(cog, await self.cog_install_path())
await repo.install_cog(cog, await self.cog_install_path())
await self._add_to_installed(cog)
await repo_name.install_libraries(self.SHAREDLIB_PATH)
await repo.install_libraries(self.SHAREDLIB_PATH)
await ctx.send(_("`{}` cog successfully installed.").format(cog_name))
await ctx.send(_("Cog `{cog_name}` successfully installed.").format(cog_name=cog_name))
if cog.install_msg is not None:
await ctx.send(cog.install_msg.replace("[p]", ctx.prefix))
@cog.command(name="uninstall")
async def _cog_uninstall(self, ctx, cog_name: InstalledCog):
@cog.command(name="uninstall", usage="<cog_name>")
async def _cog_uninstall(self, ctx, cog: InstalledCog):
"""Uninstall a cog.
You may only uninstall cogs which were previously installed
by Downloader.
"""
Allows you to uninstall cogs that were previously installed
through Downloader.
"""
# noinspection PyUnresolvedReferences,PyProtectedMember
real_name = cog_name.name
real_name = cog.name
poss_installed_path = (await self.cog_install_path()) / real_name
if poss_installed_path.exists():
ctx.bot.unload_extension(real_name)
await self._delete_cog(poss_installed_path)
# noinspection PyTypeChecker
await self._remove_from_installed(cog_name)
await ctx.send(_("`{}` was successfully removed.").format(real_name))
await self._remove_from_installed(cog)
await ctx.send(
_("Cog `{cog_name}` was successfully uninstalled.").format(cog_name=real_name)
)
else:
await ctx.send(
_(
"That cog was installed but can no longer"
" be located. You may need to remove it's"
" files manually if it is still usable."
)
" Also make sure you've unloaded the cog"
" with `{prefix}unload {cog_name}`."
).format(prefix=ctx.prefix, cog_name=real_name)
)
@cog.command(name="update")
async def _cog_update(self, ctx, cog_name: InstalledCog = None):
"""
Updates all cogs or one of your choosing.
"""
"""Update all cogs, or one of your choosing."""
installed_cogs = set(await self.installed_cogs())
if cog_name is None:
updated = await self._repo_manager.update_all_repos()
async with ctx.typing():
if cog_name is None:
updated = await self._repo_manager.update_all_repos()
else:
try:
updated = await self._repo_manager.update_repo(cog_name.repo_name)
except KeyError:
# Thrown if the repo no longer exists
updated = {}
updated_cogs = set(cog for repo in updated for cog in repo.available_cogs)
installed_and_updated = updated_cogs & installed_cogs
if installed_and_updated:
await self._reinstall_requirements(installed_and_updated)
await self._reinstall_cogs(installed_and_updated)
await self._reinstall_libraries(installed_and_updated)
message = _("Cog update completed successfully.")
cognames = {c.name for c in installed_and_updated}
message += _("\nUpdated: ") + humanize_list(tuple(map(inline, cognames)))
else:
await ctx.send(_("All installed cogs are already up to date."))
return
await ctx.send(message)
cognames &= set(ctx.bot.extensions.keys()) # only reload loaded cogs
if not cognames:
return await ctx.send(
_("None of the updated cogs were previously loaded. Update complete.")
)
message = _("Would you like to reload the updated cogs?")
can_react = ctx.channel.permissions_for(ctx.me).add_reactions
if not can_react:
message += " (y/n)"
query: discord.Message = await ctx.send(message)
if can_react:
# noinspection PyAsyncCall
start_adding_reactions(query, ReactionPredicate.YES_OR_NO_EMOJIS, ctx.bot.loop)
pred = ReactionPredicate.yes_or_no(query, ctx.author)
event = "reaction_add"
else:
try:
updated = await self._repo_manager.update_repo(cog_name.repo_name)
except KeyError:
# Thrown if the repo no longer exists
updated = {}
pred = MessagePredicate.yes_or_no(ctx)
event = "message"
try:
await ctx.bot.wait_for(event, check=pred, timeout=30)
except asyncio.TimeoutError:
await query.delete()
return
updated_cogs = set(cog for repo in updated.keys() for cog in repo.available_cogs)
installed_and_updated = updated_cogs & installed_cogs
if pred.result is True:
if can_react:
with contextlib.suppress(discord.Forbidden):
await query.clear_reactions()
await ctx.invoke(ctx.bot.get_cog("Core").reload, *cognames)
else:
if can_react:
await query.delete()
else:
await ctx.send(_("OK then."))
# noinspection PyTypeChecker
await self._reinstall_requirements(installed_and_updated)
# noinspection PyTypeChecker
await self._reinstall_cogs(installed_and_updated)
# noinspection PyTypeChecker
await self._reinstall_libraries(installed_and_updated)
await ctx.send(_("Cog update completed successfully."))
@cog.command(name="list")
async def _cog_list(self, ctx, repo_name: Repo):
"""
Lists all available cogs from a single repo.
"""
@cog.command(name="list", usage="<repo_name>")
async def _cog_list(self, ctx, repo: Repo):
"""List all available cogs from a single repo."""
installed = await self.installed_cogs()
installed_str = ""
if installed:
@@ -385,10 +423,10 @@ class Downloader:
[
"- {}{}".format(i.name, ": {}".format(i.short) if i.short else "")
for i in installed
if i.repo_name == repo_name.name
if i.repo_name == repo.name
]
)
cogs = repo_name.available_cogs
cogs = repo.available_cogs
cogs = _("Available Cogs:\n") + "\n".join(
[
"+ {}: {}".format(c.name, c.short or "")
@@ -400,20 +438,24 @@ class Downloader:
for page in pagify(cogs, ["\n"], shorten_by=16):
await ctx.send(box(page.lstrip(" "), lang="diff"))
@cog.command(name="info")
async def _cog_info(self, ctx, repo_name: Repo, cog_name: str):
"""
Lists information about a single cog.
"""
cog = discord.utils.get(repo_name.available_cogs, name=cog_name)
@cog.command(name="info", usage="<repo_name> <cog_name>")
async def _cog_info(self, ctx, repo: Repo, cog_name: str):
"""List information about a single cog."""
cog = discord.utils.get(repo.available_cogs, name=cog_name)
if cog is None:
await ctx.send(
_("There is no cog `{}` in the repo `{}`").format(cog_name, repo_name.name)
_("There is no cog `{cog_name}` in the repo `{repo.name}`").format(
cog_name=cog_name, repo=repo
)
)
return
msg = _("Information on {}:\n{}\n\nRequirements: {}").format(
cog.name, cog.description or "", ", ".join(cog.requirements) or "None"
msg = _(
"Information on {cog_name}:\n{description}\n\nRequirements: {requirements}"
).format(
cog_name=cog.name,
description=cog.description or "",
requirements=", ".join(cog.requirements) or "None",
)
await ctx.send(box(msg))
@@ -460,16 +502,16 @@ class Downloader:
if isinstance(cog_installable, Installable):
made_by = ", ".join(cog_installable.author) or _("Missing from info.json")
repo = self._repo_manager.get_repo(cog_installable.repo_name)
repo_url = repo.url
repo_url = _("Missing from installed repos") if repo is None else repo.url
cog_name = cog_installable.name
else:
made_by = "26 & co."
repo_url = "https://github.com/Cog-Creators/Red-DiscordBot"
cog_name = cog_installable.__class__.__name__
msg = _("Command: {}\nMade by: {}\nRepo: {}\nCog name: {}")
msg = _("Command: {command}\nMade by: {author}\nRepo: {repo}\nCog name: {cog}")
return msg.format(command_name, made_by, repo_url, cog_name)
return msg.format(command=command_name, author=made_by, repo=repo_url, cog=cog_name)
def cog_name_from_instance(self, instance: object) -> str:
"""Determines the cog name that Downloader knows from the cog instance.
@@ -492,9 +534,9 @@ class Downloader:
@commands.command()
async def findcog(self, ctx: commands.Context, command_name: str):
"""
Figures out which cog a command comes from. Only works with loaded
cogs.
"""Find which cog a command comes from.
This will only work with loaded cogs.
"""
command = ctx.bot.all_commands.get(command_name)

View File

@@ -9,6 +9,7 @@ __all__ = [
"HardResetError",
"UpdateError",
"GitDiffError",
"NoRemoteURL",
"PipError",
]
@@ -96,6 +97,14 @@ class GitDiffError(GitException):
pass
class NoRemoteURL(GitException):
"""
Thrown when no remote URL exists for a repo.
"""
pass
class PipError(DownloaderException):
"""
Thrown when pip returns a non-zero return code.

View File

@@ -2,27 +2,33 @@ import asyncio
import functools
import os
import pkgutil
import shutil
import re
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from subprocess import run as sp_run, PIPE
from sys import executable
from typing import Tuple, MutableMapping, Union
from typing import Tuple, MutableMapping, Union, Optional
from redbot.core import data_manager, commands
from redbot.core.utils import safe_delete
from .errors import *
from redbot.core.i18n import Translator
from . import errors
from .installable import Installable, InstallableType
from .json_mixins import RepoJSONMixin
from .log import log
_ = Translator("RepoManager", __file__)
class Repo(RepoJSONMixin):
GIT_CLONE = "git clone -b {branch} {url} {folder}"
GIT_CLONE_NO_BRANCH = "git clone {url} {folder}"
GIT_CLONE = "git clone --recurse-submodules -b {branch} {url} {folder}"
GIT_CLONE_NO_BRANCH = "git clone --recurse-submodules {url} {folder}"
GIT_CURRENT_BRANCH = "git -C {path} rev-parse --abbrev-ref HEAD"
GIT_LATEST_COMMIT = "git -C {path} rev-parse {branch}"
GIT_HARD_RESET = "git -C {path} reset --hard origin/{branch} -q"
GIT_PULL = "git -C {path} pull -q --ff-only"
GIT_PULL = "git -C {path} pull --recurse-submodules -q --ff-only"
GIT_DIFF_FILE_STATUS = "git -C {path} diff --no-commit-id --name-status {old_hash} {new_hash}"
GIT_LOG = "git -C {path} log --relative-date --reverse {old_hash}.. {relative_file_path}"
GIT_DISCOVER_REMOTE_URL = "git -C {path} config --get remote.origin.url"
@@ -62,13 +68,15 @@ class Repo(RepoJSONMixin):
async def convert(cls, ctx: commands.Context, argument: str):
downloader_cog = ctx.bot.get_cog("Downloader")
if downloader_cog is None:
raise commands.CommandError("No Downloader cog found.")
raise commands.CommandError(_("No Downloader cog found."))
# noinspection PyProtectedMember
repo_manager = downloader_cog._repo_manager
poss_repo = repo_manager.get_repo(argument)
if poss_repo is None:
raise commands.BadArgument("Repo by the name {} does not exist.".format(argument))
raise commands.BadArgument(
_('Repo by the name "{repo_name}" does not exist.').format(repo_name=argument)
)
return poss_repo
def _existing_git_repo(self) -> (bool, Path):
@@ -92,7 +100,9 @@ class Repo(RepoJSONMixin):
)
if p.returncode != 0:
raise GitDiffError("Git diff failed for repo at path: {}".format(self.folder_path))
raise errors.GitDiffError(
"Git diff failed for repo at path: {}".format(self.folder_path)
)
stdout = p.stdout.strip().decode().split("\n")
@@ -122,7 +132,7 @@ class Repo(RepoJSONMixin):
)
if p.returncode != 0:
raise GitException(
raise errors.GitException(
"An exception occurred while executing git log on"
" this repo: {}".format(self.folder_path)
)
@@ -176,7 +186,7 @@ class Repo(RepoJSONMixin):
"""
exists, path = self._existing_git_repo()
if exists:
raise ExistingGitRepo("A git repo already exists at path: {}".format(path))
raise errors.ExistingGitRepo("A git repo already exists at path: {}".format(path))
if self.branch is not None:
p = await self._run(
@@ -189,8 +199,10 @@ class Repo(RepoJSONMixin):
self.GIT_CLONE_NO_BRANCH.format(url=self.url, folder=self.folder_path).split()
)
if p.returncode != 0:
raise CloningError("Error when running git clone.")
if p.returncode:
# Try cleaning up folder
shutil.rmtree(str(self.folder_path), ignore_errors=True)
raise errors.CloningError("Error when running git clone.")
if self.branch is None:
self.branch = await self.current_branch()
@@ -210,12 +222,14 @@ class Repo(RepoJSONMixin):
"""
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo("A git repo does not exist at path: {}".format(self.folder_path))
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(self.GIT_CURRENT_BRANCH.format(path=self.folder_path).split())
if p.returncode != 0:
raise GitException(
raise errors.GitException(
"Could not determine current branch at path: {}".format(self.folder_path)
)
@@ -240,14 +254,16 @@ class Repo(RepoJSONMixin):
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo("A git repo does not exist at path: {}".format(self.folder_path))
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(
self.GIT_LATEST_COMMIT.format(path=self.folder_path, branch=branch).split()
)
if p.returncode != 0:
raise CurrentHashError("Unable to determine old commit hash.")
raise errors.CurrentHashError("Unable to determine old commit hash.")
return p.stdout.decode().strip()
@@ -267,8 +283,9 @@ class Repo(RepoJSONMixin):
Raises
------
RuntimeError
.NoRemoteURL
When the folder does not contain a git repo with a FETCH URL.
"""
if folder is None:
folder = self.folder_path
@@ -276,7 +293,7 @@ class Repo(RepoJSONMixin):
p = await self._run(Repo.GIT_DISCOVER_REMOTE_URL.format(path=folder).split())
if p.returncode != 0:
raise RuntimeError("Unable to discover a repo URL.")
raise errors.NoRemoteURL("Unable to discover a repo URL.")
return p.stdout.decode().strip()
@@ -294,14 +311,16 @@ class Repo(RepoJSONMixin):
exists, _ = self._existing_git_repo()
if not exists:
raise MissingGitRepo("A git repo does not exist at path: {}".format(self.folder_path))
raise errors.MissingGitRepo(
"A git repo does not exist at path: {}".format(self.folder_path)
)
p = await self._run(
self.GIT_HARD_RESET.format(path=self.folder_path, branch=branch).split()
)
if p.returncode != 0:
raise HardResetError(
raise errors.HardResetError(
"Some error occurred when trying to"
" execute a hard reset on the repo at"
" the following path: {}".format(self.folder_path)
@@ -324,7 +343,7 @@ class Repo(RepoJSONMixin):
p = await self._run(self.GIT_PULL.format(path=self.folder_path).split())
if p.returncode != 0:
raise UpdateError(
raise errors.UpdateError(
"Git pull returned a non zero exit code"
" for the repo located at path: {}".format(self.folder_path)
)
@@ -353,7 +372,7 @@ class Repo(RepoJSONMixin):
"""
if cog not in self.available_cogs:
raise DownloaderException("That cog does not exist in this repo")
raise errors.DownloaderException("That cog does not exist in this repo")
if not target_dir.is_dir():
raise ValueError("That target directory is not actually a directory.")
@@ -489,13 +508,19 @@ class Repo(RepoJSONMixin):
class RepoManager:
def __init__(self):
GITHUB_OR_GITLAB_RE = re.compile("https?://git(?:hub)|(?:lab)\.com/")
TREE_URL_RE = re.compile(r"(?P<tree>/tree)/(?P<branch>\S+)$")
def __init__(self):
self._repos = {}
loop = asyncio.get_event_loop()
loop.create_task(self._load_repos(set=True)) # str_name: Repo
async def initialize(self):
await self._load_repos(set=True)
@property
def repos_folder(self) -> Path:
data_folder = data_manager.cog_data_path(self)
@@ -507,10 +532,10 @@ class RepoManager:
@staticmethod
def validate_and_normalize_repo_name(name: str) -> str:
if not name.isidentifier():
raise InvalidRepoName("Not a valid Python variable name.")
raise errors.InvalidRepoName("Not a valid Python variable name.")
return name.lower()
async def add_repo(self, url: str, name: str, branch: str = "master") -> Repo:
async def add_repo(self, url: str, name: str, branch: Optional[str] = None) -> Repo:
"""Add and clone a git repository.
Parameters
@@ -529,10 +554,12 @@ class RepoManager:
"""
if self.does_repo_exist(name):
raise ExistingGitRepo(
raise errors.ExistingGitRepo(
"That repo name you provided already exists. Please choose another."
)
url, branch = self._parse_url(url, branch)
# noinspection PyTypeChecker
r = Repo(url=url, name=name, branch=branch, folder_path=self.repos_folder / name)
await r.clone()
@@ -578,13 +605,13 @@ class RepoManager:
Raises
------
MissingGitRepo
.MissingGitRepo
If the repo does not exist.
"""
repo = self.get_repo(name)
if repo is None:
raise MissingGitRepo("There is no repo with the name {}".format(name))
raise errors.MissingGitRepo("There is no repo with the name {}".format(name))
safe_delete(repo.folder_path)
@@ -623,10 +650,26 @@ class RepoManager:
continue
try:
ret[folder.stem] = await Repo.from_folder(folder)
except RuntimeError:
# Thrown when there's no findable git remote URL
pass
except errors.NoRemoteURL:
log.warning("A remote URL does not exist for repo %s", folder.stem)
except errors.DownloaderException as err:
log.error("Discarding repo %s due to error.", folder.stem, exc_info=err)
shutil.rmtree(
str(folder),
onerror=lambda func, path, exc: log.error(
"Failed to remove folder %s", path, exc_info=exc
),
)
if set:
self._repos = ret
return ret
def _parse_url(self, url: str, branch: Optional[str]) -> Tuple[str, Optional[str]]:
if self.GITHUB_OR_GITLAB_RE.match(url):
tree_url_match = self.TREE_URL_RE.search(url)
if tree_url_match:
url = url[: tree_url_match.start("tree")]
if branch is None:
branch = tree_url_match["branch"]
return url, branch

View File

@@ -3,18 +3,19 @@ import logging
import random
from collections import defaultdict, deque
from enum import Enum
from typing import cast, Iterable
import discord
from redbot.cogs.bank import check_global_setting_guildowner, check_global_setting_admin
from redbot.core import Config, bank, commands
from redbot.core import Config, bank, commands, errors
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.bot import Red
_ = Translator("Economy", __file__)
T_ = Translator("Economy", __file__)
logger = logging.getLogger("red.economy")
@@ -34,6 +35,7 @@ class SMReel(Enum):
snowflake = "\N{SNOWFLAKE}"
_ = lambda s: s
PAYOUTS = {
(SMReel.two, SMReel.two, SMReel.six): {
"payout": lambda x: x * 2500 + x,
@@ -72,6 +74,7 @@ SLOT_PAYOUTS_MSG = _(
"Three symbols: +500\n"
"Two symbols: Bet * 2"
).format(**SMReel.__dict__)
_ = T_
def guild_only_check():
@@ -105,10 +108,8 @@ class SetParser:
@cog_i18n(_)
class Economy:
"""Economy
Get rich and have fun with imaginary currency!"""
class Economy(commands.Cog):
"""Get rich and have fun with imaginary currency!"""
default_guild_settings = {
"PAYDAY_TIME": 300,
@@ -128,6 +129,7 @@ class Economy:
default_user_settings = default_member_settings
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.file_path = "data/economy/settings.json"
self.config = Config.get_conf(self, 1256844281)
@@ -141,12 +143,12 @@ class Economy:
@guild_only_check()
@commands.group(name="bank")
async def _bank(self, ctx: commands.Context):
"""Bank operations"""
"""Manage the bank."""
pass
@_bank.command()
async def balance(self, ctx: commands.Context, user: discord.Member = None):
"""Shows balance of user.
"""Show the user's account balance.
Defaults to yours."""
if user is None:
@@ -155,86 +157,100 @@ class Economy:
bal = await bank.get_balance(user)
currency = await bank.get_currency_name(ctx.guild)
await ctx.send(_("{}'s balance is {} {}").format(user.display_name, bal, currency))
await ctx.send(
_("{user}'s balance is {num} {currency}").format(
user=user.display_name, num=bal, currency=currency
)
)
@_bank.command()
async def transfer(self, ctx: commands.Context, to: discord.Member, amount: int):
"""Transfer currency to other users"""
"""Transfer currency to other users."""
from_ = ctx.author
currency = await bank.get_currency_name(ctx.guild)
try:
await bank.transfer_credits(from_, to, amount)
except ValueError as e:
except (ValueError, errors.BalanceTooHigh) as e:
return await ctx.send(str(e))
await ctx.send(
_("{} transferred {} {} to {}").format(
from_.display_name, amount, currency, to.display_name
_("{user} transferred {num} {currency} to {other_user}").format(
user=from_.display_name, num=amount, currency=currency, other_user=to.display_name
)
)
@_bank.command(name="set")
@check_global_setting_admin()
async def _set(self, ctx: commands.Context, to: discord.Member, creds: SetParser):
"""Sets balance of user's bank account. See help for more operations
"""Set the balance of user's bank account.
Passing positive and negative values will add/remove currency instead
Passing positive and negative values will add/remove currency instead.
Examples:
bank set @Twentysix 26 - Sets balance to 26
bank set @Twentysix +2 - Increases balance by 2
bank set @Twentysix -6 - Decreases balance by 6"""
- `[p]bank set @Twentysix 26` - Sets balance to 26
- `[p]bank set @Twentysix +2` - Increases balance by 2
- `[p]bank set @Twentysix -6` - Decreases balance by 6
"""
author = ctx.author
currency = await bank.get_currency_name(ctx.guild)
if creds.operation == "deposit":
await bank.deposit_credits(to, creds.sum)
await ctx.send(
_("{} added {} {} to {}'s account.").format(
author.display_name, creds.sum, currency, to.display_name
try:
if creds.operation == "deposit":
await bank.deposit_credits(to, creds.sum)
msg = _("{author} added {num} {currency} to {user}'s account.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
)
elif creds.operation == "withdraw":
await bank.withdraw_credits(to, creds.sum)
await ctx.send(
_("{} removed {} {} from {}'s account.").format(
author.display_name, creds.sum, currency, to.display_name
elif creds.operation == "withdraw":
await bank.withdraw_credits(to, creds.sum)
msg = _("{author} removed {num} {currency} from {user}'s account.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
)
else:
await bank.set_balance(to, creds.sum)
msg = _("{author} set {user}'s account balance to {num} {currency}.").format(
author=author.display_name,
num=creds.sum,
currency=currency,
user=to.display_name,
)
except (ValueError, errors.BalanceTooHigh) as e:
await ctx.send(str(e))
else:
await bank.set_balance(to, creds.sum)
await ctx.send(
_("{} set {}'s account to {} {}.").format(
author.display_name, to.display_name, creds.sum, currency
)
)
await ctx.send(msg)
@_bank.command()
@check_global_setting_guildowner()
async def reset(self, ctx, confirmation: bool = False):
"""Deletes bank accounts"""
"""Delete all bank accounts."""
if confirmation is False:
await ctx.send(
_(
"This will delete all bank accounts for {}.\nIf you're sure, type "
"`{}bank reset yes`"
"This will delete all bank accounts for {scope}.\nIf you're sure, type "
"`{prefix}bank reset yes`"
).format(
self.bot.user.name if await bank.is_global() else "this server", ctx.prefix
scope=self.bot.user.name if await bank.is_global() else _("this server"),
prefix=ctx.prefix,
)
)
else:
await bank.wipe_bank()
await bank.wipe_bank(guild=ctx.guild)
await ctx.send(
_("All bank accounts for {} have been deleted.").format(
self.bot.user.name if await bank.is_global() else "this server"
_("All bank accounts for {scope} have been deleted.").format(
scope=self.bot.user.name if await bank.is_global() else _("this server")
)
)
@guild_only_check()
@commands.command()
async def payday(self, ctx: commands.Context):
"""Get some free currency"""
"""Get some free currency."""
author = ctx.author
guild = ctx.guild
@@ -243,31 +259,43 @@ class Economy:
if await bank.is_global(): # Role payouts will not be used
next_payday = await self.config.user(author).next_payday()
if cur_time >= next_payday:
await bank.deposit_credits(author, await self.config.PAYDAY_CREDITS())
try:
await bank.deposit_credits(author, await self.config.PAYDAY_CREDITS())
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await ctx.send(
_(
"You've reached the maximum amount of {currency}! (**{balance:,}**) "
"Please spend some more \N{GRIMACING FACE}\n\n"
"You currently have {new_balance} {currency}."
).format(currency=credits_name, new_balance=exc.max_balance)
)
return
next_payday = cur_time + await self.config.PAYDAY_TIME()
await self.config.user(author).next_payday.set(next_payday)
pos = await bank.get_leaderboard_position(author)
await ctx.send(
_(
"{0.mention} Here, take some {1}. Enjoy! (+{2} {1}!)\n\n"
"You currently have {3} {1}.\n\n"
"You are currently #{4} on the global leaderboard!"
"{author.mention} Here, take some {currency}. "
"Enjoy! (+{amount} {currency}!)\n\n"
"You currently have {new_balance} {currency}.\n\n"
"You are currently #{pos} on the global leaderboard!"
).format(
author,
credits_name,
str(await self.config.PAYDAY_CREDITS()),
str(await bank.get_balance(author)),
pos,
author=author,
currency=credits_name,
amount=await self.config.PAYDAY_CREDITS(),
new_balance=await bank.get_balance(author),
pos=pos,
)
)
else:
dtime = self.display_time(next_payday - cur_time)
await ctx.send(
_("{} Too soon. For your next payday you have to wait {}.").format(
author.mention, dtime
)
_(
"{author.mention} Too soon. For your next payday you have to wait {time}."
).format(author=author, time=dtime)
)
else:
next_payday = await self.config.member(author).next_payday()
@@ -279,37 +307,50 @@ class Economy:
).PAYDAY_CREDITS() # Nice variable name
if role_credits > credit_amount:
credit_amount = role_credits
await bank.deposit_credits(author, credit_amount)
try:
await bank.deposit_credits(author, credit_amount)
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await ctx.send(
_(
"You've reached the maximum amount of {currency}! "
"Please spend some more \N{GRIMACING FACE}\n\n"
"You currently have {new_balance} {currency}."
).format(currency=credits_name, new_balance=exc.max_balance)
)
return
next_payday = cur_time + await self.config.guild(guild).PAYDAY_TIME()
await self.config.member(author).next_payday.set(next_payday)
pos = await bank.get_leaderboard_position(author)
await ctx.send(
_(
"{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!"
"{author.mention} Here, take some {currency}. "
"Enjoy! (+{amount} {currency}!)\n\n"
"You currently have {new_balance} {currency}.\n\n"
"You are currently #{pos} on the global leaderboard!"
).format(
author,
credits_name,
credit_amount,
str(await bank.get_balance(author)),
pos,
author=author,
currency=credits_name,
amount=credit_amount,
new_balance=await bank.get_balance(author),
pos=pos,
)
)
else:
dtime = self.display_time(next_payday - cur_time)
await ctx.send(
_("{} Too soon. For your next payday you have to wait {}.").format(
author.mention, dtime
)
_(
"{author.mention} Too soon. For your next payday you have to wait {time}."
).format(author=author, time=dtime)
)
@commands.command()
@guild_only_check()
async def leaderboard(self, ctx: commands.Context, top: int = 10, show_global: bool = False):
"""Prints out the leaderboard
"""Print the leaderboard.
Defaults to top 10"""
Defaults to top 10.
"""
guild = ctx.guild
author = ctx.author
if top < 1:
@@ -319,9 +360,9 @@ class Economy:
): # show_global is only applicable if bank is global
guild = None
bank_sorted = await bank.get_leaderboard(positions=top, guild=guild)
if len(bank_sorted) < top:
top = len(bank_sorted)
header = f"{f'#':4}{f'Name':36}{f'Score':2}\n"
header = "{pound:4}{name:36}{score:2}\n".format(
pound="#", name=_("Name"), score=_("Score")
)
highscores = [
(
f"{f'{pos}.': <{3 if pos < 10 else 2}} {acc[1]['name']: <{35}s} "
@@ -346,13 +387,13 @@ class Economy:
@commands.command()
@guild_only_check()
async def payouts(self, ctx: commands.Context):
"""Shows slot machine payouts"""
"""Show the payouts for the slot machine."""
await ctx.author.send(SLOT_PAYOUTS_MSG)
@commands.command()
@guild_only_check()
async def slot(self, ctx: commands.Context, bid: int):
"""Play the slot machine"""
"""Use the slot machine."""
author = ctx.author
guild = ctx.guild
channel = ctx.channel
@@ -385,8 +426,9 @@ class Economy:
await self.config.member(author).last_slot.set(now)
await self.slot_machine(author, channel, bid)
async def slot_machine(self, author, channel, bid):
default_reel = deque(SMReel)
@staticmethod
async def slot_machine(author, channel, bid):
default_reel = deque(cast(Iterable, SMReel))
reels = []
for i in range(3):
default_reel.rotate(random.randint(-999, 999)) # weeeeee
@@ -423,61 +465,77 @@ class Economy:
then = await bank.get_balance(author)
pay = payout["payout"](bid)
now = then - bid + pay
await bank.set_balance(author, now)
await channel.send(
_("{}\n{} {}\n\nYour bid: {}\n{}{}!").format(
slot, author.mention, payout["phrase"], bid, then, now
try:
await bank.set_balance(author, now)
except errors.BalanceTooHigh as exc:
await bank.set_balance(author, exc.max_balance)
await channel.send(
_(
"You've reached the maximum amount of {currency}! "
"Please spend some more \N{GRIMACING FACE}\n{old_balance} -> {new_balance}!"
).format(
currency=await bank.get_currency_name(getattr(channel, "guild", None)),
old_balance=then,
new_balance=exc.max_balance,
)
)
)
return
phrase = T_(payout["phrase"])
else:
then = await bank.get_balance(author)
await bank.withdraw_credits(author, bid)
now = then - bid
await channel.send(
_("{}\n{} Nothing!\nYour bid: {}\n{}{}!").format(
slot, author.mention, bid, then, now
)
phrase = _("Nothing!")
await channel.send(
(
"{slot}\n{author.mention} {phrase}\n\n"
+ _("Your bid: {amount}")
+ "\n{old_balance}{new_balance}!"
).format(
slot=slot,
author=author,
phrase=phrase,
amount=bid,
old_balance=then,
new_balance=now,
)
)
@commands.group()
@guild_only_check()
@check_global_setting_admin()
async def economyset(self, ctx: commands.Context):
"""Changes economy module settings"""
"""Manage Economy settings."""
guild = ctx.guild
if ctx.invoked_subcommand is None:
if await bank.is_global():
slot_min = await self.config.SLOT_MIN()
slot_max = await self.config.SLOT_MAX()
slot_time = await self.config.SLOT_TIME()
payday_time = await self.config.PAYDAY_TIME()
payday_amount = await self.config.PAYDAY_CREDITS()
conf = self.config
else:
slot_min = await self.config.guild(guild).SLOT_MIN()
slot_max = await self.config.guild(guild).SLOT_MAX()
slot_time = await self.config.guild(guild).SLOT_TIME()
payday_time = await self.config.guild(guild).PAYDAY_TIME()
payday_amount = await self.config.guild(guild).PAYDAY_CREDITS()
register_amount = await bank.get_default_balance(guild)
msg = box(
_(
"Minimum slot bid: {}\n"
"Maximum slot bid: {}\n"
"Slot cooldown: {}\n"
"Payday amount: {}\n"
"Payday cooldown: {}\n"
"Amount given at account registration: {}"
""
).format(
slot_min, slot_max, slot_time, payday_amount, payday_time, register_amount
),
_("Current Economy settings:"),
conf = self.config.guild(ctx.guild)
await ctx.send(
box(
_(
"----Economy Settings---\n"
"Minimum slot bid: {slot_min}\n"
"Maximum slot bid: {slot_max}\n"
"Slot cooldown: {slot_time}\n"
"Payday amount: {payday_amount}\n"
"Payday cooldown: {payday_time}\n"
"Amount given at account registration: {register_amount}"
).format(
slot_min=await conf.SLOT_MIN(),
slot_max=await conf.SLOT_MAX(),
slot_time=await conf.SLOT_TIME(),
payday_time=await conf.PAYDAY_TIME(),
payday_amount=await conf.PAYDAY_CREDITS(),
register_amount=await bank.get_default_balance(guild),
)
)
)
await ctx.send(msg)
@economyset.command()
async def slotmin(self, ctx: commands.Context, bid: int):
"""Minimum slot machine bid"""
"""Set the minimum slot machine bid."""
if bid < 1:
await ctx.send(_("Invalid min bid amount."))
return
@@ -487,14 +545,18 @@ class Economy:
else:
await self.config.guild(guild).SLOT_MIN.set(bid)
credits_name = await bank.get_currency_name(guild)
await ctx.send(_("Minimum bid is now {} {}.").format(bid, credits_name))
await ctx.send(
_("Minimum bid is now {bid} {currency}.").format(bid=bid, currency=credits_name)
)
@economyset.command()
async def slotmax(self, ctx: commands.Context, bid: int):
"""Maximum slot machine bid"""
"""Set the maximum slot machine bid."""
slot_min = await self.config.SLOT_MIN()
if bid < 1 or bid < slot_min:
await ctx.send(_("Invalid slotmax bid amount. Must be greater than slotmin."))
await ctx.send(
_("Invalid maximum bid amount. Must be greater than the minimum amount.")
)
return
guild = ctx.guild
credits_name = await bank.get_currency_name(guild)
@@ -502,73 +564,88 @@ class Economy:
await self.config.SLOT_MAX.set(bid)
else:
await self.config.guild(guild).SLOT_MAX.set(bid)
await ctx.send(_("Maximum bid is now {} {}.").format(bid, credits_name))
await ctx.send(
_("Maximum bid is now {bid} {currency}.").format(bid=bid, currency=credits_name)
)
@economyset.command()
async def slottime(self, ctx: commands.Context, seconds: int):
"""Seconds between each slots use"""
"""Set the cooldown for the slot machine."""
guild = ctx.guild
if await bank.is_global():
await self.config.SLOT_TIME.set(seconds)
else:
await self.config.guild(guild).SLOT_TIME.set(seconds)
await ctx.send(_("Cooldown is now {} seconds.").format(seconds))
await ctx.send(_("Cooldown is now {num} seconds.").format(num=seconds))
@economyset.command()
async def paydaytime(self, ctx: commands.Context, seconds: int):
"""Seconds between each payday"""
"""Set the cooldown for payday."""
guild = ctx.guild
if await bank.is_global():
await self.config.PAYDAY_TIME.set(seconds)
else:
await self.config.guild(guild).PAYDAY_TIME.set(seconds)
await ctx.send(
_("Value modified. At least {} seconds must pass between each payday.").format(seconds)
_("Value modified. At least {num} seconds must pass between each payday.").format(
num=seconds
)
)
@economyset.command()
async def paydayamount(self, ctx: commands.Context, creds: int):
"""Amount earned each payday"""
"""Set the amount earned each payday."""
guild = ctx.guild
credits_name = await bank.get_currency_name(guild)
if creds <= 0:
if creds <= 0 or creds > bank.MAX_BALANCE:
await ctx.send(_("Har har so funny."))
return
credits_name = await bank.get_currency_name(guild)
if await bank.is_global():
await self.config.PAYDAY_CREDITS.set(creds)
else:
await self.config.guild(guild).PAYDAY_CREDITS.set(creds)
await ctx.send(_("Every payday will now give {} {}.").format(creds, credits_name))
await ctx.send(
_("Every payday will now give {num} {currency}.").format(
num=creds, currency=credits_name
)
)
@economyset.command()
async def rolepaydayamount(self, ctx: commands.Context, role: discord.Role, creds: int):
"""Amount earned each payday for a role"""
"""Set the amount earned each payday for a role."""
guild = ctx.guild
if creds <= 0 or creds > bank.MAX_BALANCE:
await ctx.send(_("Har har so funny."))
return
credits_name = await bank.get_currency_name(guild)
if await bank.is_global():
await ctx.send("The bank must be per-server for per-role paydays to work.")
await ctx.send(_("The bank must be per-server for per-role paydays to work."))
else:
await self.config.role(role).PAYDAY_CREDITS.set(creds)
await ctx.send(
_("Every payday will now give {} {} to people with the role {}.").format(
creds, credits_name, role.name
)
_(
"Every payday will now give {num} {currency} "
"to people with the role {role_name}."
).format(num=creds, currency=credits_name, role_name=role.name)
)
@economyset.command()
async def registeramount(self, ctx: commands.Context, creds: int):
"""Amount given on registering an account"""
"""Set the initial balance for new bank accounts."""
guild = ctx.guild
if creds < 0:
creds = 0
credits_name = await bank.get_currency_name(guild)
await bank.set_default_balance(creds, guild)
await ctx.send(
_("Registering an account will now give {} {}.").format(creds, credits_name)
_("Registering an account will now give {num} {currency}.").format(
num=creds, currency=credits_name
)
)
# What would I ever do without stackoverflow?
def display_time(self, seconds, granularity=2):
@staticmethod
def display_time(seconds, granularity=2):
intervals = ( # Source: http://stackoverflow.com/a/24542445
(_("weeks"), 604800), # 60 * 60 * 24 * 7
(_("days"), 86400), # 60 * 60 * 24

View File

@@ -1,19 +1,20 @@
import discord
from typing import Union
from redbot.core import checks, Config, modlog, commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import pagify
from redbot.core.utils.mod import is_mod_or_superior
_ = Translator("Filter", __file__)
@cog_i18n(_)
class Filter:
"""Filter-related commands"""
class Filter(commands.Cog):
"""Filter unwanted words and phrases from text channels."""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.settings = Config.get_conf(self, 4766951341)
default_guild_settings = {
@@ -24,14 +25,17 @@ class Filter:
"filter_default_name": "John Doe",
}
default_member_settings = {"filter_count": 0, "next_reset_time": 0}
default_channel_settings = {"filter": []}
self.settings.register_guild(**default_guild_settings)
self.settings.register_member(**default_member_settings)
self.settings.register_channel(**default_channel_settings)
self.register_task = self.bot.loop.create_task(self.register_filterban())
def __unload(self):
self.register_task.cancel()
async def register_filterban(self):
@staticmethod
async def register_filterban():
try:
await modlog.register_casetype(
"filterban", False, ":filing_cabinet: :hammer:", "Filter ban", "ban"
@@ -39,119 +43,34 @@ class Filter:
except RuntimeError:
pass
@commands.group(name="filter")
@commands.group()
@commands.guild_only()
@checks.mod_or_permissions(manage_messages=True)
async def _filter(self, ctx: commands.Context):
"""Adds/removes words from filter
@checks.admin_or_permissions(manage_guild=True)
async def filterset(self, ctx: commands.Context):
"""Manage filter settings."""
pass
Use double quotes to add/remove sentences
Using this command with no subcommands will send
the list of the server's filtered words."""
if ctx.invoked_subcommand is None:
server = ctx.guild
author = ctx.author
word_list = await self.settings.guild(server).filter()
if word_list:
words = ", ".join(word_list)
words = _("Filtered in this server:") + "\n\n" + words
try:
for page in pagify(words, delims=[" ", "\n"], shorten_by=8):
await author.send(page)
except discord.Forbidden:
await ctx.send(_("I can't send direct messages to you."))
@_filter.command(name="add")
async def filter_add(self, ctx: commands.Context, *, words: str):
"""Adds words to the filter
Use double quotes to add sentences
Examples:
filter add word1 word2 word3
filter add \"This is a sentence\""""
server = ctx.guild
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
added = await self.add_to_filter(server, word_list)
if added:
await ctx.send(_("Words added to filter."))
else:
await ctx.send(_("Words already in the filter."))
@_filter.command(name="remove")
async def filter_remove(self, ctx: commands.Context, *, words: str):
"""Remove words from the filter
Use double quotes to remove sentences
Examples:
filter remove word1 word2 word3
filter remove \"This is a sentence\""""
server = ctx.guild
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
removed = await self.remove_from_filter(server, word_list)
if removed:
await ctx.send(_("Words removed from filter."))
else:
await ctx.send(_("Those words weren't in the filter."))
@_filter.command(name="names")
async def filter_names(self, ctx: commands.Context):
"""Toggles whether or not to check names and nicknames against the filter
This is disabled by default
"""
guild = ctx.guild
current_setting = await self.settings.guild(guild).filter_names()
await self.settings.guild(guild).filter_names.set(not current_setting)
if current_setting:
await ctx.send(_("Names and nicknames will no longer be checked against the filter."))
else:
await ctx.send(_("Names and nicknames will now be checked against the filter."))
@_filter.command(name="defaultname")
@filterset.command(name="defaultname")
async def filter_default_name(self, ctx: commands.Context, name: str):
"""Sets the default name to use if filtering names is enabled
"""Set the nickname for users with a filtered name.
Note that this has no effect if filtering names is disabled
(to toggle, run `[p]filter names`).
The default name used is John Doe
The default name used is *John Doe*.
"""
guild = ctx.guild
await self.settings.guild(guild).filter_default_name.set(name)
await ctx.send(_("The name to use on filtered names has been set."))
@_filter.command(name="ban")
@filterset.command(name="ban")
async def filter_ban(self, ctx: commands.Context, count: int, timeframe: int):
"""Autobans if the specified number of messages are filtered in the timeframe
"""Set the filter's autoban conditions.
The timeframe is represented by seconds.
Users will be banned if they send `<count>` filtered words in
`<timeframe>` seconds.
Set both to zero to disable autoban.
"""
if (count <= 0) != (timeframe <= 0):
await ctx.send(
@@ -170,30 +89,241 @@ class Filter:
await self.settings.guild(ctx.guild).filterban_time.set(timeframe)
await ctx.send(_("Count and time have been set."))
async def add_to_filter(self, server: discord.Guild, words: list) -> bool:
@commands.group(name="filter")
@commands.guild_only()
@checks.mod_or_permissions(manage_messages=True)
async def _filter(self, ctx: commands.Context):
"""Add or remove words from server filter.
Use double quotes to add or remove sentences.
Using this command with no subcommands will send the list of
the server's filtered words.
"""
if ctx.invoked_subcommand is None:
server = ctx.guild
author = ctx.author
word_list = await self.settings.guild(server).filter()
if word_list:
words = ", ".join(word_list)
words = _("Filtered in this server:") + "\n\n" + words
try:
for page in pagify(words, delims=[" ", "\n"], shorten_by=8):
await author.send(page)
except discord.Forbidden:
await ctx.send(_("I can't send direct messages to you."))
@_filter.group(name="channel")
async def _filter_channel(self, ctx: commands.Context):
"""Add or remove words from channel filter.
Use double quotes to add or remove sentences.
Using this command with no subcommands will send the list of
the channel's filtered words.
"""
if ctx.invoked_subcommand is None:
channel = ctx.channel
author = ctx.author
word_list = await self.settings.channel(channel).filter()
if word_list:
words = ", ".join(word_list)
words = _("Filtered in this channel:") + "\n\n" + words
try:
for page in pagify(words, delims=[" ", "\n"], shorten_by=8):
await author.send(page)
except discord.Forbidden:
await ctx.send(_("I can't send direct messages to you."))
@_filter_channel.command("add")
async def filter_channel_add(self, ctx: commands.Context, *, words: str):
"""Add words to the filter.
Use double quotes to add sentences.
Examples:
- `[p]filter channel add word1 word2 word3`
- `[p]filter channel add "This is a sentence"`
"""
channel = ctx.channel
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
added = await self.add_to_filter(channel, word_list)
if added:
await ctx.send(_("Words added to filter."))
else:
await ctx.send(_("Words already in the filter."))
@_filter_channel.command("remove")
async def filter_channel_remove(self, ctx: commands.Context, *, words: str):
"""Remove words from the filter.
Use double quotes to remove sentences.
Examples:
- `[p]filter channel remove word1 word2 word3`
- `[p]filter channel remove "This is a sentence"`
"""
channel = ctx.channel
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
removed = await self.remove_from_filter(channel, word_list)
if removed:
await ctx.send(_("Words removed from filter."))
else:
await ctx.send(_("Those words weren't in the filter."))
@_filter.command(name="add")
async def filter_add(self, ctx: commands.Context, *, words: str):
"""Add words to the filter.
Use double quotes to add sentences.
Examples:
- `[p]filter add word1 word2 word3`
- `[p]filter add "This is a sentence"`
"""
server = ctx.guild
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
added = await self.add_to_filter(server, word_list)
if added:
await ctx.send(_("Words successfully added to filter."))
else:
await ctx.send(_("Those words were already in the filter."))
@_filter.command(name="remove")
async def filter_remove(self, ctx: commands.Context, *, words: str):
"""Remove words from the filter.
Use double quotes to remove sentences.
Examples:
- `[p]filter remove word1 word2 word3`
- `[p]filter remove "This is a sentence"`
"""
server = ctx.guild
split_words = words.split()
word_list = []
tmp = ""
for word in split_words:
if not word.startswith('"') and not word.endswith('"') and not tmp:
word_list.append(word)
else:
if word.startswith('"'):
tmp += word[1:] + " "
elif word.endswith('"'):
tmp += word[:-1]
word_list.append(tmp)
tmp = ""
else:
tmp += word + " "
removed = await self.remove_from_filter(server, word_list)
if removed:
await ctx.send(_("Words successfully removed from filter."))
else:
await ctx.send(_("Those words weren't in the filter."))
@_filter.command(name="names")
async def filter_names(self, ctx: commands.Context):
"""Toggle name and nickname filtering.
This is disabled by default.
"""
guild = ctx.guild
current_setting = await self.settings.guild(guild).filter_names()
await self.settings.guild(guild).filter_names.set(not current_setting)
if current_setting:
await ctx.send(_("Names and nicknames will no longer be filtered."))
else:
await ctx.send(_("Names and nicknames will now be filtered."))
async def add_to_filter(
self, server_or_channel: Union[discord.Guild, discord.TextChannel], words: list
) -> bool:
added = False
async with self.settings.guild(server).filter() as cur_list:
for w in words:
if w.lower() not in cur_list and w:
cur_list.append(w.lower())
added = True
if isinstance(server_or_channel, discord.Guild):
async with self.settings.guild(server_or_channel).filter() as cur_list:
for w in words:
if w.lower() not in cur_list and w:
cur_list.append(w.lower())
added = True
elif isinstance(server_or_channel, discord.TextChannel):
async with self.settings.channel(server_or_channel).filter() as cur_list:
for w in words:
if w.lower not in cur_list and w:
cur_list.append(w.lower())
added = True
return added
async def remove_from_filter(self, server: discord.Guild, words: list) -> bool:
async def remove_from_filter(
self, server_or_channel: Union[discord.Guild, discord.TextChannel], words: list
) -> bool:
removed = False
async with self.settings.guild(server).filter() as cur_list:
for w in words:
if w.lower() in cur_list:
cur_list.remove(w.lower())
removed = True
if isinstance(server_or_channel, discord.Guild):
async with self.settings.guild(server_or_channel).filter() as cur_list:
for w in words:
if w.lower() in cur_list:
cur_list.remove(w.lower())
removed = True
elif isinstance(server_or_channel, discord.TextChannel):
async with self.settings.channel(server_or_channel).filter() as cur_list:
for w in words:
if w.lower() in cur_list:
cur_list.remove(w.lower())
removed = True
return removed
async def check_filter(self, message: discord.Message):
server = message.guild
author = message.author
word_list = await self.settings.guild(server).filter()
word_list = set(
await self.settings.guild(server).filter()
+ await self.settings.channel(message.channel).filter()
)
filter_count = await self.settings.guild(server).filterban_count()
filter_time = await self.settings.guild(server).filterban_time()
user_count = await self.settings.member(author).filter_count()
@@ -211,7 +341,7 @@ class Filter:
if w in message.content.lower():
try:
await message.delete()
except:
except discord.HTTPException:
pass
else:
if filter_count > 0 and filter_time > 0:
@@ -221,10 +351,10 @@ class Filter:
user_count >= filter_count
and message.created_at.timestamp() < next_reset_time
):
reason = "Autoban (too many filtered messages.)"
reason = _("Autoban (too many filtered messages.)")
try:
await server.ban(author, reason=reason)
except:
except discord.HTTPException:
pass
else:
await modlog.create_case(
@@ -245,74 +375,40 @@ class Filter:
if not valid_user:
return
# Bots and mods or superior are ignored from the filter
mod_or_superior = await is_mod_or_superior(self.bot, obj=author)
if mod_or_superior:
if await self.bot.is_automod_immune(message):
return
await self.check_filter(message)
async def on_message_edit(self, _, message):
author = message.author
if message.guild is None or self.bot.user == author:
return
valid_user = isinstance(author, discord.Member) and not author.bot
if not valid_user:
return
# Bots and mods or superior are ignored from the filter
mod_or_superior = await is_mod_or_superior(self.bot, obj=author)
if mod_or_superior:
return
await self.check_filter(message)
async def on_message_edit(self, _prior, message):
# message content has to change for non-bot's currently.
# if this changes, we should compare before passing it.
await self.on_message(message)
async def on_member_update(self, before: discord.Member, after: discord.Member):
if not after.guild.me.guild_permissions.manage_nicknames:
return # No permissions to manage nicknames, so can't do anything
word_list = await self.settings.guild(after.guild).filter()
filter_names = await self.settings.guild(after.guild).filter_names()
name_to_use = await self.settings.guild(after.guild).filter_default_name()
if not filter_names:
return
name_filtered = False
nick_filtered = False
for w in word_list:
if w in after.name:
name_filtered = True
if after.nick and w in after.nick: # since Member.nick can be None
nick_filtered = True
if name_filtered and nick_filtered: # Both true, so break from loop
break
if name_filtered and after.nick is None:
try:
await after.edit(nick=name_to_use, reason="Filtered name")
except:
pass
elif nick_filtered:
try:
await after.edit(nick=None, reason="Filtered nickname")
except:
pass
if before.display_name != after.display_name:
await self.maybe_filter_name(after)
async def on_member_join(self, member: discord.Member):
guild = member.guild
if not guild.me.guild_permissions.manage_nicknames:
return
word_list = await self.settings.guild(guild).filter()
filter_names = await self.settings.guild(guild).filter_names()
name_to_use = await self.settings.guild(guild).filter_default_name()
await self.maybe_filter_name(member)
if not filter_names:
async def maybe_filter_name(self, member: discord.Member):
if not member.guild.me.guild_permissions.manage_nicknames:
return # No permissions to manage nicknames, so can't do anything
if member.top_role >= member.guild.me.top_role:
return # Discord Hierarchy applies to nicks
if await self.bot.is_automod_immune(member):
return
if not await self.settings.guild(member.guild).filter_names():
return
word_list = await self.settings.guild(member.guild).filter()
for w in word_list:
if w in member.name:
if w in member.display_name.lower():
name_to_use = await self.settings.guild(member.guild).filter_default_name()
reason = _("Filtered nickname") if member.nick else _("Filtered name")
try:
await member.edit(nick=name_to_use, reason="Filtered name")
except:
await member.edit(nick=name_to_use, reason=reason)
except discord.HTTPException:
pass
break
return

View File

@@ -2,15 +2,14 @@ import datetime
import time
from enum import Enum
from random import randint, choice
from urllib.parse import quote_plus
import aiohttp
import discord
from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.utils.chat_formatting import escape, italics, pagify
from redbot.core.utils.chat_formatting import escape, italics
_ = Translator("General", __file__)
_ = T_ = Translator("General", __file__)
class RPS(Enum):
@@ -29,70 +28,78 @@ class RPSParser:
elif argument == "scissors":
self.choice = RPS.scissors
else:
raise
self.choice = None
@cog_i18n(_)
class General:
class General(commands.Cog):
"""General commands."""
global _
_ = lambda s: s
ball = [
_("As I see it, yes"),
_("It is certain"),
_("It is decidedly so"),
_("Most likely"),
_("Outlook good"),
_("Signs point to yes"),
_("Without a doubt"),
_("Yes"),
_("Yes definitely"),
_("You may rely on it"),
_("Reply hazy, try again"),
_("Ask again later"),
_("Better not tell you now"),
_("Cannot predict now"),
_("Concentrate and ask again"),
_("Don't count on it"),
_("My reply is no"),
_("My sources say no"),
_("Outlook not so good"),
_("Very doubtful"),
]
_ = T_
def __init__(self):
super().__init__()
self.stopwatches = {}
self.ball = [
_("As I see it, yes"),
_("It is certain"),
_("It is decidedly so"),
_("Most likely"),
_("Outlook good"),
_("Signs point to yes"),
_("Without a doubt"),
_("Yes"),
_("Yes definitely"),
_("You may rely on it"),
_("Reply hazy, try again"),
_("Ask again later"),
_("Better not tell you now"),
_("Cannot predict now"),
_("Concentrate and ask again"),
_("Don't count on it"),
_("My reply is no"),
_("My sources say no"),
_("Outlook not so good"),
_("Very doubtful"),
]
@commands.command()
async def choose(self, ctx, *choices):
"""Chooses between multiple choices.
"""Choose between multiple options.
To denote multiple choices, you should use double quotes.
To denote options which include whitespace, you should use
double quotes.
"""
choices = [escape(c, mass_mentions=True) for c in choices]
if len(choices) < 2:
await ctx.send(_("Not enough choices to pick from."))
await ctx.send(_("Not enough options to pick from."))
else:
await ctx.send(choice(choices))
@commands.command()
async def roll(self, ctx, number: int = 100):
"""Rolls random number (between 1 and user choice)
"""Roll a random number.
Defaults to 100.
The result will be between 1 and `<number>`.
`<number>` defaults to 100.
"""
author = ctx.author
if number > 1:
n = randint(1, number)
await ctx.send(_("{} :game_die: {} :game_die:").format(author.mention, n))
await ctx.send("{author.mention} :game_die: {n} :game_die:".format(author=author, n=n))
else:
await ctx.send(_("{} Maybe higher than 1? ;P").format(author.mention))
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author))
@commands.command()
async def flip(self, ctx, user: discord.Member = None):
"""Flips a coin... or a user.
"""Flip a coin... or a user.
Defaults to coin.
Defaults to a coin.
"""
if user != None:
if user is not None:
msg = ""
if user.id == ctx.bot.user.id:
user = ctx.author
@@ -111,9 +118,11 @@ class General:
@commands.command()
async def rps(self, ctx, your_choice: RPSParser):
"""Play rock paper scissors"""
"""Play Rock Paper Scissors."""
author = ctx.author
player_choice = your_choice.choice
if not player_choice:
return await ctx.send("This isn't a valid option. Try rock, paper, or scissors.")
red_choice = choice((RPS.rock, RPS.paper, RPS.scissors))
cond = {
(RPS.rock, RPS.paper): False,
@@ -130,39 +139,53 @@ class General:
outcome = cond[(player_choice, red_choice)]
if outcome is True:
await ctx.send(_("{} You win {}!").format(red_choice.value, author.mention))
await ctx.send(
_("{choice} You win {author.mention}!").format(
choice=red_choice.value, author=author
)
)
elif outcome is False:
await ctx.send(_("{} You lose {}!").format(red_choice.value, author.mention))
await ctx.send(
_("{choice} You lose {author.mention}!").format(
choice=red_choice.value, author=author
)
)
else:
await ctx.send(_("{} We're square {}!").format(red_choice.value, author.mention))
await ctx.send(
_("{choice} We're square {author.mention}!").format(
choice=red_choice.value, author=author
)
)
@commands.command(name="8", aliases=["8ball"])
async def _8ball(self, ctx, *, question: str):
"""Ask 8 ball a question
"""Ask 8 ball a question.
Question must end with a question mark.
"""
if question.endswith("?") and question != "?":
await ctx.send("`" + choice(self.ball) + "`")
await ctx.send("`" + T_(choice(self.ball)) + "`")
else:
await ctx.send(_("That doesn't look like a question."))
@commands.command(aliases=["sw"])
async def stopwatch(self, ctx):
"""Starts/stops stopwatch"""
"""Start or stop the stopwatch."""
author = ctx.author
if not author.id in self.stopwatches:
if author.id not in self.stopwatches:
self.stopwatches[author.id] = int(time.perf_counter())
await ctx.send(author.mention + _(" Stopwatch started!"))
else:
tmp = abs(self.stopwatches[author.id] - int(time.perf_counter()))
tmp = str(datetime.timedelta(seconds=tmp))
await ctx.send(author.mention + _(" Stopwatch stopped! Time: **") + tmp + "**")
await ctx.send(
author.mention + _(" Stopwatch stopped! Time: **{seconds}**").format(seconds=tmp)
)
self.stopwatches.pop(author.id, None)
@commands.command()
async def lmgtfy(self, ctx, *, search_terms: str):
"""Creates a lmgtfy link"""
"""Create a lmgtfy link."""
search_terms = escape(
search_terms.replace("+", "%2B").replace(" ", "+"), mass_mentions=True
)
@@ -171,9 +194,10 @@ class General:
@commands.command(hidden=True)
@commands.guild_only()
async def hug(self, ctx, user: discord.Member, intensity: int = 1):
"""Because everyone likes hugs
"""Because everyone likes hugs!
Up to 10 intensity levels."""
Up to 10 intensity levels.
"""
name = italics(user.display_name)
if intensity <= 0:
msg = "(っ˘̩╭╮˘̩)っ" + name
@@ -185,27 +209,30 @@ class General:
msg = "(つ≧▽≦)つ" + name
elif intensity >= 10:
msg = "(づ ̄ ³ ̄)づ{} ⊂(´・ω・`⊂)".format(name)
else:
# For the purposes of "msg might not be defined" linter errors
raise RuntimeError
await ctx.send(msg)
@commands.command()
@commands.guild_only()
async def serverinfo(self, ctx):
"""Shows server's informations"""
"""Show server information."""
guild = ctx.guild
online = len([m.status for m in guild.members if m.status != discord.Status.offline])
total_users = len(guild.members)
text_channels = len(guild.text_channels)
voice_channels = len(guild.voice_channels)
passed = (ctx.message.created_at - guild.created_at).days
created_at = _("Since {}. That's over {} days ago!").format(
guild.created_at.strftime("%d %b %Y %H:%M"), passed
created_at = _("Since {date}. That's over {num} days ago!").format(
date=guild.created_at.strftime("%d %b %Y %H:%M"), num=passed
)
data = discord.Embed(description=created_at, colour=(await ctx.embed_colour()))
data.add_field(name=_("Region"), value=str(guild.region))
data.add_field(name=_("Users"), value="{}/{}".format(online, total_users))
data.add_field(name=_("Text Channels"), value=text_channels)
data.add_field(name=_("Voice Channels"), value=voice_channels)
data.add_field(name=_("Roles"), value=len(guild.roles))
data.add_field(name=_("Users"), value=f"{online}/{total_users}")
data.add_field(name=_("Text Channels"), value=str(text_channels))
data.add_field(name=_("Voice Channels"), value=str(voice_channels))
data.add_field(name=_("Roles"), value=str(len(guild.roles)))
data.add_field(name=_("Owner"), value=str(guild.owner))
data.set_footer(text=_("Server ID: ") + str(guild.id))
@@ -217,12 +244,15 @@ class General:
try:
await ctx.send(embed=data)
except discord.HTTPException:
except discord.Forbidden:
await ctx.send(_("I need the `Embed links` permission to send this."))
@commands.command()
async def urban(self, ctx, *, word):
"""Searches urban dictionary entries using the unofficial api"""
"""Search the Urban Dictionary.
This uses the unofficial Urban Dictionary API.
"""
try:
url = "https://api.urbandictionary.com/v0/define?term=" + str(word).lower()
@@ -233,32 +263,34 @@ class General:
async with session.get(url, headers=headers) as response:
data = await response.json()
except:
except aiohttp.ClientError:
await ctx.send(
_("No Urban dictionary entries were found or there was an error in the process")
_("No Urban Dictionary entries were found, or there was an error in the process.")
)
return
if data.get("error") != 404:
if not data["list"]:
return await ctx.send(_("No Urban Dictionary entries were found."))
if await ctx.embed_requested():
# a list of embeds
embeds = []
for ud in data["list"]:
embed = discord.Embed()
embed.title = _("{} by {}").format(ud["word"].capitalize(), ud["author"])
embed.title = _("{word} by {author}").format(
word=ud["word"].capitalize(), author=ud["author"]
)
embed.url = ud["permalink"]
description = "{} \n \n **Example : ** {}".format(
ud["definition"], ud.get("example", "N/A")
)
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > 2048:
description = "{}...".format(description[:2045])
embed.description = description
embed.set_footer(
text=_("{} Down / {} Up , Powered by urban dictionary").format(
ud["thumbs_down"], ud["thumbs_up"]
)
text=_(
"{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary."
).format(**ud)
)
embeds.append(embed)
@@ -274,24 +306,15 @@ class General:
else:
messages = []
for ud in data["list"]:
description = _("{} \n \n **Example : ** {}").format(
ud["definition"], ud.get("example", "N/A")
)
ud.setdefault("example", "N/A")
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > 2048:
description = "{}...".format(description[:2045])
description = description
message = _(
"<{}> \n {} by {} \n \n {} \n \n {} Down / {} Up, Powered by urban "
"dictionary"
).format(
ud["permalink"],
ud["word"].capitalize(),
ud["author"],
description,
ud["thumbs_down"],
ud["thumbs_up"],
)
"<{permalink}>\n {word} by {author}\n\n{description}\n\n"
"{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary."
).format(word=ud.pop("word").capitalize(), description=description, **ud)
messages.append(message)
if messages is not None and len(messages) > 0:
@@ -305,6 +328,5 @@ class General:
)
else:
await ctx.send(
_("No Urban dictionary entries were found or there was an error in the process")
_("No Urban Dictionary entries were found, or there was an error in the process.")
)
return

View File

@@ -11,12 +11,13 @@ GIPHY_API_KEY = "dc6zaTOxFJmzC"
@cog_i18n(_)
class Image:
class Image(commands.Cog):
"""Image related commands."""
default_global = {"imgur_client_id": None}
def __init__(self, bot):
super().__init__()
self.bot = bot
self.settings = Config.get_conf(self, identifier=2652104208, force_registration=True)
self.settings.register_global(**self.default_global)
@@ -28,23 +29,26 @@ class Image:
@commands.group(name="imgur")
async def _imgur(self, ctx):
"""Retrieves pictures from imgur
"""Retrieve pictures from Imgur.
Make sure to set the client ID using
[p]imgurcreds"""
Make sure to set the Client ID using `[p]imgurcreds`.
"""
pass
@_imgur.command(name="search")
async def imgur_search(self, ctx, *, term: str):
"""Searches Imgur for the specified term and returns up to 3 results"""
"""Search Imgur for the specified term.
Returns up to 3 results.
"""
url = self.imgur_base_url + "gallery/search/time/all/0"
params = {"q": term}
imgur_client_id = await self.settings.imgur_client_id()
if not imgur_client_id:
await ctx.send(
_("A client ID has not been set! Please set one with {}.").format(
"`{}imgurcreds`".format(ctx.prefix)
)
_(
"A Client ID has not been set! Please set one with `{prefix}imgurcreds`."
).format(prefix=ctx.prefix)
)
return
headers = {"Authorization": "Client-ID {}".format(imgur_client_id)}
@@ -63,37 +67,41 @@ class Image:
msg += "\n"
await ctx.send(msg)
else:
await ctx.send(_("Something went wrong. Error code is {}.").format(data["status"]))
await ctx.send(
_("Something went wrong. Error code is {code}.").format(code=data["status"])
)
@_imgur.command(name="subreddit")
async def imgur_subreddit(
self, ctx, subreddit: str, sort_type: str = "top", window: str = "day"
):
"""Gets images from the specified subreddit section
"""Get images from a subreddit.
Sort types: new, top
Time windows: day, week, month, year, all"""
You can customize the search with the following options:
- `<sort_type>`: new, top
- `<window>`: day, week, month, year, all
"""
sort_type = sort_type.lower()
window = window.lower()
if sort_type not in ("new", "top"):
await ctx.send(_("Only 'new' and 'top' are a valid sort type."))
return
elif window not in ("day", "week", "month", "year", "all"):
await ctx.send_help()
return
if sort_type == "new":
sort = "time"
elif sort_type == "top":
sort = "top"
else:
await ctx.send(_("Only 'new' and 'top' are a valid sort type."))
return
if window not in ("day", "week", "month", "year", "all"):
await ctx.send_help()
return
imgur_client_id = await self.settings.imgur_client_id()
if not imgur_client_id:
await ctx.send(
_("A client ID has not been set! Please set one with {}.").format(
"`{}imgurcreds`".format(ctx.prefix)
)
_(
"A Client ID has not been set! Please set one with `{prefix}imgurcreds`."
).format(prefix=ctx.prefix)
)
return
@@ -116,28 +124,33 @@ class Image:
else:
await ctx.send(_("No results found."))
else:
await ctx.send(_("Something went wrong. Error code is {}.").format(data["status"]))
await ctx.send(
_("Something went wrong. Error code is {code}.").format(code=data["status"])
)
@checks.is_owner()
@commands.command()
async def imgurcreds(self, ctx, imgur_client_id: str):
"""Sets the imgur client id
"""Set the Imgur Client ID.
You will need an account on Imgur to get this
You can get these by visiting https://api.imgur.com/oauth2/addclient
and filling out the form. Enter a name for the application, select
'Anonymous usage without user authorization' for the auth type,
set the authorization callback url to 'https://localhost'
leave the app website blank, enter a valid email address, and
enter a description. Check the box for the captcha, then click Next.
Your client ID will be on the page that loads."""
To get an Imgur Client ID:
1. Login to an Imgur account.
2. Visit [this](https://api.imgur.com/oauth2/addclient) page
3. Enter a name for your application
4. Select *Anonymous usage without user authorization* for the auth type
5. Set the authorization callback URL to `https://localhost`
6. Leave the app website blank
7. Enter a valid email address and a description
8. Check the captcha box and click next
9. Your Client ID will be on the next page.
"""
await self.settings.imgur_client_id.set(imgur_client_id)
await ctx.send(_("Set the imgur client id!"))
await ctx.send(_("The Imgur Client ID has been set!"))
@commands.command(pass_context=True, no_pm=True)
@commands.guild_only()
@commands.command()
async def gif(self, ctx, *keywords):
"""Retrieves first search result from giphy"""
"""Retrieve the first search result from Giphy."""
if keywords:
keywords = "+".join(keywords)
else:
@@ -156,11 +169,12 @@ class Image:
else:
await ctx.send(_("No results found."))
else:
await ctx.send(_("Error contacting the API."))
await ctx.send(_("Error contacting the Giphy API."))
@commands.command(pass_context=True, no_pm=True)
@commands.guild_only()
@commands.command()
async def gifr(self, ctx, *keywords):
"""Retrieves a random gif from a giphy search"""
"""Retrieve a random GIF from a Giphy search."""
if keywords:
keywords = "+".join(keywords)
else:

View File

@@ -1,67 +0,0 @@
from redbot.core import commands
import discord
def mod_or_voice_permissions(**perms):
async def pred(ctx: commands.Context):
author = ctx.author
guild = ctx.guild
if await ctx.bot.is_owner(author) or guild.owner == author:
# Author is bot owner or guild owner
return True
admin_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).admin_role())
mod_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).mod_role())
if admin_role in author.roles or mod_role in author.roles:
return True
for vc in guild.voice_channels:
resolved = vc.permissions_for(author)
good = resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)
if not good:
return False
else:
return True
return commands.check(pred)
def admin_or_voice_permissions(**perms):
async def pred(ctx: commands.Context):
author = ctx.author
guild = ctx.guild
if await ctx.bot.is_owner(author) or guild.owner == author:
return True
admin_role = discord.utils.get(guild.roles, id=await ctx.bot.db.guild(guild).admin_role())
if admin_role in author.roles:
return True
for vc in guild.voice_channels:
resolved = vc.permissions_for(author)
good = resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)
if not good:
return False
else:
return True
return commands.check(pred)
def bot_has_voice_permissions(**perms):
async def pred(ctx: commands.Context):
guild = ctx.guild
for vc in guild.voice_channels:
resolved = vc.permissions_for(guild.me)
good = resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)
if not good:
return False
else:
return True
return commands.check(pred)

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
from typing import Optional
import discord
from redbot.core import checks, modlog, commands
@@ -9,32 +11,38 @@ _ = Translator("ModLog", __file__)
@cog_i18n(_)
class ModLog:
"""Log for mod actions"""
class ModLog(commands.Cog):
"""Manage log channels for moderation actions."""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
@commands.group()
@checks.guildowner_or_permissions(administrator=True)
async def modlogset(self, ctx: commands.Context):
"""Settings for the mod log"""
"""Manage modlog settings."""
pass
@modlogset.command()
@commands.guild_only()
async def modlog(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""Sets a channel as mod log
"""Set a channel as the modlog.
Leaving the channel parameter empty will deactivate it"""
Omit `<channel>` to disable the modlog.
"""
guild = ctx.guild
if channel:
if channel.permissions_for(guild.me).send_messages:
await modlog.set_modlog_channel(guild, channel)
await ctx.send(_("Mod events will be sent to {}").format(channel.mention))
await ctx.send(
_("Mod events will be sent to {channel}").format(channel=channel.mention)
)
else:
await ctx.send(
_("I do not have permissions to send messages in {}!").format(channel.mention)
_("I do not have permissions to send messages in {channel}!").format(
channel=channel.mention
)
)
else:
try:
@@ -48,39 +56,36 @@ class ModLog:
@modlogset.command(name="cases")
@commands.guild_only()
async def set_cases(self, ctx: commands.Context, action: str = None):
"""Enables or disables case creation for each type of mod action"""
"""Enable or disable case creation for a mod action."""
guild = ctx.guild
if action is None: # No args given
casetypes = await modlog.get_all_casetypes(guild)
await ctx.send_help()
title = _("Current settings:")
msg = ""
lines = []
for ct in casetypes:
enabled = await ct.is_enabled()
value = "enabled" if enabled else "disabled"
msg += "%s : %s\n" % (ct.name, value)
enabled = "enabled" if await ct.is_enabled() else "disabled"
lines.append(f"{ct.name} : {enabled}")
msg = title + "\n" + box(msg)
await ctx.send(msg)
await ctx.send(_("Current settings:\n") + box("\n".join(lines)))
return
casetype = await modlog.get_casetype(action, guild)
if not casetype:
await ctx.send(_("That action is not registered"))
else:
enabled = await casetype.is_enabled()
await casetype.set_enabled(True if not enabled else False)
msg = _("Case creation for {} actions is now {}.").format(
action, "enabled" if not enabled else "disabled"
await casetype.set_enabled(not enabled)
await ctx.send(
_("Case creation for {action_name} actions is now {enabled}.").format(
action_name=action, enabled="enabled" if not enabled else "disabled"
)
)
await ctx.send(msg)
@modlogset.command()
@commands.guild_only()
async def resetcases(self, ctx: commands.Context):
"""Resets modlog's cases"""
"""Reset all modlog cases in this server."""
guild = ctx.guild
await modlog.reset_cases(guild)
await ctx.send(_("Cases have been reset."))
@@ -88,7 +93,7 @@ class ModLog:
@commands.command()
@commands.guild_only()
async def case(self, ctx: commands.Context, number: int):
"""Shows the specified case"""
"""Show the specified case."""
try:
case = await modlog.get_case(number, ctx.guild, self.bot)
except RuntimeError:
@@ -100,24 +105,21 @@ class ModLog:
else:
await ctx.send(await case.message_content(embed=False))
@commands.command(usage="[case] <reason>")
@commands.command()
@commands.guild_only()
async def reason(self, ctx: commands.Context, *, reason: str):
"""Lets you specify a reason for mod-log's cases
async def reason(self, ctx: commands.Context, case: Optional[int], *, reason: str):
"""Specify a reason for a modlog case.
Please note that you can only edit cases you are
the owner of unless you are a mod/admin or the server owner.
If no number is specified, the latest case will be used."""
the owner of unless you are a mod, admin or server owner.
If no case number is specified, the latest case will be used.
"""
author = ctx.author
guild = ctx.guild
potential_case = reason.split()[0]
if potential_case.isdigit():
case = int(potential_case)
reason = reason.replace(potential_case, "")
else:
case = str(int(await modlog.get_next_case_number(guild)) - 1)
# latest case
if case is None:
# get the latest case
case = int(await modlog.get_next_case_number(guild)) - 1
try:
case_before = await modlog.get_case(case, guild, self.bot)
except RuntimeError:

View File

@@ -1,5 +1,13 @@
from .permissions import Permissions
def setup(bot):
bot.add_cog(Permissions(bot))
async def setup(bot):
cog = Permissions(bot)
await cog.initialize()
# It's important that these listeners are added prior to load, so
# the permissions commands themselves have rules added.
# Automatic listeners being added in add_cog happen in arbitrary
# order, so we want to circumvent that.
bot.add_listener(cog.cog_added, "on_cog_add")
bot.add_listener(cog.command_added, "on_command_add")
bot.add_cog(cog)

View File

@@ -1,44 +1,178 @@
import itertools
import re
from typing import NamedTuple, Union, Optional
import discord
from redbot.core import commands
from typing import Tuple
from redbot.core.i18n import Translator
_ = Translator("PermissionsConverters", __file__)
MENTION_RE = re.compile(r"^<?(?:(?:@[!&]?)?|#)(\d{15,21})>?$")
class CogOrCommand(commands.Converter):
async def convert(self, ctx: commands.Context, arg: str) -> Tuple[str]:
ret = ctx.bot.get_cog(arg)
if ret:
return "cogs", ret.__class__.__name__
ret = ctx.bot.get_command(arg)
if ret:
return "commands", ret.qualified_name
def _match_id(arg: str) -> Optional[int]:
m = MENTION_RE.match(arg)
if m:
return int(m.group(1))
class GlobalUniqueObjectFinder(commands.Converter):
async def convert(
self, ctx: commands.Context, arg: str
) -> Union[discord.Guild, discord.abc.GuildChannel, discord.abc.User, discord.Role]:
bot: commands.Bot = ctx.bot
_id = _match_id(arg)
if _id is not None:
guild: discord.Guild = bot.get_guild(_id)
if guild is not None:
return guild
channel: discord.abc.GuildChannel = bot.get_channel(_id)
if channel is not None:
return channel
user: discord.User = bot.get_user(_id)
if user is not None:
return user
for guild in bot.guilds:
role: discord.Role = guild.get_role(_id)
if role is not None:
return role
objects = itertools.chain(
bot.get_all_channels(),
bot.users,
bot.guilds,
*(filter(lambda r: not r.is_default(), guild.roles) for guild in bot.guilds),
)
maybe_matches = []
for obj in objects:
if obj.name == arg or str(obj) == arg:
maybe_matches.append(obj)
if ctx.guild is not None:
for member in ctx.guild.members:
if member.nick == arg and not any(obj.id == member.id for obj in maybe_matches):
maybe_matches.append(member)
if not maybe_matches:
raise commands.BadArgument(
_(
'"{arg}" was not found. It must be the ID, mention, or name of a server, '
"channel, user or role which the bot can see."
).format(arg=arg)
)
elif len(maybe_matches) == 1:
return maybe_matches[0]
else:
raise commands.BadArgument(
_(
'"{arg}" does not refer to a unique server, channel, user or role. Please use '
"the ID for whatever/whoever you're trying to specify, or mention it/them."
).format(arg=arg)
)
class GuildUniqueObjectFinder(commands.Converter):
async def convert(
self, ctx: commands.Context, arg: str
) -> Union[discord.abc.GuildChannel, discord.Member, discord.Role]:
guild: discord.Guild = ctx.guild
_id = _match_id(arg)
if _id is not None:
channel: discord.abc.GuildChannel = guild.get_channel(_id)
if channel is not None:
return channel
member: discord.Member = guild.get_member(_id)
if member is not None:
return member
role: discord.Role = guild.get_role(_id)
if role is not None and not role.is_default():
return role
objects = itertools.chain(
guild.channels, guild.members, filter(lambda r: not r.is_default(), guild.roles)
)
maybe_matches = []
for obj in objects:
if obj.name == arg or str(obj) == arg:
maybe_matches.append(obj)
try:
if obj.nick == arg:
maybe_matches.append(obj)
except AttributeError:
pass
if not maybe_matches:
raise commands.BadArgument(
_(
'"{arg}" was not found. It must be the ID, mention, or name of a channel, '
"user or role in this server."
).format(arg=arg)
)
elif len(maybe_matches) == 1:
return maybe_matches[0]
else:
raise commands.BadArgument(
_(
'"{arg}" does not refer to a unique channel, user or role. Please use the ID '
"for whatever/whoever you're trying to specify, or mention it/them."
).format(arg=arg)
)
class CogOrCommand(NamedTuple):
type: str
name: str
obj: Union[commands.Command, commands.Cog]
# noinspection PyArgumentList
@classmethod
async def convert(cls, ctx: commands.Context, arg: str) -> "CogOrCommand":
cog = ctx.bot.get_cog(arg)
if cog:
return cls(type="COG", name=cog.__class__.__name__, obj=cog)
cmd = ctx.bot.get_command(arg)
if cmd:
return cls(type="COMMAND", name=cmd.qualified_name, obj=cmd)
raise commands.BadArgument(
'Cog or command "{arg}" not found. Please note that this is case sensitive.'
"".format(arg=arg)
_(
'Cog or command "{name}" not found. Please note that this is case sensitive.'
).format(name=arg)
)
class RuleType(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"
def RuleType(arg: str) -> bool:
if arg.lower() in ("allow", "whitelist", "allowed"):
return True
if arg.lower() in ("deny", "blacklist", "denied"):
return False
raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny"'.format(arg=arg)
)
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"
def ClearableRuleType(arg: str) -> Optional[bool]:
if arg.lower() in ("allow", "whitelist", "allowed"):
return True
if arg.lower() in ("deny", "blacklist", "denied"):
return False
if arg.lower() in ("clear", "reset"):
return None
raise commands.BadArgument(
'"{arg}" is not a valid rule. Valid rules are "allow" or "deny", or "clear" to remove the rule'
"".format(arg=arg)
)
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

@@ -1,102 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
import types
import contextlib
import asyncio
import logging
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
"""
val = None
# let's not spam the console with improperly made 3rd party checks
try:
if asyncio.iscoroutinefunction(check):
val = await check(ctx, level=level)
else:
val = check(ctx, level=level)
except Exception as e:
# but still provide a way to view it (run with debug flag)
log.debug(str(e))
return val
def resolve_models(*, ctx: commands.Context, models: dict) -> bool:
"""
Resolves models in order.
"""
cmd_name = ctx.command.qualified_name
cog_name = ctx.cog.__class__.__name__
resolved = None
to_iter = (("commands", cmd_name), ("cogs", cog_name))
for model_name, ctx_attr in to_iter:
if ctx_attr in models.get(model_name, {}):
blacklist = models[model_name][ctx_attr].get("deny", [])
whitelist = models[model_name][ctx_attr].get("allow", [])
resolved = resolve_lists(ctx=ctx, whitelist=whitelist, blacklist=blacklist)
if resolved is not None:
return resolved
resolved = models[model_name][ctx_attr].get("default", None)
if resolved is not None:
return resolved
return None
def resolve_lists(*, ctx: commands.Context, whitelist: list, blacklist: list) -> bool:
"""
resolves specific lists
"""
for entry in entries_from_ctx(ctx):
if entry in whitelist:
return True
if entry in blacklist:
return False
return None

View File

@@ -1,19 +0,0 @@
cogs:
Admin:
allow:
- 78631113035100160
deny:
- 96733288462286848
Audio:
allow:
- 133049272517001216
default: deny
commands:
cleanup bot:
allow:
- 78631113035100160
default: deny
ping:
deny:
- 96733288462286848
default: allow

View File

@@ -1,67 +0,0 @@
import io
import yaml
import pathlib
import discord
def yaml_template() -> dict:
template_fp = pathlib.Path(__file__).parent / "template.yaml"
with template_fp.open() as f:
return yaml.safe_load(f)
async def yamlset_acl(ctx, *, config, update):
_fp = io.BytesIO()
await ctx.message.attachments[0].save(_fp)
try:
data = yaml.safe_load(_fp)
except yaml.YAMLError:
_fp.close()
del _fp
raise
old_data = await config()
for outer, inner in data.items():
for ok, iv in inner.items():
for k, v in iv.items():
if k == "default":
data[outer][ok][k] = {"allow": True, "deny": False}.get(v.lower(), None)
if not update:
continue
try:
if isinstance(old_data[outer][ok][k], list):
data[outer][ok][k].extend(old_data[outer][ok][k])
except KeyError:
pass
await config.set(data)
async def yamlget_acl(ctx, *, config):
data = await config()
removals = []
for outer, inner in data.items():
for ok, iv in inner.items():
for k, v in iv.items():
if k != "default":
continue
if v is True:
data[outer][ok][k] = "allow"
elif v is False:
data[outer][ok][k] = "deny"
else:
removals.append((outer, ok, k))
for tup in removals:
o, i, k = tup
data[o][i].pop(k, None)
_fp = io.BytesIO(yaml.dump(data, default_flow_style=False).encode())
_fp.seek(0)
await ctx.author.send(file=discord.File(_fp, filename="acl.yaml"))
_fp.close()

View File

@@ -1,6 +1,6 @@
import logging
import asyncio
from typing import Union
from typing import Union, List
from datetime import timedelta
from copy import copy
import contextlib
@@ -11,6 +11,7 @@ from redbot.core.utils.chat_formatting import pagify, box
from redbot.core.utils.antispam import AntiSpam
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.predicates import MessagePredicate
from redbot.core.utils.tunnel import Tunnel
@@ -20,7 +21,7 @@ log = logging.getLogger("red.reports")
@cog_i18n(_)
class Reports:
class Reports(commands.Cog):
default_guild_settings = {"output_channel": None, "active": False, "next_ticket": 1}
@@ -40,6 +41,7 @@ class Reports:
]
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, 78631113035100160, force_registration=True)
self.config.register_guild(**self.default_guild_settings)
@@ -58,23 +60,20 @@ class Reports:
@commands.guild_only()
@commands.group(name="reportset")
async def reportset(self, ctx: commands.Context):
"""
Settings for the report system.
"""
"""Manage Reports."""
pass
@checks.admin_or_permissions(manage_guild=True)
@reportset.command(name="output")
async def setoutput(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel where reports will show up"""
async def reportset_output(self, ctx: commands.Context, channel: discord.TextChannel):
"""Set the channel where reports will be sent."""
await self.config.guild(ctx.guild).output_channel.set(channel.id)
await ctx.send(_("The report channel has been set."))
@checks.admin_or_permissions(manage_guild=True)
@reportset.command(name="toggle", aliases=["toggleactive"])
async def report_toggle(self, ctx: commands.Context):
"""Enables or Disables reporting for the server"""
async def reportset_toggle(self, ctx: commands.Context):
"""Enable or Disable reporting for this server."""
active = await self.config.guild(ctx.guild).active()
active = not active
await self.config.guild(ctx.guild).active.set(active)
@@ -87,10 +86,8 @@ class Reports:
ret = False
if mod:
guild = m.guild
admin_role = discord.utils.get(
guild.roles, id=await self.bot.db.guild(guild).admin_role()
)
mod_role = discord.utils.get(guild.roles, id=await self.bot.db.guild(guild).mod_role())
admin_role = guild.get_role(await self.bot.db.guild(guild).admin_role())
mod_role = guild.get_role(await self.bot.db.guild(guild).mod_role())
ret |= any(r in m.roles for r in (mod_role, admin_role))
if perms:
ret |= m.guild_permissions >= perms
@@ -137,13 +134,14 @@ class Reports:
output += "\n{}".format(prompt)
for page in pagify(output, delims=["\n"]):
dm = await author.send(box(page))
def pred(m):
return m.author == author and m.channel == dm.channel
await author.send(box(page))
try:
message = await self.bot.wait_for("message", check=pred, timeout=45)
message = await self.bot.wait_for(
"message",
check=MessagePredicate.same_context(channel=author.dm_channel, user=author),
timeout=45,
)
except asyncio.TimeoutError:
await author.send(_("You took too long to select. Try again later."))
return None
@@ -167,7 +165,7 @@ class Reports:
if channel is None:
return None
files = await Tunnel.files_from_attatch(msg)
files: List[discord.File] = await Tunnel.files_from_attatch(msg)
ticket_number = await self.config.guild(guild).next_ticket()
await self.config.guild(guild).next_ticket.set(ticket_number + 1)
@@ -203,11 +201,10 @@ class Reports:
@commands.group(name="report", invoke_without_command=True)
async def report(self, ctx: commands.Context, *, _report: str = ""):
"""
Send a report.
"""Send a report.
Use without arguments for interactive reporting, or do
[p]report <text> to use it non-interactively.
`[p]report <text>` to use it non-interactively.
"""
author = ctx.author
guild = ctx.guild
@@ -248,7 +245,7 @@ class Reports:
val = await self.send_report(_m, guild)
else:
try:
dm = await author.send(
await author.send(
_(
"Please respond to this message with your Report."
"\nYour report should be a single message"
@@ -257,11 +254,12 @@ class Reports:
except discord.Forbidden:
return await ctx.send(_("This requires DMs enabled."))
def pred(m):
return m.author == author and m.channel == dm.channel
try:
message = await self.bot.wait_for("message", check=pred, timeout=180)
message = await self.bot.wait_for(
"message",
check=MessagePredicate.same_context(ctx, channel=author.dm_channel),
timeout=180,
)
except asyncio.TimeoutError:
return await author.send(_("You took too long. Try again later."))
else:
@@ -318,12 +316,11 @@ class Reports:
self.tunnel_store[k]["msgs"] = msgs
@commands.guild_only()
@checks.mod_or_permissions(manage_members=True)
@checks.mod_or_permissions(manage_roles=True)
@report.command(name="interact")
async def response(self, ctx, ticket_number: int):
"""
Open a message tunnel.
"""Open a message tunnel.
This tunnel will forward things you say in this channel
to the ticket opener's direct messages.
@@ -352,8 +349,7 @@ class Reports:
)
big_topic = _(
"{who} opened a 2-way communication "
"about ticket number {ticketnum}. Anything you say or upload here "
" 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 "
@@ -362,8 +358,12 @@ class Reports:
"\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)
topic = (
_(
"A moderator in the server `{guild.name}` has opened a 2-way communication about "
"ticket number {ticket_number}."
).format(guild=guild, ticket_number=ticket_number)
+ big_topic
)
try:
m = await tun.communicate(message=ctx.message, topic=topic, skip_message_content=True)
@@ -371,4 +371,9 @@ class Reports:
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))
await ctx.send(
_(
"You have opened a 2-way communication about ticket number {ticket_number}."
).format(ticket_number=ticket_number)
+ big_topic
)

View File

@@ -1,3 +1,5 @@
import contextlib
import discord
from redbot.core import Config, checks, commands
from redbot.core.utils.chat_formatting import pagify
@@ -22,11 +24,11 @@ from .errors import (
StreamsError,
InvalidTwitchCredentials,
)
from . import streamtypes as StreamClasses
from . import streamtypes as _streamtypes
from collections import defaultdict
import asyncio
import re
from typing import Optional, List
from typing import Optional, List, Tuple
CHECK_DELAY = 60
@@ -35,7 +37,7 @@ _ = Translator("Streams", __file__)
@cog_i18n(_)
class Streams:
class Streams(commands.Cog):
global_defaults = {"tokens": {}, "streams": [], "communities": []}
@@ -44,6 +46,7 @@ class Streams:
role_defaults = {"mention": False}
def __init__(self, bot: Red):
super().__init__()
self.db = Config.get_conf(self, 26262626)
self.db.register_global(**self.global_defaults)
@@ -75,14 +78,14 @@ class Streams:
@commands.command()
async def twitch(self, ctx: commands.Context, channel_name: str):
"""Checks if a Twitch channel is live"""
"""Check 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 live"""
"""Check 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,23 +96,24 @@ class Streams:
@commands.command()
async def hitbox(self, ctx: commands.Context, channel_name: str):
"""Checks if a Hitbox channel is live"""
"""Check 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 live"""
"""Check 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 live"""
"""Check if a Picarto channel is live."""
stream = PicartoStream(name=channel_name)
await self.check_online(ctx, stream)
async def check_online(self, ctx: commands.Context, stream):
@staticmethod
async def check_online(ctx: commands.Context, stream):
try:
embed = await stream.is_online()
except OfflineStream:
@@ -118,15 +122,17 @@ class Streams:
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(
"{}streamset twitchtoken".format(ctx.prefix)
)
_(
"The Twitch token is either invalid or has not been set. See "
"`{prefix}streamset twitchtoken`."
).format(prefix=ctx.prefix)
)
except InvalidYoutubeCredentials:
await ctx.send(
_("Your Youtube API key is either invalid or has not been set. See {}.").format(
"`{}streamset youtubekey`".format(ctx.prefix)
)
_(
"The YouTube API key is either invalid or has not been set. See "
"`{prefix}streamset youtubekey`."
).format(prefix=ctx.prefix)
)
except APIError:
await ctx.send(
@@ -139,11 +145,12 @@ class Streams:
@commands.guild_only()
@checks.mod()
async def streamalert(self, ctx: commands.Context):
"""Manage automated stream alerts."""
pass
@streamalert.group(name="twitch", invoke_without_command=True)
async def _twitch(self, ctx: commands.Context, channel_name: str = None):
"""Twitch stream alerts"""
"""Manage Twitch stream notifications."""
if channel_name is not None:
await ctx.invoke(self.twitch_alert_channel, channel_name)
else:
@@ -151,7 +158,7 @@ class Streams:
@_twitch.command(name="channel")
async def twitch_alert_channel(self, ctx: commands.Context, channel_name: str):
"""Sets a Twitch alert notification in the channel"""
"""Toggle alerts in this channel for a Twitch stream."""
if re.fullmatch(r"<#\d+>", channel_name):
await ctx.send("Please supply the name of a *Twitch* channel, not a Discord channel.")
return
@@ -159,33 +166,39 @@ class Streams:
@_twitch.command(name="community")
async def twitch_alert_community(self, ctx: commands.Context, community: str):
"""Sets an alert notification in the channel for the specified twitch community."""
"""Toggle alerts in this channel for a 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 alert notification in the channel"""
"""Toggle alerts in this channel for a YouTube stream."""
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 alert notification in the channel"""
"""Toggle alerts in this channel for a Hitbox stream."""
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 alert notification in the channel"""
"""Toggle alerts in this channel for a Mixer stream."""
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 alert notification in the channel"""
"""Toggle alerts in this channel for a Picarto stream."""
await self.stream_alert(ctx, PicartoStream, channel_name)
@streamalert.command(name="stop")
@streamalert.command(name="stop", usage="[disable_all=No]")
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"""
"""Disable all stream alerts in this channel or server.
`[p]streamalert stop` will disable this channel's stream
alerts.
Do `[p]streamalert stop yes` to disable all stream alerts in
this server.
"""
streams = self.streams.copy()
local_channel_ids = [c.id for c in ctx.guild.channels]
to_remove = []
@@ -207,9 +220,10 @@ class Streams:
self.streams = streams
await self.save_streams()
msg = _("All the alerts in the {} have been disabled.").format(
"server" if _all else "channel"
)
if _all:
msg = _("All the stream alerts in this server have been disabled.")
else:
msg = _("All the stream alerts in this channel have been disabled.")
await ctx.send(msg)
@@ -249,16 +263,18 @@ class Streams:
exists = await self.check_exists(stream)
except InvalidTwitchCredentials:
await ctx.send(
_("Your twitch token is either invalid or has not been set. See {}.").format(
"`{}streamset twitchtoken`".format(ctx.prefix)
)
_(
"The Twitch token is either invalid or has not been set. See "
"`{prefix}streamset twitchtoken`."
).format(prefix=ctx.prefix)
)
return
except InvalidYoutubeCredentials:
await ctx.send(
_(
"Your Youtube API key is either invalid or has not been set. See {}."
).format("`{}streamset youtubekey`".format(ctx.prefix))
"The YouTube API key is either invalid or has not been set. See "
"`{prefix}streamset youtubekey`."
).format(prefix=ctx.prefix)
)
return
except APIError:
@@ -282,9 +298,10 @@ class Streams:
await community.get_community_streams()
except InvalidTwitchCredentials:
await ctx.send(
_("The twitch token is either invalid or has not been set. See {}.").format(
"`{}streamset twitchtoken`".format(ctx.prefix)
)
_(
"The Twitch token is either invalid or has not been set. See "
"`{prefix}streamset twitchtoken`."
).format(prefix=ctx.prefix)
)
return
except CommunityNotFound:
@@ -303,19 +320,21 @@ class Streams:
@commands.group()
@checks.mod()
async def streamset(self, ctx: commands.Context):
"""Set tokens for accessing streams."""
pass
@streamset.command()
@checks.is_owner()
async def twitchtoken(self, ctx: commands.Context, token: str):
"""Set the Client ID for twitch.
"""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*
3. Enter a name, set the OAuth Redirect URI to `http://localhost`, and
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!
1. Go to this page: https://dev.twitch.tv/dashboard/apps.
2. Click *Register Your Application*
3. Enter a name, set the OAuth Redirect URI to `http://localhost`, and
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)
@@ -324,92 +343,90 @@ class Streams:
@streamset.command()
@checks.is_owner()
async def youtubekey(self, ctx: commands.Context, key: str):
"""Sets the API key for Youtube.
"""Set 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)
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."))
await ctx.send(_("YouTube key set."))
@streamset.group()
@commands.guild_only()
async def mention(self, ctx: commands.Context):
"""Sets mentions for alerts."""
"""Manage mention settings for stream alerts."""
pass
@mention.command(aliases=["everyone"])
@commands.guild_only()
async def all(self, ctx: commands.Context):
"""Toggles everyone mention"""
"""Toggle the `@\u200beveryone` mention."""
guild = ctx.guild
current_setting = await self.db.guild(guild).mention_everyone()
if current_setting:
await self.db.guild(guild).mention_everyone.set(False)
await ctx.send(
_("{} will no longer be mentioned when a stream or community is live").format(
"@\u200beveryone"
)
)
await ctx.send(_("`@\u200beveryone` will no longer be mentioned for stream alerts."))
else:
await self.db.guild(guild).mention_everyone.set(True)
await ctx.send(
_("When a stream or community " "is live, {} will be mentioned.").format(
"@\u200beveryone"
)
_("When a stream or community is live, `@\u200beveryone` will be mentioned.")
)
@mention.command(aliases=["here"])
@commands.guild_only()
async def online(self, ctx: commands.Context):
"""Toggles here mention"""
"""Toggle the `@\u200bhere` mention."""
guild = ctx.guild
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 an alert.").format("@\u200bhere"))
await ctx.send(_("`@\u200bhere` will no longer be mentioned for stream alerts."))
else:
await self.db.guild(guild).mention_here.set(True)
await ctx.send(
_("When a stream or community " "is live, {} will be mentioned.").format(
"@\u200bhere"
)
_("When a stream or community is live, `@\u200bhere` will be mentioned.")
)
@mention.command()
@commands.guild_only()
async def role(self, ctx: commands.Context, *, role: discord.Role):
"""Toggles role mention"""
"""Toggle a role mention."""
current_setting = await self.db.role(role).mention()
if not role.mentionable:
await ctx.send("That role is not mentionable!")
return
if current_setting:
await self.db.role(role).mention.set(False)
await ctx.send(
_("{} will no longer be mentioned for an alert.").format(
"@\u200b{}".format(role.name)
_("`@\u200b{role.name}` will no longer be mentioned for stream alerts.").format(
role=role
)
)
else:
await self.db.role(role).mention.set(True)
await ctx.send(
_("When a stream or community " "is live, {} will be mentioned." "").format(
"@\u200b{}".format(role.name)
msg = _(
"When a stream or community is live, `@\u200b{role.name}` will be mentioned."
).format(role=role)
if not role.mentionable:
msg += " " + _(
"Since the role is not mentionable, it will be momentarily made mentionable "
"when announcing a streamalert. Please make sure I have the correct "
"permissions to manage this role, or else members of this role won't receive "
"a notification."
)
)
await ctx.send(msg)
@streamset.command()
@commands.guild_only()
async def autodelete(self, ctx: commands.Context, on_off: bool):
"""Toggles automatic deletion of notifications for streams that go offline"""
"""Toggle alert deletion for when streams go offline."""
await self.db.guild(ctx.guild).autodelete.set(on_off)
if on_off:
await ctx.send("The notifications will be deleted once streams go offline.")
await ctx.send(_("The notifications will be deleted once streams go offline."))
else:
await ctx.send("Notifications will never be deleted.")
await ctx.send(_("Notifications will no longer be deleted."))
async def add_or_remove(self, ctx: commands.Context, stream):
if ctx.channel.id not in stream.channels:
@@ -417,18 +434,18 @@ class Streams:
if stream not in self.streams:
self.streams.append(stream)
await ctx.send(
_("I'll now send a notification in this channel when {} is live.").format(
stream.name
)
_(
"I'll now send a notification in this channel when {stream.name} is live."
).format(stream=stream)
)
else:
stream.channels.remove(ctx.channel.id)
if not stream.channels:
self.streams.remove(stream)
await ctx.send(
_("I won't send notifications about {} in this channel anymore.").format(
stream.name
)
_(
"I won't send notifications about {stream.name} in this channel anymore."
).format(stream=stream)
)
await self.save_streams()
@@ -441,9 +458,8 @@ class Streams:
await ctx.send(
_(
"I'll send a notification in this channel when a "
"channel is live in the {} community."
""
).format(community.name)
"channel is live in the {community.name} community."
).format(community=community)
)
else:
community.channels.remove(ctx.channel.id)
@@ -452,9 +468,8 @@ class Streams:
await ctx.send(
_(
"I won't send notifications about channels streaming "
"in the {} community in this channel anymore."
""
).format(community.name)
"in the {community.name} community in this channel anymore."
).format(community=community)
)
await self.save_communities()
@@ -480,7 +495,8 @@ class Streams:
if community.type == _class.__name__ and community.name.lower() == name.lower():
return community
async def check_exists(self, stream):
@staticmethod
async def check_exists(stream):
try:
await stream.is_online()
except OfflineStream:
@@ -505,94 +521,110 @@ class Streams:
async def check_streams(self):
for stream in self.streams:
try:
embed = await stream.is_online()
except OfflineStream:
if not stream._messages_cache:
continue
for message in stream._messages_cache:
try:
autodelete = await self.db.guild(message.guild).autodelete()
if autodelete:
await message.delete()
except:
pass
stream._messages_cache.clear()
await self.save_streams()
except:
pass
else:
if stream._messages_cache:
continue
for channel_id in stream.channels:
channel = self.bot.get_channel(channel_id)
mention_str = await self._get_mention_str(channel.guild)
with contextlib.suppress(Exception):
try:
embed = await stream.is_online()
except OfflineStream:
if not stream._messages_cache:
continue
for message in stream._messages_cache:
with contextlib.suppress(Exception):
autodelete = await self.db.guild(message.guild).autodelete()
if autodelete:
await message.delete()
stream._messages_cache.clear()
await self.save_streams()
else:
if stream._messages_cache:
continue
for channel_id in stream.channels:
channel = self.bot.get_channel(channel_id)
mention_str, edited_roles = await self._get_mention_str(channel.guild)
if mention_str:
content = "{}, {} is live!".format(mention_str, stream.name)
else:
content = "{} is live!".format(stream.name)
if mention_str:
content = _("{mention}, {stream.name} is live!").format(
mention=mention_str, stream=stream
)
else:
content = _("{stream.name} is live!").format(stream=stream)
try:
m = await channel.send(content, embed=embed)
stream._messages_cache.append(m)
if edited_roles:
for role in edited_roles:
await role.edit(mentionable=False)
await self.save_streams()
except:
pass
async def _get_mention_str(self, guild: discord.Guild):
async def _get_mention_str(self, guild: discord.Guild) -> Tuple[str, List[discord.Role]]:
"""Returns a 2-tuple with the string containing the mentions, and a list of
all roles which need to have their `mentionable` property set back to False.
"""
settings = self.db.guild(guild)
mentions = []
edited_roles = []
if await settings.mention_everyone():
mentions.append("@everyone")
if await settings.mention_here():
mentions.append("@here")
can_manage_roles = guild.me.guild_permissions.manage_roles
for role in guild.roles:
if await self.db.role(role).mention():
if can_manage_roles and not role.mentionable:
try:
await role.edit(mentionable=True)
except discord.Forbidden:
# Might still be unable to edit role based on hierarchy
pass
else:
edited_roles.append(role)
mentions.append(role.mention)
return " ".join(mentions)
return " ".join(mentions), edited_roles
async def check_communities(self):
for community in self.communities:
try:
stream_list = await community.get_community_streams()
except CommunityNotFound:
print(_("The Community {} was not found!").format(community.name))
continue
except OfflineCommunity:
if not community._messages_cache:
with contextlib.suppress(Exception):
try:
stream_list = await community.get_community_streams()
except CommunityNotFound:
print(
_("The Community {community.name} was not found!").format(
community=community
)
)
continue
for message in community._messages_cache:
try:
autodelete = await self.db.guild(message.guild).autodelete()
if autodelete:
await message.delete()
except:
pass
community._messages_cache.clear()
await self.save_communities()
except:
pass
else:
for channel in community.channels:
chn = self.bot.get_channel(channel)
streams = await self.filter_streams(stream_list, chn)
emb = await community.make_embed(streams)
chn_msg = [m for m in community._messages_cache if m.channel == chn]
if not chn_msg:
mentions = await self._get_mention_str(chn.guild)
if mentions:
msg = await chn.send(mentions, embed=emb)
except OfflineCommunity:
if not community._messages_cache:
continue
for message in community._messages_cache:
with contextlib.suppress(Exception):
autodelete = await self.db.guild(message.guild).autodelete()
if autodelete:
await message.delete()
community._messages_cache.clear()
await self.save_communities()
else:
for channel in community.channels:
chn = self.bot.get_channel(channel)
streams = await self.filter_streams(stream_list, chn)
emb = await community.make_embed(streams)
chn_msg = [m for m in community._messages_cache if m.channel == chn]
if not chn_msg:
mentions, roles = await self._get_mention_str(chn.guild)
if mentions:
msg = await chn.send(mentions, embed=emb)
else:
msg = await chn.send(embed=emb)
community._messages_cache.append(msg)
if roles:
for role in roles:
await role.edit(mentionable=False)
await self.save_communities()
else:
msg = await chn.send(embed=emb)
community._messages_cache.append(msg)
await self.save_communities()
else:
chn_msg = sorted(chn_msg, key=lambda x: x.created_at, reverse=True)[0]
community._messages_cache.remove(chn_msg)
await chn_msg.edit(embed=emb)
community._messages_cache.append(chn_msg)
await self.save_communities()
chn_msg = sorted(chn_msg, key=lambda x: x.created_at, reverse=True)[0]
community._messages_cache.remove(chn_msg)
await chn_msg.edit(embed=emb)
community._messages_cache.append(chn_msg)
await self.save_communities()
async def filter_streams(self, streams: list, channel: discord.TextChannel) -> list:
filtered = []
@@ -610,15 +642,20 @@ class Streams:
streams = []
for raw_stream in await self.db.streams():
_class = getattr(StreamClasses, raw_stream["type"], None)
_class = getattr(_streamtypes, raw_stream["type"], None)
if not _class:
continue
raw_msg_cache = raw_stream["messages"]
raw_stream["_messages_cache"] = []
for raw_msg in raw_msg_cache:
chn = self.bot.get_channel(raw_msg["channel"])
msg = await chn.get_message(raw_msg["message"])
raw_stream["_messages_cache"].append(msg)
if chn is not None:
try:
msg = await chn.get_message(raw_msg["message"])
except discord.HTTPException:
pass
else:
raw_stream["_messages_cache"].append(msg)
token = await self.db.tokens.get_raw(_class.__name__, default=None)
if token is not None:
raw_stream["token"] = token
@@ -630,15 +667,20 @@ class Streams:
communities = []
for raw_community in await self.db.communities():
_class = getattr(StreamClasses, raw_community["type"], None)
_class = getattr(_streamtypes, raw_community["type"], None)
if not _class:
continue
raw_msg_cache = raw_community["messages"]
raw_community["_messages_cache"] = []
for raw_msg in raw_msg_cache:
chn = self.bot.get_channel(raw_msg["channel"])
msg = await chn.get_message(raw_msg["message"])
raw_community["_messages_cache"].append(msg)
if chn is not None:
try:
msg = await chn.get_message(raw_msg["message"])
except discord.HTTPException:
pass
else:
raw_community["_messages_cache"].append(msg)
token = await self.db.tokens.get_raw(_class.__name__, default=None)
communities.append(_class(token=token, **raw_community))
@@ -666,3 +708,5 @@ class Streams:
def __unload(self):
if self.task:
self.task.cancel()
__del__ = __unload

View File

@@ -4,19 +4,30 @@ import time
import random
from collections import Counter
import discord
from redbot.core.bank import deposit_credits
from redbot.core.utils.chat_formatting import box
from redbot.core import bank
from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import box, bold, humanize_list
from redbot.core.utils.common_filters import normalize_smartquotes
from .log import LOG
__all__ = ["TriviaSession"]
_REVEAL_MESSAGES = ("I know this one! {}!", "Easy: {}.", "Oh really? It's {} of course.")
_FAIL_MESSAGES = (
"To the next one I guess...",
"Moving on...",
"I'm sure you'll know the answer of the next one.",
"\N{PENSIVE FACE} Next one.",
T_ = Translator("TriviaSession", __file__)
_ = lambda s: s
_REVEAL_MESSAGES = (
_("I know this one! {answer}!"),
_("Easy: {answer}."),
_("Oh really? It's {answer} of course."),
)
_FAIL_MESSAGES = (
_("To the next one I guess..."),
_("Moving on..."),
_("I'm sure you'll know the answer of the next one."),
_("\N{PENSIVE FACE} Next one."),
)
_ = T_
class TriviaSession:
@@ -103,7 +114,7 @@ class TriviaSession:
async with self.ctx.typing():
await asyncio.sleep(3)
self.count += 1
msg = "**Question number {}!**\n\n{}".format(self.count, question)
msg = bold(_("Question number {num}!").format(num=self.count)) + "\n\n" + question
await self.ctx.send(msg)
continue_ = await self.wait_for_answer(answers, delay, timeout)
if continue_ is False:
@@ -112,7 +123,7 @@ class TriviaSession:
await self.end_game()
break
else:
await self.ctx.send("There are no more questions!")
await self.ctx.send(_("There are no more questions!"))
await self.end_game()
async def _send_startup_msg(self):
@@ -120,20 +131,13 @@ class TriviaSession:
for idx, tup in enumerate(self.settings["lists"].items()):
name, author = tup
if author:
title = "{} (by {})".format(name, author)
title = _("{trivia_list} (by {author})").format(trivia_list=name, author=author)
else:
title = name
list_names.append(title)
num_lists = len(list_names)
if num_lists > 2:
# at least 3 lists, join all but last with comma
msg = ", ".join(list_names[: num_lists - 1])
# join onto last with "and"
msg = " and ".join((msg, list_names[num_lists - 1]))
else:
# either 1 or 2 lists, join together with "and"
msg = " and ".join(list_names)
await self.ctx.send("Starting Trivia: " + msg)
await self.ctx.send(
_("Starting Trivia: {list_names}").format(list_names=humanize_list(list_names))
)
def _iter_questions(self):
"""Iterate over questions and answers for this session.
@@ -178,20 +182,20 @@ class TriviaSession:
)
except asyncio.TimeoutError:
if time.time() - self._last_response >= timeout:
await self.ctx.send("Guys...? Well, I guess I'll stop then.")
await self.ctx.send(_("Guys...? Well, I guess I'll stop then."))
self.stop()
return False
if self.settings["reveal_answer"]:
reply = random.choice(_REVEAL_MESSAGES).format(answers[0])
reply = T_(random.choice(_REVEAL_MESSAGES)).format(answer=answers[0])
else:
reply = random.choice(_FAIL_MESSAGES)
reply = T_(random.choice(_FAIL_MESSAGES))
if self.settings["bot_plays"]:
reply += " **+1** for me!"
reply += _(" **+1** for me!")
self.scores[self.ctx.guild.me] += 1
await self.ctx.send(reply)
else:
self.scores[message.author] += 1
reply = "You got it {}! **+1** to you!".format(message.author.display_name)
reply = _("You got it {user}! **+1** to you!").format(user=message.author.display_name)
await self.ctx.send(reply)
return True
@@ -222,6 +226,7 @@ class TriviaSession:
self._last_response = time.time()
guess = message.content.lower()
guess = normalize_smartquotes(guess)
for answer in answers:
if " " in answer and answer in guess:
# Exact matching, issue #331
@@ -280,10 +285,16 @@ class TriviaSession:
amount = int(multiplier * score)
if amount > 0:
LOG.debug("Paying trivia winner: %d credits --> %s", amount, str(winner))
await deposit_credits(winner, int(multiplier * score))
await bank.deposit_credits(winner, int(multiplier * score))
await self.ctx.send(
"Congratulations, {0}, you have received {1} credits"
" for coming first.".format(winner.display_name, amount)
_(
"Congratulations, {user}, you have received {num} {currency}"
" for coming first."
).format(
user=winner.display_name,
num=amount,
currency=await bank.get_currency_name(self.ctx.guild),
)
)
@@ -311,9 +322,9 @@ def _parse_answers(answers):
for answer in answers:
if isinstance(answer, bool):
if answer is True:
ret.append("True", "Yes")
ret.extend(["True", "Yes", "On"])
else:
ret.append("False", "No")
ret.extend(["False", "No", "Off"])
else:
ret.append(str(answer))
# Uniquify list

View File

@@ -7,14 +7,17 @@ import discord
from redbot.core import commands
from redbot.core import Config, checks
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box, pagify, bold
from redbot.cogs.bank import check_global_setting_admin
from .log import LOG
from .session import TriviaSession
__all__ = ["Trivia", "UNIQUE_ID", "get_core_lists"]
UNIQUE_ID = 0xb3c0e453
UNIQUE_ID = 0xB3C0E453
_ = Translator("Trivia", __file__)
class InvalidListError(Exception):
@@ -23,10 +26,12 @@ class InvalidListError(Exception):
pass
class Trivia:
@cog_i18n(_)
class Trivia(commands.Cog):
"""Play trivia with friends!"""
def __init__(self):
super().__init__()
self.trivia_sessions = []
self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True)
@@ -46,20 +51,21 @@ class Trivia:
@commands.guild_only()
@checks.mod_or_permissions(administrator=True)
async def triviaset(self, ctx: commands.Context):
"""Manage trivia settings."""
"""Manage Trivia settings."""
if ctx.invoked_subcommand is None:
settings = self.conf.guild(ctx.guild)
settings_dict = await settings.all()
msg = box(
"**Current settings**\n"
"Bot gains points: {bot_plays}\n"
"Answer time limit: {delay} seconds\n"
"Lack of response timeout: {timeout} seconds\n"
"Points to win: {max_score}\n"
"Reveal answer on timeout: {reveal_answer}\n"
"Payout multiplier: {payout_multiplier}\n"
"Allow lists to override settings: {allow_override}"
"".format(**settings_dict),
_(
"**Current settings**\n"
"Bot gains points: {bot_plays}\n"
"Answer time limit: {delay} seconds\n"
"Lack of response timeout: {timeout} seconds\n"
"Points to win: {max_score}\n"
"Reveal answer on timeout: {reveal_answer}\n"
"Payout multiplier: {payout_multiplier}\n"
"Allow lists to override settings: {allow_override}"
).format(**settings_dict),
lang="py",
)
await ctx.send(msg)
@@ -68,33 +74,34 @@ class Trivia:
async def triviaset_max_score(self, ctx: commands.Context, score: int):
"""Set the total points required to win."""
if score < 0:
await ctx.send("Score must be greater than 0.")
await ctx.send(_("Score must be greater than 0."))
return
settings = self.conf.guild(ctx.guild)
await settings.max_score.set(score)
await ctx.send("Done. Points required to win set to {}.".format(score))
await ctx.send(_("Done. Points required to win set to {num}.").format(num=score))
@triviaset.command(name="timelimit")
async def triviaset_timelimit(self, ctx: commands.Context, seconds: float):
"""Set the maximum seconds permitted to answer a question."""
if seconds < 4.0:
await ctx.send("Must be at least 4 seconds.")
await ctx.send(_("Must be at least 4 seconds."))
return
settings = self.conf.guild(ctx.guild)
await settings.delay.set(seconds)
await ctx.send("Done. Maximum seconds to answer set to {}.".format(seconds))
await ctx.send(_("Done. Maximum seconds to answer set to {num}.").format(num=seconds))
@triviaset.command(name="stopafter")
async def triviaset_stopafter(self, ctx: commands.Context, seconds: float):
"""Set how long until trivia stops due to no response."""
settings = self.conf.guild(ctx.guild)
if seconds < await settings.delay():
await ctx.send("Must be larger than the answer time limit.")
await ctx.send(_("Must be larger than the answer time limit."))
return
await settings.timeout.set(seconds)
await ctx.send(
"Done. Trivia sessions will now time out after {}"
" seconds of no responses.".format(seconds)
_(
"Done. Trivia sessions will now time out after {num} seconds of no responses."
).format(num=seconds)
)
@triviaset.command(name="override")
@@ -102,46 +109,44 @@ class Trivia:
"""Allow/disallow trivia lists to override settings."""
settings = self.conf.guild(ctx.guild)
await settings.allow_override.set(enabled)
enabled = "now" if enabled else "no longer"
await ctx.send(
"Done. Trivia lists can {} override the trivia settings"
" for this server.".format(enabled)
)
if enabled:
await ctx.send(
_("Done. Trivia lists can now override the trivia settings for this server.")
)
else:
await ctx.send(
_(
"Done. Trivia lists can no longer override the trivia settings for this "
"server."
)
)
@triviaset.command(name="botplays")
async def trivaset_bot_plays(self, ctx: commands.Context, true_or_false: bool):
@triviaset.command(name="botplays", usage="<true_or_false>")
async def trivaset_bot_plays(self, ctx: commands.Context, enabled: bool):
"""Set whether or not the bot gains points.
If enabled, the bot will gain a point if no one guesses correctly.
"""
settings = self.conf.guild(ctx.guild)
await settings.bot_plays.set(true_or_false)
await ctx.send(
"Done. "
+ (
"I'll gain a point if users don't answer in time."
if true_or_false
else "Alright, I won't embarass you at trivia anymore."
)
)
await settings.bot_plays.set(enabled)
if enabled:
await ctx.send(_("Done. I'll now gain a point if users don't answer in time."))
else:
await ctx.send(_("Alright, I won't embarass you at trivia anymore."))
@triviaset.command(name="revealanswer")
async def trivaset_reveal_answer(self, ctx: commands.Context, true_or_false: bool):
@triviaset.command(name="revealanswer", usage="<true_or_false>")
async def trivaset_reveal_answer(self, ctx: commands.Context, enabled: bool):
"""Set whether or not the answer is revealed.
If enabled, the bot will reveal the answer if no one guesses correctly
in time.
"""
settings = self.conf.guild(ctx.guild)
await settings.reveal_answer.set(true_or_false)
await ctx.send(
"Done. "
+ (
"I'll reveal the answer if no one knows it."
if true_or_false
else "I won't reveal the answer to the questions anymore."
)
)
await settings.reveal_answer.set(enabled)
if enabled:
await ctx.send(_("Done. I'll reveal the answer if no one knows it."))
else:
await ctx.send(_("Alright, I won't reveal the answer to the questions anymore."))
@triviaset.command(name="payout")
@check_global_setting_admin()
@@ -157,13 +162,13 @@ class Trivia:
"""
settings = self.conf.guild(ctx.guild)
if multiplier < 0:
await ctx.send("Multiplier must be at least 0.")
await ctx.send(_("Multiplier must be at least 0."))
return
await settings.payout_multiplier.set(multiplier)
if not multiplier:
await ctx.send("Done. I will no longer reward the winner with a payout.")
return
await ctx.send("Done. Payout multiplier set to {}.".format(multiplier))
if multiplier:
await ctx.send(_("Done. Payout multiplier set to {num}.").format(num=multiplier))
else:
await ctx.send(_("Done. I will no longer reward the winner with a payout."))
@commands.group(invoke_without_command=True)
@commands.guild_only()
@@ -179,7 +184,7 @@ class Trivia:
categories = [c.lower() for c in categories]
session = self._get_trivia_session(ctx.channel)
if session is not None:
await ctx.send("There is already an ongoing trivia session in this channel.")
await ctx.send(_("There is already an ongoing trivia session in this channel."))
return
trivia_dict = {}
authors = []
@@ -190,15 +195,17 @@ class Trivia:
dict_ = self.get_trivia_list(category)
except FileNotFoundError:
await ctx.send(
"Invalid category `{0}`. See `{1}trivia list`"
" for a list of trivia categories."
"".format(category, ctx.prefix)
_(
"Invalid category `{name}`. See `{prefix}trivia list` for a list of "
"trivia categories."
).format(name=category, prefix=ctx.prefix)
)
except InvalidListError:
await ctx.send(
"There was an error parsing the trivia list for"
" the `{}` category. It may be formatted"
" incorrectly.".format(category)
_(
"There was an error parsing the trivia list for the `{name}` category. It "
"may be formatted incorrectly."
).format(name=category)
)
else:
trivia_dict.update(dict_)
@@ -207,7 +214,7 @@ class Trivia:
return
if not trivia_dict:
await ctx.send(
"The trivia list was parsed successfully, however it appears to be empty!"
_("The trivia list was parsed successfully, however it appears to be empty!")
)
return
settings = await self.conf.guild(ctx.guild).all()
@@ -224,7 +231,7 @@ class Trivia:
"""Stop an ongoing trivia session."""
session = self._get_trivia_session(ctx.channel)
if session is None:
await ctx.send("There is no ongoing trivia session in this channel.")
await ctx.send(_("There is no ongoing trivia session in this channel."))
return
author = ctx.author
auth_checks = (
@@ -237,20 +244,28 @@ class Trivia:
if any(auth_checks):
await session.end_game()
session.force_stop()
await ctx.send("Trivia stopped.")
await ctx.send(_("Trivia stopped."))
else:
await ctx.send("You are not allowed to do that.")
await ctx.send(_("You are not allowed to do that."))
@trivia.command(name="list")
async def trivia_list(self, ctx: commands.Context):
"""List available trivia categories."""
lists = set(p.stem for p in self._all_lists())
msg = box("**Available trivia lists**\n\n{}".format(", ".join(sorted(lists))))
if len(msg) > 1000:
await ctx.author.send(msg)
return
await ctx.send(msg)
if await ctx.embed_requested():
await ctx.send(
embed=discord.Embed(
title=_("Available trivia lists"),
colour=await ctx.embed_colour(),
description=", ".join(sorted(lists)),
)
)
else:
msg = box(bold(_("Available trivia lists")) + "\n\n" + ", ".join(sorted(lists)))
if len(msg) > 1000:
await ctx.author.send(msg)
else:
await ctx.send(msg)
@trivia.group(name="leaderboard", aliases=["lboard"], autohelp=False)
async def trivia_leaderboard(self, ctx: commands.Context):
@@ -272,19 +287,21 @@ class Trivia:
):
"""Leaderboard for this server.
<sort_by> can be any of the following fields:
- wins : total wins
- avg : average score
- total : total correct answers
`<sort_by>` can be any of the following fields:
- `wins` : total wins
- `avg` : average score
- `total` : total correct answers
- `games` : total games played
<top> is the number of ranks to show on the leaderboard.
`<top>` is the number of ranks to show on the leaderboard.
"""
key = self._get_sort_key(sort_by)
if key is None:
await ctx.send(
"Unknown field `{}`, see `{}help trivia "
"leaderboard server` for valid fields to sort by."
"".format(sort_by, ctx.prefix)
_(
"Unknown field `{field_name}`, see `{prefix}help trivia leaderboard server` "
"for valid fields to sort by."
).format(field_name=sort_by, prefix=ctx.prefix)
)
return
guild = ctx.guild
@@ -299,20 +316,21 @@ class Trivia:
):
"""Global trivia leaderboard.
<sort_by> can be any of the following fields:
- wins : total wins
- avg : average score
- total : total correct answers from all sessions
- games : total games played
`<sort_by>` can be any of the following fields:
- `wins` : total wins
- `avg` : average score
- `total` : total correct answers from all sessions
- `games` : total games played
<top> is the number of ranks to show on the leaderboard.
`<top>` is the number of ranks to show on the leaderboard.
"""
key = self._get_sort_key(sort_by)
if key is None:
await ctx.send(
"Unknown field `{}`, see `{}help trivia "
"leaderboard global` for valid fields to sort by."
"".format(sort_by, ctx.prefix)
_(
"Unknown field `{field_name}`, see `{prefix}help trivia leaderboard server` "
"for valid fields to sort by."
).format(field_name=sort_by, prefix=ctx.prefix)
)
return
data = await self.conf.all_members()
@@ -364,11 +382,11 @@ class Trivia:
"""
if not data:
await ctx.send("There are no scores on record!")
await ctx.send(_("There are no scores on record!"))
return
leaderboard = self._get_leaderboard(data, key, top)
ret = []
for page in pagify(leaderboard):
for page in pagify(leaderboard, shorten_by=10):
ret.append(await ctx.send(box(page, lang="py")))
return ret
@@ -385,7 +403,7 @@ class Trivia:
try:
priority.remove(key)
except ValueError:
raise ValueError("{} is not a valid key.".format(key))
raise ValueError(f"{key} is not a valid key.")
# Put key last in reverse priority
priority.append(key)
items = data.items()
@@ -394,16 +412,15 @@ class Trivia:
max_name_len = max(map(lambda m: len(str(m)), data.keys()))
# Headers
headers = (
"Rank",
"Member{}".format(" " * (max_name_len - 6)),
"Wins",
"Games Played",
"Total Score",
"Average Score",
_("Rank"),
_("Member") + " " * (max_name_len - 6),
_("Wins"),
_("Games Played"),
_("Total Score"),
_("Average Score"),
)
lines = [" | ".join(headers)]
lines = [" | ".join(headers), " | ".join(("-" * len(h) for h in headers))]
# Header underlines
lines.append(" | ".join(("-" * len(h) for h in headers)))
for rank, tup in enumerate(items, 1):
member, m_data = tup
# Align fields to header width
@@ -487,7 +504,7 @@ class Trivia:
with path.open(encoding="utf-8") as file:
try:
dict_ = yaml.load(file)
dict_ = yaml.safe_load(file)
except yaml.error.YAMLError as exc:
raise InvalidListError("YAML parsing failed.") from exc
else:

View File

@@ -5,6 +5,7 @@ import discord
from redbot.core import Config, checks, commands
from redbot.core.i18n import Translator
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("Warnings", __file__)
@@ -18,9 +19,11 @@ async def warning_points_add_check(
act = {}
async with guild_settings.actions() as registered_actions:
for a in registered_actions:
# Actions are sorted in decreasing order of points.
# The first action we find where the user is above the threshold will be the
# highest action we can take.
if points >= a["points"]:
act = a
else:
break
if act and act["exceed_command"] is not None: # some action needs to be taken
await create_and_invoke_context(ctx, act["exceed_command"], user)
@@ -95,11 +98,10 @@ async def get_command_for_exceeded_points(ctx: commands.Context):
await ctx.send(_("You may enter your response now."))
def same_author_check(m):
return m.author == ctx.author
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
msg = await ctx.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=30
)
except asyncio.TimeoutError:
return None
else:
@@ -140,11 +142,10 @@ async def get_command_for_dropping_points(ctx: commands.Context):
await ctx.send(_("You may enter your response now."))
def same_author_check(m):
return m.author == ctx.author
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
msg = await ctx.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=30
)
except asyncio.TimeoutError:
return None
else:

View File

@@ -9,69 +9,80 @@ from redbot.cogs.warnings.helpers import (
get_command_for_dropping_points,
warning_points_remove_check,
)
from redbot.core import Config, modlog, checks, commands
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.mod import is_admin_or_superior
from redbot.core.utils.chat_formatting import warning, pagify
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.utils.predicates import MessagePredicate
_ = Translator("Warnings", __file__)
@cog_i18n(_)
class Warnings:
"""A warning system for Red"""
class Warnings(commands.Cog):
"""Warn misbehaving users and take automated actions."""
default_guild = {"actions": [], "reasons": {}, "allow_custom_reasons": False}
default_member = {"total_points": 0, "status": "", "warnings": {}}
def __init__(self, bot: Red):
super().__init__()
self.config = Config.get_conf(self, identifier=5757575755)
self.config.register_guild(**self.default_guild)
self.config.register_member(**self.default_member)
self.bot = bot
loop = asyncio.get_event_loop()
loop.create_task(self.register_warningtype())
@staticmethod
async def register_warningtype():
try:
await modlog.register_casetype("warning", True, "\N{WARNING SIGN}", "Warning", None)
except RuntimeError:
pass
# We're not utilising modlog yet - no need to register a casetype
# @staticmethod
# async def register_warningtype():
# try:
# await modlog.register_casetype("warning", True, "\N{WARNING SIGN}", "Warning", None)
# except RuntimeError:
# pass
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warningset(self, ctx: commands.Context):
"""Warning settings"""
"""Manage settings for Warnings."""
pass
@warningset.command()
@commands.guild_only()
async def allowcustomreasons(self, ctx: commands.Context, allowed: bool):
"""Enable or Disable custom reasons for a warning"""
"""Enable or disable custom reasons for a warning."""
guild = ctx.guild
await self.config.guild(guild).allow_custom_reasons.set(allowed)
await ctx.send(
_("Custom reasons have been {}.").format(_("enabled") if allowed else _("disabled"))
)
if allowed:
await ctx.send(_("Custom reasons have been enabled."))
else:
await ctx.send(_("Custom reasons have been disabled."))
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnaction(self, ctx: commands.Context):
"""Action management"""
"""Manage automated actions for Warnings.
Actions are essentially command macros. Any command can be run
when the action is initially triggered, and/or when the action
is lifted.
Actions must be given a name and a points threshold. When a
user is warned enough so that their points go over this
threshold, the action will be executed.
"""
pass
@warnaction.command(name="add")
@commands.guild_only()
async def action_add(self, ctx: commands.Context, name: str, points: int):
"""Create an action to be taken at a specified point count
"""Create an automated action.
Duplicate action names are not allowed
Duplicate action names are not allowed.
"""
guild = ctx.guild
@@ -102,7 +113,7 @@ class Warnings:
@warnaction.command(name="del")
@commands.guild_only()
async def action_del(self, ctx: commands.Context, action_name: str):
"""Delete the point count action with the specified name"""
"""Delete the action with the specified name."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
async with guild_settings.actions() as registered_actions:
@@ -115,23 +126,29 @@ class Warnings:
registered_actions.remove(to_remove)
await ctx.tick()
else:
await ctx.send(_("No action named {} exists!").format(action_name))
await ctx.send(_("No action named {name} exists!").format(name=action_name))
@commands.group()
@commands.guild_only()
@checks.guildowner_or_permissions(administrator=True)
async def warnreason(self, ctx: commands.Context):
"""Add reasons for warnings"""
"""Manage warning reasons.
Reasons must be given a name, description and points value. The
name of the reason must be given when a user is warned.
"""
pass
@warnreason.command(name="add")
@warnreason.command(name="create", aliases=["add"])
@commands.guild_only()
async def reason_add(self, ctx: commands.Context, name: str, points: int, *, description: str):
"""Add a reason to be available for warnings"""
async def reason_create(
self, ctx: commands.Context, name: str, points: int, *, description: str
):
"""Create a warning reason."""
guild = ctx.guild
if name.lower() == "custom":
await ctx.send("That cannot be used as a reason name!")
await ctx.send(_("*Custom* cannot be used as a reason name!"))
return
to_add = {"points": points, "description": description}
completed = {name.lower(): to_add}
@@ -141,12 +158,12 @@ class Warnings:
async with guild_settings.reasons() as registered_reasons:
registered_reasons.update(completed)
await ctx.send(_("That reason has been registered."))
await ctx.send(_("The new reason has been registered."))
@warnreason.command(name="del")
@warnreason.command(name="del", aliases=["remove"])
@commands.guild_only()
async def reason_del(self, ctx: commands.Context, reason_name: str):
"""Delete the reason with the specified name"""
"""Delete a warning reason."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
async with guild_settings.reasons() as registered_reasons:
@@ -159,7 +176,7 @@ class Warnings:
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def reasonlist(self, ctx: commands.Context):
"""List all configured reasons for warnings"""
"""List all configured reasons for Warnings."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
msg_list = []
@@ -173,9 +190,9 @@ class Warnings:
msg_list.append(em)
else:
msg_list.append(
"Name: {}\nPoints: {}\nDescription: {}".format(
r, v["points"], v["description"]
)
_(
"Name: {reason_name}\nPoints: {points}\nDescription: {description}"
).format(reason_name=r, **v)
)
if msg_list:
await menu(ctx, msg_list, DEFAULT_CONTROLS)
@@ -186,7 +203,7 @@ class Warnings:
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def actionlist(self, ctx: commands.Context):
"""List the actions to be taken at specific point values"""
"""List all configured automated actions for Warnings."""
guild = ctx.guild
guild_settings = self.config.guild(guild)
msg_list = []
@@ -200,10 +217,10 @@ class Warnings:
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"]
)
_(
"Name: {action_name}\nPoints: {points}\n"
"Exceed command: {exceed_command}\nDrop command: {drop_command}"
).format(**r)
)
if msg_list:
await menu(ctx, msg_list, DEFAULT_CONTROLS)
@@ -214,9 +231,10 @@ class Warnings:
@commands.guild_only()
@checks.admin_or_permissions(ban_members=True)
async def warn(self, ctx: commands.Context, user: discord.Member, reason: str):
"""Warn the user for the specified reason
"""Warn the user for the specified reason.
Reason must be a registered reason, or "custom" if custom reasons are allowed
`<reason>` must be a registered reason name, or *custom* if
custom reasons are enabled.
"""
if user == ctx.author:
await ctx.send(_("You cannot warn yourself."))
@@ -226,9 +244,9 @@ class Warnings:
if not custom_allowed:
await ctx.send(
_(
"Custom reasons are not allowed! Please see {} for "
"Custom reasons are not allowed! Please see `{prefix}reasonlist` for "
"a complete list of valid reasons."
).format("`{}reasonlist`".format(ctx.prefix))
).format(prefix=ctx.prefix)
)
return
reason_type = await self.custom_warning_reason(ctx)
@@ -272,9 +290,7 @@ class Warnings:
await warning_points_add_check(self.config, ctx, user, current_point_count)
try:
em = discord.Embed(
title=_("Warning from {mod_name}#{mod_discrim}").format(
mod_name=ctx.author.display_name, mod_discrim=ctx.author.discriminator
),
title=_("Warning from {user}").format(user=ctx.author),
description=reason_type["description"],
)
em.add_field(name=_("Points"), value=str(reason_type["points"]))
@@ -286,20 +302,17 @@ class Warnings:
)
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
)
)
await ctx.send(_("User {user} has been warned.").format(user=user))
@commands.command()
@commands.guild_only()
async def warnings(self, ctx: commands.Context, userid: int = None):
"""Show warnings for the specified user.
"""List the warnings for the specified user.
Emit `<userid>` to see your own warnings.
If userid is None, show warnings for the person running the command
Note that showing warnings for users other than yourself requires
appropriate permissions
appropriate permissions.
"""
if userid is None:
user = ctx.author
@@ -327,16 +340,24 @@ class Warnings:
)
if mod is None:
mod = await self.bot.get_user_info(user_warnings[key]["mod"])
msg += "{} point warning {} issued by {} for {}\n".format(
user_warnings[key]["points"], key, mod, user_warnings[key]["description"]
msg += _(
"{num_points} point warning {reason_name} issued by {user} for "
"{description}\n"
).format(
num_points=user_warnings[key]["points"],
reason_name=key,
user=mod,
description=user_warnings[key]["description"],
)
await ctx.send_interactive(pagify(msg), box_lang="Warnings for {}".format(user))
await ctx.send_interactive(
pagify(msg, shorten_by=58), box_lang=_("Warnings for {user}").format(user=user)
)
@commands.command()
@commands.guild_only()
@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"""
"""Remove a warning from a user."""
if user_id == ctx.author.id:
await ctx.send(_("You cannot remove warnings from yourself."))
return
@@ -350,7 +371,7 @@ class Warnings:
await warning_points_remove_check(self.config, ctx, member, current_point_count)
async with member_settings.warnings() as user_warnings:
if warn_id not in user_warnings.keys():
await ctx.send("That warning doesn't exist!")
await ctx.send(_("That warning doesn't exist!"))
return
else:
current_point_count -= user_warnings[warn_id]["points"]
@@ -363,12 +384,11 @@ class Warnings:
"""Handles getting description and points for custom reasons"""
to_add = {"points": 0, "description": ""}
def same_author_check(m):
return m.author == ctx.author
await ctx.send(_("How many points should be given for this reason?"))
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
msg = await ctx.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=30
)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
return
@@ -385,7 +405,9 @@ class Warnings:
await ctx.send(_("Enter a description for this reason."))
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=30)
msg = await ctx.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=30
)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
return

View File

@@ -1,40 +1,152 @@
import re as _re
from math import inf as _inf
from typing import (
Any as _Any,
ClassVar as _ClassVar,
Dict as _Dict,
List as _List,
Optional as _Optional,
Pattern as _Pattern,
Tuple as _Tuple,
Union as _Union,
)
from .config import Config
__all__ = ["Config", "__version__"]
__all__ = ["Config", "__version__", "version_info", "VersionInfo"]
class VersionInfo:
def __init__(self, major, minor, micro, releaselevel, serial):
self._levels = ["alpha", "beta", "final"]
self.major = major
self.minor = minor
self.micro = micro
ALPHA = "alpha"
BETA = "beta"
RELEASE_CANDIDATE = "release candidate"
FINAL = "final"
if releaselevel not in self._levels:
raise TypeError("'releaselevel' must be one of: {}".format(", ".join(self._levels)))
_VERSION_STR_PATTERN: _ClassVar[_Pattern[str]] = _re.compile(
r"^"
r"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<micro>0|[1-9]\d*)"
r"(?:(?P<releaselevel>a|b|rc)(?P<serial>0|[1-9]\d*))?"
r"(?:\.post(?P<post_release>0|[1-9]\d*))?"
r"(?:\.dev(?P<dev_release>0|[1-9]\d*))?"
r"$",
flags=_re.IGNORECASE,
)
_RELEASE_LEVELS: _ClassVar[_List[str]] = [ALPHA, BETA, RELEASE_CANDIDATE, FINAL]
_SHORT_RELEASE_LEVELS: _ClassVar[_Dict[str, str]] = {
"a": ALPHA,
"b": BETA,
"rc": RELEASE_CANDIDATE,
}
self.releaselevel = releaselevel
self.serial = serial
def __init__(
self,
major: int,
minor: int,
micro: int,
releaselevel: str,
serial: _Optional[int] = None,
post_release: _Optional[int] = None,
dev_release: _Optional[int] = None,
) -> None:
self.major: int = major
self.minor: int = minor
self.micro: int = micro
def __lt__(self, other):
my_index = self._levels.index(self.releaselevel)
other_index = self._levels.index(other.releaselevel)
return (self.major, self.minor, self.micro, my_index, self.serial) < (
other.major,
other.minor,
other.micro,
other_index,
other.serial,
if releaselevel not in self._RELEASE_LEVELS:
raise TypeError(f"'releaselevel' must be one of: {', '.join(self._RELEASE_LEVELS)}")
self.releaselevel: str = releaselevel
self.serial: _Optional[int] = serial
self.post_release: _Optional[int] = post_release
self.dev_release: _Optional[int] = dev_release
@classmethod
def from_str(cls, version_str: str) -> "VersionInfo":
"""Parse a string into a VersionInfo object.
Raises
------
ValueError
If the version info string is invalid.
"""
match = cls._VERSION_STR_PATTERN.match(version_str)
if not match:
raise ValueError(f"Invalid version string: {version_str}")
kwargs: _Dict[str, _Union[str, int]] = {}
for key in ("major", "minor", "micro"):
kwargs[key] = int(match[key])
releaselevel = match["releaselevel"]
if releaselevel is not None:
kwargs["releaselevel"] = cls._SHORT_RELEASE_LEVELS[releaselevel]
else:
kwargs["releaselevel"] = cls.FINAL
for key in ("serial", "post_release", "dev_release"):
if match[key] is not None:
kwargs[key] = int(match[key])
return cls(**kwargs)
@classmethod
def from_json(
cls, data: _Union[_Dict[str, _Union[int, str]], _List[_Union[int, str]]]
) -> "VersionInfo":
if isinstance(data, _List):
# For old versions, data was stored as a list:
# [MAJOR, MINOR, MICRO, RELEASELEVEL, SERIAL]
return cls(*data)
else:
return cls(**data)
def to_json(self) -> _Dict[str, _Union[int, str]]:
return {
"major": self.major,
"minor": self.minor,
"micro": self.micro,
"releaselevel": self.releaselevel,
"serial": self.serial,
"post_release": self.post_release,
"dev_release": self.dev_release,
}
def __lt__(self, other: _Any) -> bool:
if not isinstance(other, VersionInfo):
return NotImplemented
tups: _List[_Tuple[int, int, int, int, int, int, int]] = []
for obj in (self, other):
tups.append(
(
obj.major,
obj.minor,
obj.micro,
obj._RELEASE_LEVELS.index(obj.releaselevel),
obj.serial if obj.serial is not None else _inf,
obj.post_release if obj.post_release is not None else -_inf,
obj.dev_release if obj.dev_release is not None else _inf,
)
)
return tups[0] < tups[1]
def __str__(self) -> str:
ret = f"{self.major}.{self.minor}.{self.micro}"
if self.releaselevel != self.FINAL:
short = next(
k for k, v in self._SHORT_RELEASE_LEVELS.items() if v == self.releaselevel
)
ret += f"{short}{self.serial}"
if self.post_release is not None:
ret += f".post{self.post_release}"
if self.dev_release is not None:
ret += f".dev{self.dev_release}"
return ret
def __repr__(self) -> str:
return (
"VersionInfo(major={major}, minor={minor}, micro={micro}, "
"releaselevel={releaselevel}, serial={serial}, post={post_release}, "
"dev={dev_release})".format(**self.to_json())
)
def __repr__(self):
return "VersionInfo(major={}, minor={}, micro={}, releaselevel={}, serial={})".format(
self.major, self.minor, self.micro, self.releaselevel, self.serial
)
def to_json(self):
return [self.major, self.minor, self.micro, self.releaselevel, self.serial]
__version__ = "3.0.0b21"
version_info = VersionInfo(3, 0, 0, "beta", 21)
__version__ = "3.0.0rc3.post1"
version_info = VersionInfo.from_str(__version__)

View File

@@ -1,12 +1,13 @@
import datetime
import os
from typing import Union, List
from typing import Union, List, Optional
import discord
from redbot.core import Config
from . import Config, errors
__all__ = [
"MAX_BALANCE",
"Account",
"get_balance",
"set_balance",
@@ -26,6 +27,8 @@ __all__ = [
"set_default_balance",
]
MAX_BALANCE = 2 ** 63 - 1
_DEFAULT_GLOBAL = {
"is_global": False,
"bank_name": "Twentysix bank",
@@ -170,10 +173,22 @@ async def set_balance(member: discord.Member, amount: int) -> int:
------
ValueError
If attempting to set the balance to a negative number.
BalanceTooHigh
If attempting to set the balance to a value greater than
``bank.MAX_BALANCE``
"""
if amount < 0:
raise ValueError("Not allowed to have negative balance.")
if amount > MAX_BALANCE:
currency = (
await get_currency_name()
if await is_global()
else await get_currency_name(member.guild)
)
raise errors.BalanceTooHigh(
user=member.display_name, max_balance=MAX_BALANCE, currency_name=currency
)
if await is_global():
group = _conf.user(member)
else:
@@ -296,12 +311,20 @@ async def transfer_credits(from_: discord.Member, to: discord.Member, amount: in
return await deposit_credits(to, amount)
async def wipe_bank():
"""Delete all accounts from the bank."""
async def wipe_bank(guild: Optional[discord.Guild] = None) -> None:
"""Delete all accounts from the bank.
Parameters
----------
guild : discord.Guild
The guild to clear accounts for. If unsupplied and the bank is
per-server, all accounts in every guild will be wiped.
"""
if await is_global():
await _conf.clear_all_users()
else:
await _conf.clear_all_members()
await _conf.clear_all_members(guild)
async def get_leaderboard(positions: int = None, guild: discord.Guild = None) -> List[tuple]:

View File

@@ -1,24 +1,21 @@
import asyncio
import inspect
import os
import logging
from collections import Counter
from enum import Enum
from importlib.machinery import ModuleSpec
from pathlib import Path
from typing import Optional, Union, List
import discord
import sys
from discord.ext.commands import when_mentioned_or
# This supresses the PyNaCl warning that isn't relevant here
from discord.voice_client import VoiceClient
VoiceClient.warn_nacl = False
from . import Config, i18n, commands, errors
from .cog_manager import CogManager
from . import Config, i18n, commands
from .rpc import RPCMixin
from .help_formatter import Help, help as help_
from .rpc import RPCMixin
from .sentry import SentryManager
from .utils import common_filters
@@ -72,6 +69,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
use_bot_color=False,
fuzzy=False,
disabled_commands=[],
autoimmune_ids=[],
)
self.db.register_user(embeds=None)
@@ -113,7 +111,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
self.main_dir = bot_dir
self.cog_mgr = CogManager(paths=(str(self.main_dir / "cogs"),))
self.cog_mgr = CogManager()
super().__init__(*args, formatter=Help(), **kwargs)
@@ -122,6 +120,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
self.add_command(help_)
self._sentry_mgr = None
self._permissions_hooks: List[commands.CheckPredicate] = []
def enable_sentry(self):
"""Enable Sentry logging for Red."""
@@ -187,18 +186,29 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def is_admin(self, member: discord.Member):
"""Checks if a member is an admin of their guild."""
admin_role = await self.db.guild(member.guild).admin_role()
return any(role.id == admin_role for role in member.roles)
try:
if any(role.id == admin_role for role in member.roles):
return True
except AttributeError: # someone passed a webhook to this
pass
return False
async def is_mod(self, member: discord.Member):
"""Checks if a member is a mod or admin of their guild."""
mod_role = await self.db.guild(member.guild).mod_role()
admin_role = await self.db.guild(member.guild).admin_role()
return any(role.id in (mod_role, admin_role) for role in member.roles)
try:
if any(role.id in (mod_role, admin_role) for role in member.roles):
return True
except AttributeError: # someone passed a webhook to this
pass
return False
async def get_context(self, message, *, cls=commands.Context):
return await super().get_context(message, cls=cls)
def list_packages(self):
@staticmethod
def list_packages():
"""Lists packages present in the cogs the folder"""
return os.listdir("cogs")
@@ -218,7 +228,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
async def load_extension(self, spec: ModuleSpec):
name = spec.name.split(".")[-1]
if name in self.extensions:
raise discord.ClientException(f"there is already a package named {name} loaded")
raise errors.PackageAlreadyLoaded(spec)
lib = spec.loader.load_module()
if not hasattr(lib, "setup"):
@@ -232,7 +242,19 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
self.extensions[name] = lib
def remove_cog(self, cogname):
def remove_cog(self, cogname: str):
cog = self.get_cog(cogname)
if cog is None:
return
for cls in inspect.getmro(cog.__class__):
try:
hook = getattr(cog, f"_{cls.__name__}__permissions_hook")
except AttributeError:
pass
else:
self.remove_permissions_hook(hook)
super().remove_cog(cogname)
for meth in self.rpc_handlers.pop(cogname.upper(), ()):
@@ -294,6 +316,48 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
if pkg_name.startswith("redbot.cogs."):
del sys.modules["redbot.cogs"].__dict__[name]
async def is_automod_immune(
self, to_check: Union[discord.Message, commands.Context, discord.abc.User, discord.Role]
) -> bool:
"""
Checks if the user, message, context, or role should be considered immune from automated
moderation actions.
This will return ``False`` in direct messages.
Parameters
----------
to_check : `discord.Message` or `commands.Context` or `discord.abc.User` or `discord.Role`
Something to check if it would be immune
Returns
-------
bool
``True`` if immune
"""
guild = to_check.guild
if not guild:
return False
if isinstance(to_check, discord.Role):
ids_to_check = [to_check.id]
else:
author = getattr(to_check, "author", to_check)
try:
ids_to_check = [r.id for r in author.roles]
except AttributeError:
# webhook messages are a user not member,
# cheaper than isinstance
return True # webhooks require significant permissions to enable.
else:
ids_to_check.append(author.id)
immune_ids = await self.db.guild(guild).autoimmune_ids()
return any(i in immune_ids for i in ids_to_check)
@staticmethod
async def send_filtered(
destination: discord.abc.Messageable,
filter_mass_mentions=True,
@@ -327,7 +391,24 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
await destination.send(content=content, **kwargs)
def add_cog(self, cog):
def add_cog(self, cog: commands.Cog):
if not isinstance(cog, commands.Cog):
raise RuntimeError(
f"The {cog.__class__.__name__} cog in the {cog.__module__} package does "
f"not inherit from the commands.Cog base class. The cog author must update "
f"the cog to adhere to this requirement."
)
if not hasattr(cog, "requires"):
commands.Cog.__init__(cog)
for cls in inspect.getmro(cog.__class__):
try:
hook = getattr(cog, f"_{cls.__name__}__permissions_hook")
except AttributeError:
pass
else:
self.add_permissions_hook(hook)
for attr in dir(cog):
_attr = getattr(cog, attr)
if isinstance(_attr, discord.ext.commands.Command) and not isinstance(
@@ -342,6 +423,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
"http://red-discordbot.readthedocs.io/en/v3-develop/framework_commands.html"
)
super().add_cog(cog)
self.dispatch("cog_add", cog)
def add_command(self, command: commands.Command):
if not isinstance(command, commands.Command):
@@ -350,6 +432,76 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin):
super().add_command(command)
self.dispatch("command_add", command)
def clear_permission_rules(self, guild_id: Optional[int]) -> None:
"""Clear all permission overrides in a scope.
Parameters
----------
guild_id : Optional[int]
The guild ID to wipe permission overrides for. If
``None``, this will clear all global rules and leave all
guild rules untouched.
"""
for cog in self.cogs.values():
cog.requires.clear_all_rules(guild_id)
for command in self.walk_commands():
command.requires.clear_all_rules(guild_id)
def add_permissions_hook(self, hook: commands.CheckPredicate) -> None:
"""Add a permissions hook.
Permissions hooks are check predicates which are called before
calling `Requires.verify`, and they can optionally return an
override: ``True`` to allow, ``False`` to deny, and ``None`` to
default to normal behaviour.
Parameters
----------
hook
A command check predicate which returns ``True``, ``False``
or ``None``.
"""
self._permissions_hooks.append(hook)
def remove_permissions_hook(self, hook: commands.CheckPredicate) -> None:
"""Remove a permissions hook.
Parameters are the same as those in `add_permissions_hook`.
Raises
------
ValueError
If the permissions hook has not been added.
"""
self._permissions_hooks.remove(hook)
async def verify_permissions_hooks(self, ctx: commands.Context) -> Optional[bool]:
"""Run permissions hooks.
Parameters
----------
ctx : commands.Context
The context for the command being invoked.
Returns
-------
Optional[bool]
``False`` if any hooks returned ``False``, ``True`` if any
hooks return ``True`` and none returned ``False``, ``None``
otherwise.
"""
hook_results = []
for hook in self._permissions_hooks:
result = await discord.utils.maybe_coroutine(hook, ctx)
if result is not None:
hook_results.append(result)
if hook_results:
return all(hook_results)
class Red(RedBase, discord.AutoShardedClient):
"""

View File

@@ -1,126 +1,77 @@
import warnings
from typing import Awaitable, TYPE_CHECKING, Dict
import discord
from redbot.core import commands
from .commands import (
bot_has_permissions,
has_permissions,
is_owner,
guildowner,
guildowner_or_permissions,
admin,
admin_or_permissions,
mod,
mod_or_permissions,
check as _check_decorator,
)
from .utils.mod import (
is_mod_or_superior as _is_mod_or_superior,
is_admin_or_superior as _is_admin_or_superior,
check_permissions as _check_permissions,
)
if TYPE_CHECKING:
from .bot import Red
from .commands import Context
__all__ = [
"bot_has_permissions",
"has_permissions",
"is_owner",
"guildowner",
"guildowner_or_permissions",
"admin",
"admin_or_permissions",
"mod",
"mod_or_permissions",
"is_mod_or_superior",
"is_admin_or_superior",
"bot_in_a_guild",
"check_permissions",
]
async def check_overrides(ctx, *, level):
if await ctx.bot.is_owner(ctx.author):
return True
perm_cog = ctx.bot.get_cog("Permissions")
if not perm_cog or ctx.cog == perm_cog:
return None
# don't break if someone loaded a cog named
# permissions that doesn't implement this
func = getattr(perm_cog, "check_overrides", None)
val = None if func is None else await func(ctx, level)
return val
def bot_in_a_guild():
"""Deny the command if the bot is not in a guild."""
def is_owner(**kwargs):
async def check(ctx):
return await ctx.bot.is_owner(ctx.author, **kwargs)
return commands.check(check)
async def check_permissions(ctx, perms):
if await ctx.bot.is_owner(ctx.author):
return True
elif not perms:
return False
resolved = ctx.channel.permissions_for(ctx.author)
return resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)
async def is_mod_or_superior(ctx):
if ctx.guild is None:
return await ctx.bot.is_owner(ctx.author)
else:
author = ctx.author
settings = ctx.bot.db.guild(ctx.guild)
mod_role_id = await settings.mod_role()
admin_role_id = await settings.admin_role()
mod_role = discord.utils.get(ctx.guild.roles, id=mod_role_id)
admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id)
return (
await ctx.bot.is_owner(ctx.author)
or mod_role in author.roles
or admin_role in author.roles
or author == ctx.guild.owner
)
async def is_admin_or_superior(ctx):
if ctx.guild is None:
return await ctx.bot.is_owner(ctx.author)
else:
author = ctx.author
settings = ctx.bot.db.guild(ctx.guild)
admin_role_id = await settings.admin_role()
admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id)
return (
await ctx.bot.is_owner(ctx.author)
or admin_role in author.roles
or author == ctx.guild.owner
)
def mod_or_permissions(**perms):
async def predicate(ctx):
override = await check_overrides(ctx, level="mod")
return (
override
if override is not None
else await check_permissions(ctx, perms) or await is_mod_or_superior(ctx)
)
return commands.check(predicate)
def admin_or_permissions(**perms):
async def predicate(ctx):
override = await check_overrides(ctx, level="admin")
return (
override
if override is not None
else await check_permissions(ctx, perms) or await is_admin_or_superior(ctx)
)
return commands.check(predicate)
def bot_in_a_guild(**kwargs):
async def predicate(ctx):
return len(ctx.bot.guilds) > 0
return commands.check(predicate)
return _check_decorator(predicate)
def guildowner_or_permissions(**perms):
async def predicate(ctx):
has_perms_or_is_owner = await check_permissions(ctx, perms)
if ctx.guild is None:
return has_perms_or_is_owner
is_guild_owner = ctx.author == ctx.guild.owner
override = await check_overrides(ctx, level="guildowner")
return override if override is not None else is_guild_owner or has_perms_or_is_owner
return commands.check(predicate)
def is_mod_or_superior(ctx: "Context") -> Awaitable[bool]:
warnings.warn(
"`redbot.core.checks.is_mod_or_superior` is deprecated and will be removed in a future "
"release, please use `redbot.core.utils.mod.is_mod_or_superior` instead.",
category=DeprecationWarning,
)
return _is_mod_or_superior(ctx.bot, ctx.author)
def guildowner():
return guildowner_or_permissions()
def is_admin_or_superior(ctx: "Context") -> Awaitable[bool]:
warnings.warn(
"`redbot.core.checks.is_admin_or_superior` is deprecated and will be removed in a future "
"release, please use `redbot.core.utils.mod.is_admin_or_superior` instead.",
category=DeprecationWarning,
)
return _is_admin_or_superior(ctx.bot, ctx.author)
def admin():
return admin_or_permissions()
def mod():
return mod_or_permissions()
def check_permissions(ctx: "Context", perms: Dict[str, bool]) -> Awaitable[bool]:
warnings.warn(
"`redbot.core.checks.check_permissions` is deprecated and will be removed in a future "
"release, please use `redbot.core.utils.mod.check_permissions`."
)
return _check_permissions(ctx, perms)

View File

@@ -50,12 +50,10 @@ def interactive_config(red, token_set, prefix_set):
def ask_sentry(red: Red):
loop = asyncio.get_event_loop()
print(
"\nThank you for installing Red V3 beta! The current version\n"
" is not suited for production use and is aimed at testing\n"
" the current and upcoming featureset, that's why we will\n"
" also collect the fatal error logs to help us fix any new\n"
" found issues in a timely manner. If you wish to opt in\n"
' the process please type "yes":\n'
"\nThank you for installing Red V3! Red is constantly undergoing\n"
" improvements, and we would like ask if you are comfortable with\n"
" the bot automatically submitting fatal error logs to the development\n"
' team. If you wish to opt into the process please type "yes":\n'
)
if not confirm("> "):
loop.run_until_complete(red.db.enable_sentry.set(False))

View File

@@ -3,7 +3,7 @@ import pkgutil
from importlib import import_module, invalidate_caches
from importlib.machinery import ModuleSpec
from pathlib import Path
from typing import Tuple, Union, List, Optional
from typing import Union, List, Optional
import redbot.cogs
from redbot.core.utils import deduplicate_iterables
@@ -25,8 +25,6 @@ class NoSuchCog(ImportError):
Different from ImportError because some ImportErrors can happen inside cogs.
"""
pass
class CogManager:
"""Directory manager for Red's cogs.
@@ -39,30 +37,27 @@ class CogManager:
CORE_PATH = Path(redbot.cogs.__path__[0])
def __init__(self, paths: Tuple[str] = ()):
def __init__(self):
self.conf = Config.get_conf(self, 2938473984732, True)
tmp_cog_install_path = cog_data_path(self) / "cogs"
tmp_cog_install_path.mkdir(parents=True, exist_ok=True)
self.conf.register_global(paths=[], install_path=str(tmp_cog_install_path))
self._paths = [Path(p) for p in paths]
async def paths(self) -> Tuple[Path, ...]:
"""Get all currently valid path directories.
async def paths(self) -> List[Path]:
"""Get all currently valid path directories, in order of priority
Returns
-------
`tuple` of `pathlib.Path`
All valid cog paths.
List[pathlib.Path]
A list of paths where cog packages can be found. The
install path is highest priority, followed by the
user-defined paths, and the core path has the lowest
priority.
"""
conf_paths = [Path(p) for p in await self.conf.paths()]
other_paths = self._paths
all_paths = deduplicate_iterables(conf_paths, other_paths, [self.CORE_PATH])
if self.install_path not in all_paths:
all_paths.insert(0, await self.install_path())
return tuple(p.resolve() for p in all_paths if p.is_dir())
return deduplicate_iterables(
[await self.install_path()], await self.user_defined_paths(), [self.CORE_PATH]
)
async def install_path(self) -> Path:
"""Get the install path for 3rd party cogs.
@@ -73,8 +68,20 @@ class CogManager:
The path to the directory where 3rd party cogs are stored.
"""
p = Path(await self.conf.install_path())
return p.resolve()
return Path(await self.conf.install_path()).resolve()
async def user_defined_paths(self) -> List[Path]:
"""Get a list of user-defined cog paths.
All paths will be absolute and unique, in order of priority.
Returns
-------
List[pathlib.Path]
A list of user-defined paths.
"""
return list(map(Path, deduplicate_iterables(await self.conf.paths())))
async def set_install_path(self, path: Path) -> Path:
"""Set the install path for 3rd party cogs.
@@ -125,11 +132,10 @@ class CogManager:
path = Path(path)
return path
async def add_path(self, path: Union[Path, str]):
async def add_path(self, path: Union[Path, str]) -> None:
"""Add a cog path to current list.
This will ignore duplicates. Does have a side effect of removing all
invalid paths from the saved path list.
This will ignore duplicates.
Parameters
----------
@@ -156,11 +162,12 @@ class CogManager:
if path == self.CORE_PATH:
raise ValueError("Cannot add the core path as an additional path.")
async with self.conf.paths() as paths:
if not any(Path(p) == path for p in paths):
paths.append(str(path))
current_paths = await self.user_defined_paths()
if path not in current_paths:
current_paths.append(path)
await self.set_paths(current_paths)
async def remove_path(self, path: Union[Path, str]) -> Tuple[Path, ...]:
async def remove_path(self, path: Union[Path, str]) -> None:
"""Remove a path from the current paths list.
Parameters
@@ -168,20 +175,12 @@ class CogManager:
path : `pathlib.Path` or `str`
Path to remove.
Returns
-------
`tuple` of `pathlib.Path`
Tuple of new valid paths.
"""
path = self._ensure_path_obj(path).resolve()
paths = await self.user_defined_paths()
paths = [Path(p) for p in await self.conf.paths()]
if path in paths:
paths.remove(path)
await self.set_paths(paths)
return tuple(paths)
paths.remove(path)
await self.set_paths(paths)
async def set_paths(self, paths_: List[Path]):
"""Set the current paths list.
@@ -192,7 +191,7 @@ class CogManager:
List of paths to set.
"""
str_paths = [str(p) for p in paths_]
str_paths = list(map(str, paths_))
await self.conf.paths.set(str_paths)
async def _find_ext_cog(self, name: str) -> ModuleSpec:
@@ -213,9 +212,9 @@ class CogManager:
------
NoSuchCog
When no cog with the requested name was found.
"""
resolved_paths = await self.paths()
real_paths = [str(p) for p in resolved_paths if p != self.CORE_PATH]
real_paths = list(map(str, [await self.install_path()] + await self.user_defined_paths()))
for finder, module_name, _ in pkgutil.iter_modules(real_paths):
if name == module_name:
@@ -287,10 +286,8 @@ class CogManager:
return await self._find_core_cog(name)
async def available_modules(self) -> List[str]:
"""Finds the names of all available modules to load.
"""
paths = (await self.install_path(),) + await self.paths()
paths = [str(p) for p in paths]
"""Finds the names of all available modules to load."""
paths = list(map(str, await self.paths()))
ret = []
for finder, module_name, _ in pkgutil.iter_modules(paths):
@@ -311,16 +308,9 @@ _ = Translator("CogManagerUI", __file__)
@cog_i18n(_)
class CogManagerUI:
class CogManagerUI(commands.Cog):
"""Commands to interface with Red's cog manager."""
@staticmethod
async def visible_paths(ctx):
install_path = await ctx.bot.cog_mgr.install_path()
cog_paths = await ctx.bot.cog_mgr.paths()
cog_paths = [p for p in cog_paths if p != install_path]
return cog_paths
@commands.command()
@checks.is_owner()
async def paths(self, ctx: commands.Context):
@@ -330,8 +320,7 @@ class CogManagerUI:
cog_mgr = ctx.bot.cog_mgr
install_path = await cog_mgr.install_path()
core_path = cog_mgr.CORE_PATH
cog_paths = await cog_mgr.paths()
cog_paths = [p for p in cog_paths if p not in (install_path, core_path)]
cog_paths = await cog_mgr.user_defined_paths()
msg = _("Install Path: {install_path}\nCore Path: {core_path}\n\n").format(
install_path=install_path, core_path=core_path
@@ -369,7 +358,11 @@ class CogManagerUI:
from !paths
"""
path_number -= 1
cog_paths = await self.visible_paths(ctx)
if path_number < 0:
await ctx.send(_("Path numbers must be positive."))
return
cog_paths = await ctx.bot.cog_mgr.user_defined_paths()
try:
to_remove = cog_paths.pop(path_number)
except IndexError:
@@ -388,8 +381,11 @@ class CogManagerUI:
# Doing this because in the paths command they're 1 indexed
from_ -= 1
to -= 1
if from_ < 0 or to < 0:
await ctx.send(_("Path numbers must be positive."))
return
all_paths = await self.visible_paths(ctx)
all_paths = await ctx.bot.cog_mgr.user_defined_paths()
try:
to_move = all_paths.pop(from_)
except IndexError:

View File

@@ -1,5 +1,6 @@
from discord.ext.commands import *
from .commands import *
from .context import *
from .converter import *
from .errors import *
from .requires import *

View File

@@ -5,33 +5,118 @@ replace those from the `discord.ext.commands` module.
"""
import inspect
import weakref
from typing import Awaitable, Callable, TYPE_CHECKING
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
import discord
from discord.ext import commands
from . import converter as converters
from .errors import ConversionFailure
from .requires import PermState, PrivilegeLevel, Requires
from ..i18n import Translator
if TYPE_CHECKING:
from .context import Context
__all__ = ["Command", "GroupMixin", "Group", "command", "group"]
__all__ = [
"Cog",
"CogCommandMixin",
"CogGroupMixin",
"Command",
"Group",
"GroupMixin",
"command",
"group",
]
_ = Translator("commands.commands", __file__)
class Command(commands.Command):
class CogCommandMixin:
"""A mixin for cogs and commands."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if isinstance(self, Command):
decorated = self.callback
else:
decorated = self
self.requires: Requires = Requires(
privilege_level=getattr(
decorated, "__requires_privilege_level__", PrivilegeLevel.NONE
),
user_perms=getattr(decorated, "__requires_user_perms__", {}),
bot_perms=getattr(decorated, "__requires_bot_perms__", {}),
checks=getattr(decorated, "__requires_checks__", []),
)
def allow_for(self, model_id: int, guild_id: int) -> None:
"""Actively allow this command for the given model."""
self.requires.set_rule(model_id, PermState.ACTIVE_ALLOW, guild_id=guild_id)
def deny_to(self, model_id: int, guild_id: int) -> None:
"""Actively deny this command to the given model."""
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.PASSIVE_ALLOW:
self.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
else:
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
"""Clear the rule which is currently set for this model."""
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.ACTIVE_ALLOW:
new_rule = PermState.NORMAL
elif cur_rule is PermState.ACTIVE_DENY:
new_rule = PermState.NORMAL
elif cur_rule is PermState.CAUTIOUS_ALLOW:
new_rule = PermState.PASSIVE_ALLOW
else:
return cur_rule, cur_rule
self.requires.set_rule(model_id, new_rule, guild_id=guild_id)
return cur_rule, new_rule
def set_default_rule(self, rule: Optional[bool], guild_id: int) -> None:
"""Set the default rule for this cog or command.
Parameters
----------
rule : Optional[bool]
The rule to set as default. If ``True`` for allow,
``False`` for deny and ``None`` for normal.
guild_id : Optional[int]
Specify to set the default rule for a specific guild.
When ``None``, this will set the global default rule.
"""
if guild_id:
self.requires.set_default_guild_rule(guild_id, PermState.from_bool(rule))
else:
self.requires.default_global_rule = PermState.from_bool(rule)
class Command(CogCommandMixin, commands.Command):
"""Command class for Red.
This should not be created directly, and instead via the decorator.
This class inherits from `discord.ext.commands.Command`.
This class inherits from `discord.ext.commands.Command`. The
attributes listed below are simply additions to the ones listed
with that class.
Attributes
----------
checks : List[`coroutine function`]
A list of check predicates which cannot be overridden, unlike
`Requires.checks`.
translator : Translator
A translator for this command's help docstring.
"""
def __init__(self, *args, **kwargs):
self._help_override = kwargs.pop("help_override", None)
super().__init__(*args, **kwargs)
self._help_override = kwargs.pop("help_override", None)
self.translator = kwargs.pop("i18n", None)
@property
@@ -59,11 +144,10 @@ class Command(commands.Command):
pass
@property
def parents(self):
"""
Returns all parent commands of this command.
def parents(self) -> List["Group"]:
"""List[commands.Group] : Returns all parent commands of this command.
This is a list, sorted by the length of :attr:`.qualified_name` from highest to lowest.
This is sorted by the length of :attr:`.qualified_name` from highest to lowest.
If the command has no parents, this will be an empty list.
"""
cmd = self.parent
@@ -73,6 +157,76 @@ class Command(commands.Command):
cmd = cmd.parent
return sorted(entries, key=lambda x: len(x.qualified_name), reverse=True)
# noinspection PyMethodOverriding
async def can_run(
self,
ctx: "Context",
*,
check_all_parents: bool = False,
change_permission_state: bool = False,
) -> bool:
"""Check if this command can be run in the given context.
This function first checks if the command can be run using
discord.py's method `discord.ext.commands.Command.can_run`,
then will return the result of `Requires.verify`.
Keyword Arguments
-----------------
check_all_parents : bool
If ``True``, this will check permissions for all of this
command's parents and its cog as well as the command
itself. Defaults to ``False``.
change_permission_state : bool
Whether or not the permission state should be changed as
a result of this call. For most cases this should be
``False``. Defaults to ``False``.
"""
ret = await super().can_run(ctx)
if ret is False:
return False
# This is so contexts invoking other commands can be checked with
# this command as well
original_command = ctx.command
original_state = ctx.permission_state
ctx.command = self
if check_all_parents is True:
# Since we're starting from the beginning, we should reset the state to normal
ctx.permission_state = PermState.NORMAL
for parent in reversed(self.parents):
try:
result = await parent.can_run(ctx, change_permission_state=True)
except commands.CommandError:
result = False
if result is False:
return False
if self.parent is None and self.instance is not None:
# For top-level commands, we need to check the cog's requires too
ret = await self.instance.requires.verify(ctx)
if ret is False:
return False
try:
return await self.requires.verify(ctx)
finally:
ctx.command = original_command
if not change_permission_state:
ctx.permission_state = original_state
async def _verify_checks(self, ctx):
if not self.enabled:
raise commands.DisabledCommand(f"{self.name} command is disabled")
if not (await self.can_run(ctx, change_permission_state=True)):
raise commands.CheckFailure(
f"The check functions for command {self.qualified_name} failed."
)
async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter
):
@@ -106,6 +260,38 @@ class Command(commands.Command):
# We should expose anything which might be a bug in the converter
raise exc
async def can_see(self, ctx: "Context"):
"""Check if this command is visible in the given context.
In short, this will verify whether the user can run the
command, and also whether the command is hidden or not.
Parameters
----------
ctx : `Context`
The invocation context to check with.
Returns
-------
bool
``True`` if this command is visible in the given context.
"""
for cmd in (self, *self.parents):
if cmd.hidden:
return False
try:
can_run = await self.can_run(
ctx, check_all_parents=True, change_permission_state=False
)
except commands.CheckFailure:
return False
else:
if can_run is False:
return False
return True
def disable_in(self, guild: discord.Guild) -> bool:
"""Disable this command in the given guild.
@@ -149,8 +335,32 @@ class Command(commands.Command):
else:
return True
def allow_for(self, model_id: int, guild_id: int) -> None:
super().allow_for(model_id, guild_id=guild_id)
parents = self.parents
if self.instance is not None:
parents.append(self.instance)
for parent in parents:
cur_rule = parent.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule is PermState.NORMAL:
parent.requires.set_rule(model_id, PermState.PASSIVE_ALLOW, guild_id=guild_id)
elif cur_rule is PermState.ACTIVE_DENY:
parent.requires.set_rule(model_id, PermState.CAUTIOUS_ALLOW, guild_id=guild_id)
class GroupMixin(commands.GroupMixin):
def clear_rule_for(self, model_id: int, guild_id: int) -> Tuple[PermState, PermState]:
old_rule, new_rule = super().clear_rule_for(model_id, guild_id=guild_id)
if old_rule is PermState.ACTIVE_ALLOW:
parents = self.parents
if self.instance is not None:
parents.append(self.instance)
for parent in parents:
should_continue = parent.reevaluate_rules_for(model_id, guild_id=guild_id)[1]
if not should_continue:
break
return old_rule, new_rule
class GroupMixin(discord.ext.commands.GroupMixin):
"""Mixin for `Group` and `Red` classes.
This class inherits from :class:`discord.ext.commands.GroupMixin`.
@@ -181,7 +391,34 @@ class GroupMixin(commands.GroupMixin):
return decorator
class Group(GroupMixin, Command, commands.Group):
class CogGroupMixin:
requires: Requires
all_commands: Dict[str, Command]
def reevaluate_rules_for(
self, model_id: int, guild_id: Optional[int]
) -> Tuple[PermState, bool]:
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
if cur_rule in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
# These three states are unaffected by subcommand rules
return cur_rule, False
else:
# Remaining states can be changed if there exists no actively-allowed
# subcommand (this includes subcommands multiple levels below)
if any(
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermState.ALLOWED_STATES
for cmd in self.all_commands.values()
):
return cur_rule, False
elif cur_rule is PermState.PASSIVE_ALLOW:
self.requires.set_rule(model_id, PermState.NORMAL, guild_id=guild_id)
return PermState.NORMAL, True
elif cur_rule is PermState.CAUTIOUS_ALLOW:
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
return PermState.ACTIVE_DENY, True
class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
"""Group command class for Red.
This class inherits from `Command`, with :class:`GroupMixin` and
@@ -192,7 +429,7 @@ class Group(GroupMixin, Command, commands.Group):
self.autohelp = kwargs.pop("autohelp", True)
super().__init__(*args, **kwargs)
async def invoke(self, ctx):
async def invoke(self, ctx: "Context"):
view = ctx.view
previous = view.index
view.skip_ws()
@@ -217,7 +454,12 @@ class Group(GroupMixin, Command, commands.Group):
await super().invoke(ctx)
# decorators
class Cog(CogCommandMixin, CogGroupMixin):
"""Base class for a cog."""
@property
def all_commands(self) -> Dict[str, Command]:
return {cmd.name: cmd for cmd in self.__dict__.values() if isinstance(cmd, Command)}
def command(name=None, cls=Command, **attrs):

View File

@@ -1,11 +1,12 @@
import asyncio
from typing import Iterable, List
import discord
from discord.ext import commands
from redbot.core.utils.chat_formatting import box
from redbot.core.utils import common_filters
from .requires import PermState
from ..utils.chat_formatting import box
from ..utils.predicates import MessagePredicate
from ..utils import common_filters
TICK = "\N{WHITE HEAVY CHECK MARK}"
@@ -20,6 +21,10 @@ class Context(commands.Context):
This class inherits from `discord.ext.commands.Context`.
"""
def __init__(self, **attrs):
super().__init__(**attrs)
self.permission_state: PermState = PermState.NORMAL
async def send(self, content=None, **kwargs):
"""Sends a message to the destination with the content given.
@@ -136,10 +141,6 @@ class Context(commands.Context):
messages = tuple(messages)
ret = []
more_check = lambda m: (
m.author == self.author and m.channel == self.channel and m.content.lower() == "more"
)
for idx, page in enumerate(messages, 1):
if box_lang is None:
msg = await self.send(page)
@@ -160,7 +161,11 @@ class Context(commands.Context):
"".format(is_are, n_remaining, plural)
)
try:
resp = await self.bot.wait_for("message", check=more_check, timeout=timeout)
resp = await self.bot.wait_for(
"message",
check=MessagePredicate.lower_equal_to("more", self),
timeout=timeout,
)
except asyncio.TimeoutError:
await query.delete()
break
@@ -170,7 +175,7 @@ class Context(commands.Context):
except (discord.HTTPException, AttributeError):
# In case the bot can't delete other users' messages,
# or is not a bot account
# or chanel is a DM
# or channel is a DM
await query.delete()
return ret
@@ -237,3 +242,20 @@ class Context(commands.Context):
)
else:
return await self.send(message)
@property
def clean_prefix(self) -> str:
"""str: The command prefix, but a mention prefix is displayed nicer."""
me = self.me
return self.prefix.replace(me.mention, f"@{me.display_name}")
@property
def me(self) -> discord.abc.User:
"""discord.abc.User: The bot member or user object.
If the context is DM, this will be a `discord.User` object.
"""
if self.guild is not None:
return self.guild.me
else:
return self.bot.user

View File

@@ -0,0 +1,41 @@
import re
from typing import TYPE_CHECKING
import discord
from . import BadArgument
from ..i18n import Translator
if TYPE_CHECKING:
from .context import Context
__all__ = ["GuildConverter"]
_ = Translator("commands.converter", __file__)
ID_REGEX = re.compile(r"([0-9]{15,21})")
class GuildConverter(discord.Guild):
"""Converts to a `discord.Guild` object.
The lookup strategy is as follows (in order):
1. Lookup by ID.
2. Lookup by name.
"""
@classmethod
async def convert(cls, ctx: "Context", argument: str) -> discord.Guild:
match = ID_REGEX.fullmatch(argument)
if match is None:
ret = discord.utils.get(ctx.bot.guilds, name=argument)
else:
guild_id = int(match.group(1))
ret = ctx.bot.get_guild(guild_id)
if ret is None:
raise BadArgument(_('Server "{name}" not found.').format(name=argument))
return ret

View File

@@ -1,8 +1,9 @@
"""Errors module for the commands package."""
import inspect
import discord
from discord.ext import commands
__all__ = ["ConversionFailure"]
__all__ = ["ConversionFailure", "BotMissingPermissions"]
class ConversionFailure(commands.BadArgument):
@@ -13,3 +14,11 @@ class ConversionFailure(commands.BadArgument):
self.argument = argument
self.param = param
super().__init__(*args)
class BotMissingPermissions(commands.CheckFailure):
"""Raised if the bot is missing permissions required to run a command."""
def __init__(self, missing: discord.Permissions, *args):
self.missing: discord.Permissions = missing
super().__init__(*args)

View File

@@ -0,0 +1,708 @@
"""
commands.requires
=================
This module manages the logic of resolving command permissions and
requirements. This includes rules which override those requirements,
as well as custom checks which can be overriden, and some special
checks like bot permissions checks.
"""
import asyncio
import enum
from typing import (
Union,
Optional,
List,
Callable,
Awaitable,
Dict,
Any,
TYPE_CHECKING,
TypeVar,
Tuple,
)
import discord
from .converter import GuildConverter
from .errors import BotMissingPermissions
if TYPE_CHECKING:
from .commands import Command
from .context import Context
_CommandOrCoro = TypeVar("_CommandOrCoro", Callable[..., Awaitable[Any]], Command)
__all__ = [
"CheckPredicate",
"DM_PERMS",
"GlobalPermissionModel",
"GuildPermissionModel",
"PermissionModel",
"PrivilegeLevel",
"PermState",
"Requires",
"permissions_check",
"bot_has_permissions",
"has_permissions",
"is_owner",
"guildowner",
"guildowner_or_permissions",
"admin",
"admin_or_permissions",
"mod",
"mod_or_permissions",
]
_T = TypeVar("_T")
GlobalPermissionModel = Union[
discord.User,
discord.VoiceChannel,
discord.TextChannel,
discord.CategoryChannel,
discord.Role,
GuildConverter, # Unfortunately this will have to do for now
]
GuildPermissionModel = Union[
discord.Member,
discord.VoiceChannel,
discord.TextChannel,
discord.CategoryChannel,
discord.Role,
GuildConverter,
]
PermissionModel = Union[GlobalPermissionModel, GuildPermissionModel]
CheckPredicate = Callable[["Context"], Union[Optional[bool], Awaitable[Optional[bool]]]]
# Here we are trying to model DM permissions as closely as possible. The only
# discrepancy I've found is that users can pin messages, but they cannot delete them.
# This means manage_messages is only half True, so it's left as False.
# This is also the same as the permissions returned when `permissions_for` is used in DM.
DM_PERMS = discord.Permissions.none()
DM_PERMS.update(
add_reactions=True,
attach_files=True,
embed_links=True,
external_emojis=True,
mention_everyone=True,
read_message_history=True,
read_messages=True,
send_messages=True,
)
class PrivilegeLevel(enum.IntEnum):
"""Enumeration for special privileges."""
NONE = enum.auto()
"""No special privilege level."""
MOD = enum.auto()
"""User has the mod role."""
ADMIN = enum.auto()
"""User has the admin role."""
GUILD_OWNER = enum.auto()
"""User is the guild level."""
BOT_OWNER = enum.auto()
"""User is a bot owner."""
@classmethod
async def from_ctx(cls, ctx: "Context") -> "PrivilegeLevel":
"""Get a command author's PrivilegeLevel based on context."""
if await ctx.bot.is_owner(ctx.author):
return cls.BOT_OWNER
elif ctx.guild is None:
return cls.NONE
elif ctx.author == ctx.guild.owner:
return cls.GUILD_OWNER
# The following is simply an optimised way to check if the user has the
# admin or mod role.
guild_settings = ctx.bot.db.guild(ctx.guild)
admin_role_id = await guild_settings.admin_role()
mod_role_id = await guild_settings.mod_role()
is_mod = False
for role in ctx.author.roles:
if role.id == admin_role_id:
return cls.ADMIN
elif role.id == mod_role_id:
is_mod = True
if is_mod:
return cls.MOD
return cls.NONE
def __repr__(self) -> str:
return f"<{self.__class__.__name__}.{self.name}>"
class PermState(enum.Enum):
"""Enumeration for permission states used by rules."""
ACTIVE_ALLOW = enum.auto()
"""This command has been actively allowed, default user checks
should be ignored.
"""
NORMAL = enum.auto()
"""No overrides have been set for this command, make determination
from default user checks.
"""
PASSIVE_ALLOW = enum.auto()
"""There exists a subcommand in the `ACTIVE_ALLOW` state, continue
down the subcommand tree until we either find it or realise we're
on the wrong branch.
"""
CAUTIOUS_ALLOW = enum.auto()
"""This command has been actively denied, but there exists a
subcommand in the `ACTIVE_ALLOW` state. This occurs when
`PASSIVE_ALLOW` and `ACTIVE_DENY` are combined.
"""
ACTIVE_DENY = enum.auto()
"""This command has been actively denied, terminate the command
chain.
"""
def transition_to(
self, next_state: "PermState"
) -> Tuple[Optional[bool], Union["PermState", Dict[bool, "PermState"]]]:
return self.TRANSITIONS[self][next_state]
@classmethod
def from_bool(cls, value: Optional[bool]) -> "PermState":
"""Get a PermState from a bool or ``NoneType``."""
if value is True:
return cls.ACTIVE_ALLOW
elif value is False:
return cls.ACTIVE_DENY
else:
return cls.NORMAL
def __repr__(self) -> str:
return f"<{self.__class__.__name__}.{self.name}>"
# Here we're defining how we transition between states.
# The dict is in the form:
# previous state -> this state -> Tuple[override, next state]
# "override" is a bool describing whether or not the command should be
# invoked. It can be None, in which case the default permission checks
# will be used instead.
# There is also one case where the "next state" is dependent on the
# result of the default permission checks - the transition from NORMAL
# to PASSIVE_ALLOW. In this case "next state" is a dict mapping the
# permission check results to the actual next state.
PermState.TRANSITIONS = {
PermState.ACTIVE_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (True, PermState.ACTIVE_ALLOW),
PermState.PASSIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.NORMAL: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (None, PermState.NORMAL),
PermState.PASSIVE_ALLOW: (True, {True: PermState.NORMAL, False: PermState.PASSIVE_ALLOW}),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.PASSIVE_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (False, PermState.NORMAL),
PermState.PASSIVE_ALLOW: (True, PermState.PASSIVE_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.CAUTIOUS_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (False, PermState.ACTIVE_DENY),
PermState.PASSIVE_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.CAUTIOUS_ALLOW: (True, PermState.CAUTIOUS_ALLOW),
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
PermState.ACTIVE_DENY: { # We can only start from ACTIVE_DENY if it is set on a cog.
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW), # Should never happen
PermState.NORMAL: (False, PermState.ACTIVE_DENY),
PermState.PASSIVE_ALLOW: (False, PermState.ACTIVE_DENY), # Should never happen
PermState.CAUTIOUS_ALLOW: (False, PermState.ACTIVE_DENY), # Should never happen
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
}
PermState.ALLOWED_STATES = (
PermState.ACTIVE_ALLOW,
PermState.PASSIVE_ALLOW,
PermState.CAUTIOUS_ALLOW,
)
class Requires:
"""This class describes the requirements for executing a specific command.
The permissions described include both bot permissions and user
permissions.
Attributes
----------
checks : List[Callable[[Context], Union[bool, Awaitable[bool]]]]
A list of checks which can be overridden by rules. Use
`Command.checks` if you would like them to never be overridden.
privilege_level : PrivilegeLevel
The required privilege level (bot owner, admin, etc.) for users
to execute the command. Can be ``None``, in which case the
`user_perms` will be used exclusively, otherwise, for levels
other than bot owner, the user can still run the command if
they have the required `user_perms`.
user_perms : Optional[discord.Permissions]
The required permissions for users to execute the command. Can
be ``None``, in which case the `privilege_level` will be used
exclusively, otherwise, it will pass whether the user has the
required `privilege_level` _or_ `user_perms`.
bot_perms : discord.Permissions
The required bot permissions for a command to be executed. This
is not overrideable by other conditions.
"""
def __init__(
self,
privilege_level: Optional[PrivilegeLevel],
user_perms: Union[Dict[str, bool], discord.Permissions, None],
bot_perms: Union[Dict[str, bool], discord.Permissions],
checks: List[CheckPredicate],
):
self.checks: List[CheckPredicate] = checks
self.privilege_level: Optional[PrivilegeLevel] = privilege_level
if isinstance(user_perms, dict):
self.user_perms: Optional[discord.Permissions] = discord.Permissions.none()
_validate_perms_dict(user_perms)
self.user_perms.update(**user_perms)
else:
self.user_perms = user_perms
if isinstance(bot_perms, dict):
self.bot_perms: discord.Permissions = discord.Permissions.none()
_validate_perms_dict(bot_perms)
self.bot_perms.update(**bot_perms)
else:
self.bot_perms = bot_perms
self.default_global_rule: PermState = PermState.NORMAL
self._global_rules: _IntKeyDict[PermState] = _IntKeyDict()
self._default_guild_rules: _IntKeyDict[PermState] = _IntKeyDict()
self._guild_rules: _IntKeyDict[_IntKeyDict[PermState]] = _IntKeyDict()
@staticmethod
def get_decorator(
privilege_level: Optional[PrivilegeLevel], user_perms: Dict[str, bool]
) -> Callable[["_CommandOrCoro"], "_CommandOrCoro"]:
if not user_perms:
user_perms = None
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func):
func.__requires_privilege_level__ = privilege_level
func.__requires_user_perms__ = user_perms
else:
func.requires.privilege_level = privilege_level
if user_perms is None:
func.requires.user_perms = None
else:
_validate_perms_dict(user_perms)
func.requires.user_perms.update(**user_perms)
return func
return decorator
def get_rule(self, model: Union[int, PermissionModel], guild_id: int) -> PermState:
"""Get the rule for a particular model.
Parameters
----------
model : PermissionModel
The model to get the rule for.
guild_id : int
The ID of the guild for the rule's scope. Set to ``0``
for a global rule.
Returns
-------
PermState
The state for this rule. See the `PermState` class
for an explanation.
"""
if not isinstance(model, int):
model = model.id
if guild_id:
rules = self._guild_rules.get(guild_id, _IntKeyDict())
else:
rules = self._global_rules
return rules.get(model, PermState.NORMAL)
def set_rule(self, model_id: int, rule: PermState, guild_id: int) -> None:
"""Set the rule for a particular model.
Parameters
----------
model_id : PermissionModel
The model to add a rule for.
rule : PermState
Which state this rule should be set as. See the `PermState`
class for an explanation.
guild_id : int
The ID of the guild for the rule's scope. Set to ``0``
for a global rule.
"""
if guild_id:
rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
else:
rules = self._global_rules
if rule is PermState.NORMAL:
rules.pop(model_id, None)
else:
rules[model_id] = rule
def clear_all_rules(self, guild_id: int) -> None:
"""Clear all rules of a particular scope.
Parameters
----------
guild_id : int
The guild ID to clear rules for. If ``0``, this will
clear all global rules and leave all guild rules
untouched.
"""
if guild_id:
rules = self._guild_rules.setdefault(guild_id, _IntKeyDict())
else:
rules = self._global_rules
rules.clear()
def get_default_guild_rule(self, guild_id: int) -> PermState:
"""Get the default rule for a guild."""
return self._default_guild_rules.get(guild_id, PermState.NORMAL)
def set_default_guild_rule(self, guild_id: int, rule: PermState) -> None:
"""Set the default rule for a guild."""
self._default_guild_rules[guild_id] = rule
async def verify(self, ctx: "Context") -> bool:
"""Check if the given context passes the requirements.
This will check the bot permissions, overrides, user permissions
and privilege level.
Parameters
----------
ctx : "Context"
The invkokation context to check with.
Returns
-------
bool
``True`` if the context passes the requirements.
Raises
------
BotMissingPermissions
If the bot is missing required permissions to run the
command.
CommandError
Propogated from any permissions checks.
"""
await self._verify_bot(ctx)
# Owner should never be locked out of commands for user permissions.
if await ctx.bot.is_owner(ctx.author):
return True
# Owner-only commands are non-overrideable, and we already checked for owner.
if self.privilege_level is PrivilegeLevel.BOT_OWNER:
return False
hook_result = await ctx.bot.verify_permissions_hooks(ctx)
if hook_result is not None:
return hook_result
return await self._transition_state(ctx)
async def _verify_bot(self, ctx: "Context") -> None:
if ctx.guild is None:
bot_user = ctx.bot.user
else:
bot_user = ctx.guild.me
bot_perms = ctx.channel.permissions_for(bot_user)
if not (bot_perms.administrator or bot_perms >= self.bot_perms):
raise BotMissingPermissions(missing=self._missing_perms(self.bot_perms, bot_perms))
async def _transition_state(self, ctx: "Context") -> bool:
prev_state = ctx.permission_state
cur_state = self._get_rule_from_ctx(ctx)
should_invoke, next_state = prev_state.transition_to(cur_state)
if should_invoke is None:
# NORMAL invokation, we simply follow standard procedure
should_invoke = await self._verify_user(ctx)
elif isinstance(next_state, dict):
# NORMAL to PASSIVE_ALLOW; should we proceed as normal or transition?
# We must check what would happen normally, if no explicit rules were set.
default_rule = PermState.NORMAL
if ctx.guild is not None:
default_rule = self.get_default_guild_rule(guild_id=ctx.guild.id)
if default_rule is PermState.NORMAL:
default_rule = self.default_global_rule
if default_rule == PermState.ACTIVE_DENY:
would_invoke = False
elif default_rule == PermState.ACTIVE_ALLOW:
would_invoke = True
else:
would_invoke = await self._verify_user(ctx)
next_state = next_state[would_invoke]
ctx.permission_state = next_state
return should_invoke
async def _verify_user(self, ctx: "Context") -> bool:
checks_pass = await self._verify_checks(ctx)
if checks_pass is False:
return False
if self.user_perms is not None:
user_perms = ctx.channel.permissions_for(ctx.author)
if user_perms.administrator or user_perms >= self.user_perms:
return True
if self.privilege_level is not None:
privilege_level = await PrivilegeLevel.from_ctx(ctx)
if privilege_level >= self.privilege_level:
return True
return False
def _get_rule_from_ctx(self, ctx: "Context") -> PermState:
author = ctx.author
guild = ctx.guild
if ctx.guild is None:
# We only check the user for DM channels
rule = self._global_rules.get(author.id)
if rule is not None:
return rule
return self.default_global_rule
rules_chain = [self._global_rules]
guild_rules = self._guild_rules.get(ctx.guild.id)
if guild_rules:
rules_chain.append(guild_rules)
channels = []
if author.voice is not None:
channels.append(author.voice.channel)
channels.append(ctx.channel)
category = ctx.channel.category
if category is not None:
channels.append(category)
model_chain = [author, *channels, *author.roles, guild]
for rules in rules_chain:
for model in model_chain:
rule = rules.get(model.id)
if rule is not None:
return rule
del model_chain[-1] # We don't check for the guild in guild rules
default_rule = self.get_default_guild_rule(guild.id)
if default_rule is PermState.NORMAL:
default_rule = self.default_global_rule
return default_rule
async def _verify_checks(self, ctx: "Context") -> bool:
if not self.checks:
return True
return await discord.utils.async_all(check(ctx) for check in self.checks)
@staticmethod
def _get_perms_for(ctx: "Context", user: discord.abc.User) -> discord.Permissions:
if ctx.guild is None:
return DM_PERMS
else:
return ctx.channel.permissions_for(user)
@classmethod
def _get_bot_perms(cls, ctx: "Context") -> discord.Permissions:
return cls._get_perms_for(ctx, ctx.guild.me if ctx.guild else ctx.bot.user)
@staticmethod
def _missing_perms(
required: discord.Permissions, actual: discord.Permissions
) -> discord.Permissions:
# Explained in set theory terms:
# Assuming R is the set of required permissions, and A is
# the set of the user's permissions, the set of missing
# permissions will be equal to R \ A, i.e. the relative
# complement/difference of A with respect to R.
relative_complement = required.value & ~actual.value
return discord.Permissions(relative_complement)
@staticmethod
def _member_as_user(member: discord.abc.User) -> discord.User:
if isinstance(member, discord.Member):
# noinspection PyProtectedMember
return member._user
return member
def __repr__(self) -> str:
return (
f"<Requires privilege_level={self.privilege_level!r} user_perms={self.user_perms!r} "
f"bot_perms={self.bot_perms!r}>"
)
# check decorators
def permissions_check(predicate: CheckPredicate):
"""An overwriteable version of `discord.ext.commands.check`.
This has the same behaviour as `discord.ext.commands.check`,
however this check can be ignored if the command is allowed
through a permissions cog.
"""
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if hasattr(func, "requires"):
func.requires.checks.append(predicate)
else:
if not hasattr(func, "__requires_checks__"):
func.__requires_checks__ = []
# noinspection PyUnresolvedReferences
func.__requires_checks__.append(predicate)
return func
return decorator
def bot_has_permissions(**perms: bool):
"""Complain if the bot is missing permissions.
If the user tries to run the command, but the bot is missing the
permissions, it will send a message describing which permissions
are missing.
This check cannot be overridden by rules.
"""
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func):
func.__requires_bot_perms__ = perms
else:
_validate_perms_dict(perms)
func.requires.bot_perms.update(**perms)
return func
return decorator
def has_permissions(**perms: bool):
"""Restrict the command to users with these permissions.
This check can be overridden by rules.
"""
if perms is None:
raise TypeError("Must provide at least one keyword argument to has_permissions")
return Requires.get_decorator(None, perms)
def is_owner():
"""Restrict the command to bot owners.
This check cannot be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.BOT_OWNER, {})
def guildowner_or_permissions(**perms: bool):
"""Restrict the command to the guild owner or users with these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.GUILD_OWNER, perms)
def guildowner():
"""Restrict the command to the guild owner.
This check can be overridden by rules.
"""
return guildowner_or_permissions()
def admin_or_permissions(**perms: bool):
"""Restrict the command to users with the admin role or these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.ADMIN, perms)
def admin():
"""Restrict the command to users with the admin role.
This check can be overridden by rules.
"""
return admin_or_permissions()
def mod_or_permissions(**perms: bool):
"""Restrict the command to users with the mod role or these permissions.
This check can be overridden by rules.
"""
return Requires.get_decorator(PrivilegeLevel.MOD, perms)
def mod():
"""Restrict the command to users with the mod role.
This check can be overridden by rules.
"""
return mod_or_permissions()
class _IntKeyDict(Dict[int, _T]):
"""Dict subclass which throws KeyError when a non-int key is used."""
def __getitem__(self, key: Any) -> _T:
if not isinstance(key, int):
raise TypeError("Keys must be of type `int`")
return super().__getitem__(key)
def __setitem__(self, key: Any, value: _T) -> None:
if not isinstance(key, int):
raise TypeError("Keys must be of type `int`")
return super().__setitem__(key, value)
def _validate_perms_dict(perms: Dict[str, bool]) -> None:
for perm, value in perms.items():
try:
attr = getattr(discord.Permissions, perm)
except AttributeError:
attr = None
if attr is None or not isinstance(attr, property):
# We reject invalid permissions
raise TypeError(f"Unknown permission name '{perm}'")
if value is not True:
# We reject any permission not specified as 'True', since this is the only value which
# makes practical sense.
raise TypeError(f"Permission {perm} may only be specified as 'True', not {value}")

View File

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

View File

@@ -13,17 +13,22 @@ from collections import namedtuple
from pathlib import Path
from random import SystemRandom
from string import ascii_letters, digits
from distutils.version import StrictVersion
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict
import aiohttp
import discord
import pkg_resources
from redbot.core import __version__
from redbot.core import checks
from redbot.core import i18n
from redbot.core import commands
from redbot.core import (
__version__,
version_info as red_version_info,
VersionInfo,
checks,
commands,
errors,
i18n,
)
from .utils.predicates import MessagePredicate
from .utils.chat_formatting import pagify, box, inline
if TYPE_CHECKING:
@@ -55,7 +60,9 @@ class CoreLogic:
self.bot.register_rpc_handler(self._version_info)
self.bot.register_rpc_handler(self._invite_url)
async def _load(self, cog_names: list):
async def _load(
self, cog_names: Iterable[str]
) -> Tuple[List[str], List[str], List[str], List[str]]:
"""
Loads cogs by name.
Parameters
@@ -65,11 +72,12 @@ class CoreLogic:
Returns
-------
tuple
3 element tuple of loaded, failed, and not found cogs.
4-tuple of loaded, failed, not found and already loaded cogs.
"""
failed_packages = []
loaded_packages = []
notfound_packages = []
alreadyloaded_packages = []
bot = self.bot
@@ -94,6 +102,8 @@ class CoreLogic:
try:
self._cleanup_and_refresh_modules(spec.name)
await bot.load_extension(spec)
except errors.PackageAlreadyLoaded:
alreadyloaded_packages.append(name)
except Exception as e:
log.exception("Package loading failed", exc_info=e)
@@ -105,9 +115,10 @@ class CoreLogic:
await bot.add_loaded_package(name)
loaded_packages.append(name)
return loaded_packages, failed_packages, notfound_packages
return loaded_packages, failed_packages, notfound_packages, alreadyloaded_packages
def _cleanup_and_refresh_modules(self, module_name: str):
@staticmethod
def _cleanup_and_refresh_modules(module_name: str) -> None:
"""Interally reloads modules so that changes are detected"""
splitted = module_name.split(".")
@@ -119,6 +130,7 @@ class CoreLogic:
else:
importlib._bootstrap._exec(lib.__spec__, lib)
# noinspection PyTypeChecker
modules = itertools.accumulate(splitted, "{}.{}".format)
for m in modules:
maybe_reload(m)
@@ -127,7 +139,10 @@ class CoreLogic:
for child_name, lib in children.items():
importlib._bootstrap._exec(lib.__spec__, lib)
def _get_package_strings(self, packages: list, fmt: str, other: tuple = None):
@staticmethod
def _get_package_strings(
packages: List[str], fmt: str, other: Optional[Tuple[str, ...]] = None
) -> str:
"""
Gets the strings needed for the load, unload and reload commands
"""
@@ -143,7 +158,7 @@ class CoreLogic:
final_string = fmt.format(**form)
return final_string
async def _unload(self, cog_names: list):
async def _unload(self, cog_names: Iterable[str]) -> Tuple[List[str], List[str]]:
"""
Unloads cogs with the given names.
@@ -171,14 +186,16 @@ class CoreLogic:
return unloaded_packages, failed_packages
async def _reload(self, cog_names):
async def _reload(
self, cog_names: Sequence[str]
) -> Tuple[List[str], List[str], List[str], List[str]]:
await self._unload(cog_names)
loaded, load_failed, not_found = await self._load(cog_names)
loaded, load_failed, not_found, already_loaded = await self._load(cog_names)
return loaded, load_failed, not_found
return loaded, load_failed, not_found, already_loaded
async def _name(self, name: str = None):
async def _name(self, name: Optional[str] = None) -> str:
"""
Gets or sets the bot's username.
@@ -197,7 +214,7 @@ class CoreLogic:
return self.bot.user.name
async def _prefixes(self, prefixes: list = None):
async def _prefixes(self, prefixes: Optional[Sequence[str]] = None) -> List[str]:
"""
Gets or sets the bot's global prefixes.
@@ -216,7 +233,8 @@ class CoreLogic:
await self.bot.db.prefix.set(prefixes)
return await self.bot.db.prefix()
async def _version_info(self):
@classmethod
async def _version_info(cls) -> Dict[str, str]:
"""
Version information for Red and discord.py
@@ -227,7 +245,7 @@ class CoreLogic:
"""
return {"redbot": __version__, "discordpy": discord.__version__}
async def _invite_url(self):
async def _invite_url(self) -> str:
"""
Generates the invite URL for the bot.
@@ -241,14 +259,11 @@ class CoreLogic:
@i18n.cog_i18n(_)
class Core(CoreLogic):
class Core(commands.Cog, CoreLogic):
"""Commands related to core functions"""
def __init__(self, bot):
super().__init__(bot)
@commands.command(hidden=True)
async def ping(self, ctx):
async def ping(self, ctx: commands.Context):
"""Pong."""
await ctx.send("Pong.")
@@ -273,7 +288,7 @@ class Core(CoreLogic):
async with aiohttp.ClientSession() as session:
async with session.get("{}/json".format(red_pypi)) as r:
data = await r.json()
outdated = StrictVersion(data["info"]["version"]) > StrictVersion(__version__)
outdated = VersionInfo.from_str(data["info"]["version"]) > red_version_info
about = (
"This is an instance of [Red, an open source Discord bot]({}) "
"created by [Twentysix]({}) and [improved by many]({}).\n\n"
@@ -309,7 +324,7 @@ class Core(CoreLogic):
passed = self.get_bot_uptime()
await ctx.send("Been up for: **{}** (since {} UTC)".format(passed, since))
def get_bot_uptime(self, *, brief=False):
def get_bot_uptime(self, *, brief: bool = False):
# Courtesy of Danny
now = datetime.datetime.utcnow()
delta = now - self.bot.uptime
@@ -412,7 +427,7 @@ class Core(CoreLogic):
@commands.command()
@checks.is_owner()
async def traceback(self, ctx, public: bool = False):
async def traceback(self, ctx: commands.Context, public: bool = False):
"""Sends to the owner the last command exception that has occurred
If public (yes is specified), it will be sent to the chat instead"""
@@ -422,104 +437,97 @@ class Core(CoreLogic):
destination = ctx.channel
if self.bot._last_exception:
for page in pagify(self.bot._last_exception):
for page in pagify(self.bot._last_exception, shorten_by=10):
await destination.send(box(page, lang="py"))
else:
await ctx.send("No exception has occurred yet")
@commands.command()
@checks.is_owner()
async def invite(self, ctx):
async def invite(self, ctx: commands.Context):
"""Show's Red's invite url"""
await ctx.author.send(await self._invite_url())
@commands.command()
@commands.guild_only()
@checks.is_owner()
async def leave(self, ctx):
async def leave(self, ctx: commands.Context):
"""Leaves server"""
author = ctx.author
guild = ctx.guild
await ctx.send("Are you sure you want me to leave this server? (y/n)")
await ctx.send("Are you sure you want me to leave this server? Type yes to confirm.")
def conf_check(m):
return m.author == author
response = await self.bot.wait_for("message", check=conf_check)
if response.content.lower().strip() == "yes":
await ctx.send("Alright. Bye :wave:")
log.debug("Leaving '{}'".format(guild.name))
await guild.leave()
pred = MessagePredicate.yes_or_no(ctx)
try:
await self.bot.wait_for("message", check=pred)
except asyncio.TimeoutError:
await ctx.send("Response timed out.")
return
else:
if pred.result is True:
await ctx.send("Alright. Bye :wave:")
log.debug("Leaving guild '{}'".format(ctx.guild.name))
await ctx.guild.leave()
else:
await ctx.send("Alright, I'll stay then :)")
@commands.command()
@checks.is_owner()
async def servers(self, ctx):
async def servers(self, ctx: commands.Context):
"""Lists and allows to leave servers"""
owner = ctx.author
guilds = sorted(list(self.bot.guilds), key=lambda s: s.name.lower())
msg = ""
responses = []
for i, server in enumerate(guilds, 1):
msg += "{}: {}\n".format(i, server.name)
msg += "\nTo leave a server, just type its number."
responses.append(str(i))
for page in pagify(msg, ["\n"]):
await ctx.send(page)
def msg_check(m):
return m.author == owner
while msg is not None:
try:
msg = await self.bot.wait_for("message", check=msg_check, timeout=15)
except asyncio.TimeoutError:
await ctx.send("I guess not.")
break
try:
msg = int(msg.content) - 1
if msg < 0:
break
await self.leave_confirmation(guilds[msg], owner, ctx)
break
except (IndexError, ValueError, AttributeError):
pass
async def leave_confirmation(self, server, owner, ctx):
await ctx.send("Are you sure you want me to leave {}? (yes/no)".format(server.name))
def conf_check(m):
return m.author == owner
query = await ctx.send("To leave a server, just type its number.")
pred = MessagePredicate.contained_in(responses, ctx)
try:
msg = await self.bot.wait_for("message", check=conf_check, timeout=15)
if msg.content.lower().strip() in ("yes", "y"):
if server.owner == ctx.bot.user:
await ctx.send("I cannot leave a guild I am the owner of.")
return
await server.leave()
if server != ctx.guild:
await self.bot.wait_for("message", check=pred, timeout=15)
except asyncio.TimeoutError:
await query.delete()
else:
await self.leave_confirmation(guilds[pred.result], ctx)
async def leave_confirmation(self, guild, ctx):
if guild.owner.id == ctx.bot.user.id:
await ctx.send("I cannot leave a guild I am the owner of.")
return
await ctx.send("Are you sure you want me to leave {}? (yes/no)".format(guild.name))
pred = MessagePredicate.yes_or_no(ctx)
try:
await self.bot.wait_for("message", check=pred, timeout=15)
if pred.result is True:
await guild.leave()
if guild != ctx.guild:
await ctx.send("Done.")
else:
await ctx.send("Alright then.")
except asyncio.TimeoutError:
await ctx.send("I guess not.")
await ctx.send("Response timed out.")
@commands.command()
@checks.is_owner()
async def load(self, ctx, *, cog_name: str):
async def load(self, ctx: commands.Context, *cogs: str):
"""Loads packages"""
cog_names = [c.strip() for c in cog_name.split(" ")]
async with ctx.typing():
loaded, failed, not_found = await self._load(cog_names)
loaded, failed, not_found, already_loaded = await self._load(cogs)
if loaded:
fmt = "Loaded {packs}."
formed = self._get_package_strings(loaded, fmt)
await ctx.send(formed)
if already_loaded:
fmt = "The package{plural} {packs} {other} already loaded."
formed = self._get_package_strings(already_loaded, fmt, ("is", "are"))
await ctx.send(formed)
if failed:
fmt = (
"Failed to load package{plural} {packs}. Check your console or "
@@ -535,12 +543,9 @@ class Core(CoreLogic):
@commands.command()
@checks.is_owner()
async def unload(self, ctx, *, cog_name: str):
async def unload(self, ctx: commands.Context, *cogs: str):
"""Unloads packages"""
cog_names = [c.strip() for c in cog_name.split(" ")]
unloaded, failed = await self._unload(cog_names)
unloaded, failed = await self._unload(cogs)
if unloaded:
fmt = "Package{plural} {packs} {other} unloaded."
@@ -554,12 +559,10 @@ class Core(CoreLogic):
@commands.command(name="reload")
@checks.is_owner()
async def reload_(self, ctx, *, cog_name: str):
async def reload(self, ctx: commands.Context, *cogs: str):
"""Reloads packages"""
cog_names = [c.strip() for c in cog_name.split(" ")]
async with ctx.typing():
loaded, failed, not_found = await self._reload(cog_names)
loaded, failed, not_found, already_loaded = await self._reload(cogs)
if loaded:
fmt = "Package{plural} {packs} {other} reloaded."
@@ -578,41 +581,40 @@ class Core(CoreLogic):
@commands.command(name="shutdown")
@checks.is_owner()
async def _shutdown(self, ctx, silently: bool = False):
async def _shutdown(self, ctx: commands.Context, silently: bool = False):
"""Shuts down the bot"""
wave = "\N{WAVING HAND SIGN}"
skin = "\N{EMOJI MODIFIER FITZPATRICK TYPE-3}"
try: # We don't want missing perms to stop our shutdown
with contextlib.suppress(discord.HTTPException):
if not silently:
await ctx.send(_("Shutting down... ") + wave + skin)
except:
pass
await ctx.bot.shutdown()
@commands.command(name="restart")
@checks.is_owner()
async def _restart(self, ctx, silently: bool = False):
async def _restart(self, ctx: commands.Context, silently: bool = False):
"""Attempts to restart Red
Makes Red quit with exit code 26
The restart is not guaranteed: it must be dealt
with by the process manager in use"""
try:
with contextlib.suppress(discord.HTTPException):
if not silently:
await ctx.send(_("Restarting..."))
except:
pass
await ctx.bot.shutdown(restart=True)
@commands.group(name="set")
async def _set(self, ctx):
async def _set(self, ctx: commands.Context):
"""Changes Red's settings"""
if ctx.invoked_subcommand is None:
if ctx.guild:
admin_role_id = await ctx.bot.db.guild(ctx.guild).admin_role()
admin_role = discord.utils.get(ctx.guild.roles, id=admin_role_id) or "Not set"
mod_role_id = await ctx.bot.db.guild(ctx.guild).mod_role()
mod_role = discord.utils.get(ctx.guild.roles, id=mod_role_id) or "Not set"
guild = ctx.guild
admin_role = (
guild.get_role(await ctx.bot.db.guild(ctx.guild).admin_role()) or "Not set"
)
mod_role = (
guild.get_role(await ctx.bot.db.guild(ctx.guild).mod_role()) or "Not set"
)
prefixes = await ctx.bot.db.guild(ctx.guild).prefix()
guild_settings = f"Admin role: {admin_role}\nMod role: {mod_role}\n"
else:
@@ -634,7 +636,7 @@ class Core(CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def adminrole(self, ctx, *, role: discord.Role):
async def adminrole(self, ctx: commands.Context, *, role: discord.Role):
"""Sets the admin role for this server"""
await ctx.bot.db.guild(ctx.guild).admin_role.set(role.id)
await ctx.send(_("The admin role for this guild has been set."))
@@ -642,7 +644,7 @@ class Core(CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def modrole(self, ctx, *, role: discord.Role):
async def modrole(self, ctx: commands.Context, *, role: discord.Role):
"""Sets the mod role for this server"""
await ctx.bot.db.guild(ctx.guild).mod_role.set(role.id)
await ctx.send(_("The mod role for this guild has been set."))
@@ -650,7 +652,7 @@ class Core(CoreLogic):
@_set.command(aliases=["usebotcolor"])
@checks.guildowner()
@commands.guild_only()
async def usebotcolour(self, ctx):
async def usebotcolour(self, ctx: commands.Context):
"""
Toggle whether to use the bot owner-configured colour for embeds.
@@ -668,7 +670,7 @@ class Core(CoreLogic):
@_set.command()
@checks.guildowner()
@commands.guild_only()
async def serverfuzzy(self, ctx):
async def serverfuzzy(self, ctx: commands.Context):
"""
Toggle whether to enable fuzzy command search for the server.
@@ -684,7 +686,7 @@ class Core(CoreLogic):
@_set.command()
@checks.is_owner()
async def fuzzy(self, ctx):
async def fuzzy(self, ctx: commands.Context):
"""
Toggle whether to enable fuzzy command search in DMs.
@@ -700,7 +702,7 @@ class Core(CoreLogic):
@_set.command(aliases=["color"])
@checks.is_owner()
async def colour(self, ctx, *, colour: discord.Colour = None):
async def colour(self, ctx: commands.Context, *, colour: discord.Colour = None):
"""
Sets a default colour to be used for the bot's embeds.
@@ -718,7 +720,7 @@ class Core(CoreLogic):
@_set.command()
@checks.is_owner()
async def avatar(self, ctx, url: str):
async def avatar(self, ctx: commands.Context, url: str):
"""Sets Red's avatar"""
async with aiohttp.ClientSession() as session:
async with session.get(url) as r:
@@ -742,7 +744,7 @@ class Core(CoreLogic):
@_set.command(name="game")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _game(self, ctx, *, game: str = None):
async def _game(self, ctx: commands.Context, *, game: str = None):
"""Sets Red's playing status"""
if game:
@@ -756,7 +758,7 @@ class Core(CoreLogic):
@_set.command(name="listening")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _listening(self, ctx, *, listening: str = None):
async def _listening(self, ctx: commands.Context, *, listening: str = None):
"""Sets Red's listening status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
@@ -770,7 +772,7 @@ class Core(CoreLogic):
@_set.command(name="watching")
@checks.bot_in_a_guild()
@checks.is_owner()
async def _watching(self, ctx, *, watching: str = None):
async def _watching(self, ctx: commands.Context, *, watching: str = None):
"""Sets Red's watching status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
@@ -784,7 +786,7 @@ class Core(CoreLogic):
@_set.command()
@checks.bot_in_a_guild()
@checks.is_owner()
async def status(self, ctx, *, status: str):
async def status(self, ctx: commands.Context, *, status: str):
"""Sets Red's status
Available statuses:
@@ -813,7 +815,7 @@ class Core(CoreLogic):
@_set.command()
@checks.bot_in_a_guild()
@checks.is_owner()
async def stream(self, ctx, streamer=None, *, stream_title=None):
async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None):
"""Sets Red's streaming status
Leaving both streamer and stream_title empty will clear it."""
@@ -834,7 +836,7 @@ class Core(CoreLogic):
@_set.command(name="username", aliases=["name"])
@checks.is_owner()
async def _username(self, ctx, *, username: str):
async def _username(self, ctx: commands.Context, *, username: str):
"""Sets Red's username"""
try:
await self._name(name=username)
@@ -853,7 +855,7 @@ class Core(CoreLogic):
@_set.command(name="nickname")
@checks.admin()
@commands.guild_only()
async def _nickname(self, ctx, *, nickname: str = None):
async def _nickname(self, ctx: commands.Context, *, nickname: str = None):
"""Sets Red's nickname"""
try:
await ctx.guild.me.edit(nick=nickname)
@@ -864,7 +866,7 @@ class Core(CoreLogic):
@_set.command(aliases=["prefixes"])
@checks.is_owner()
async def prefix(self, ctx, *prefixes):
async def prefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's global prefix(es)"""
if not prefixes:
await ctx.send_help()
@@ -875,7 +877,7 @@ class Core(CoreLogic):
@_set.command(aliases=["serverprefixes"])
@checks.admin()
@commands.guild_only()
async def serverprefix(self, ctx, *prefixes):
async def serverprefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's server prefix(es)"""
if not prefixes:
await ctx.bot.db.guild(ctx.guild).prefix.set([])
@@ -887,12 +889,8 @@ class Core(CoreLogic):
@_set.command()
@commands.cooldown(1, 60 * 10, commands.BucketType.default)
async def owner(self, ctx):
async def owner(self, ctx: commands.Context):
"""Sets Red's main owner"""
def check(m):
return m.author == ctx.author and m.channel == ctx.channel
# According to the Python docs this is suitable for cryptographic use
random = SystemRandom()
length = random.randint(25, 35)
@@ -916,10 +914,14 @@ class Core(CoreLogic):
)
try:
message = await ctx.bot.wait_for("message", check=check, timeout=60)
message = await ctx.bot.wait_for(
"message", check=MessagePredicate.same_context(ctx), timeout=60
)
except asyncio.TimeoutError:
self.owner.reset_cooldown(ctx)
await ctx.send(_("The set owner request has timed out."))
await ctx.send(
_("The `{prefix}set owner` request has timed out.").format(prefix=ctx.prefix)
)
else:
if message.content.strip() == token:
self.owner.reset_cooldown(ctx)
@@ -931,7 +933,7 @@ class Core(CoreLogic):
@_set.command()
@checks.is_owner()
async def token(self, ctx, token: str):
async def token(self, ctx: commands.Context, token: str):
"""Change bot token."""
if not isinstance(ctx.channel, discord.DMChannel):
@@ -1070,13 +1072,13 @@ class Core(CoreLogic):
if not locale_list:
await ctx.send("No languages found.")
return
pages = pagify("\n".join(locale_list))
pages = pagify("\n".join(locale_list), shorten_by=26)
await ctx.send_interactive(pages, box_lang="Available Locales:")
@commands.command()
@checks.is_owner()
async def backup(self, ctx, backup_path: str = None):
async def backup(self, ctx: commands.Context, backup_path: str = None):
"""Creates a backup of all data for the instance."""
from redbot.core.data_manager import basic_config, instance_name
from redbot.core.drivers.red_json import JSON
@@ -1085,21 +1087,20 @@ class Core(CoreLogic):
if basic_config["STORAGE_TYPE"] == "MongoDB":
from redbot.core.drivers.red_mongo import Mongo
m = Mongo("Core", **basic_config["STORAGE_DETAILS"])
m = Mongo("Core", "0", **basic_config["STORAGE_DETAILS"])
db = m.db
collection_names = await db.collection_names(include_system_collections=False)
collection_names = await db.list_collection_names()
for c_name in collection_names:
if c_name == "Core":
c_data_path = data_dir / basic_config["CORE_PATH_APPEND"]
else:
c_data_path = data_dir / basic_config["COG_PATH_APPEND"]
output = {}
c_data_path = data_dir / basic_config["COG_PATH_APPEND"] / c_name
docs = await db[c_name].find().to_list(None)
for item in docs:
item_id = str(item.pop("_id"))
output[item_id] = item
target = JSON(c_name, data_path_override=c_data_path)
await target.jsonIO._threadsafe_save_json(output)
output = item
target = JSON(c_name, item_id, data_path_override=c_data_path)
await target.jsonIO._threadsafe_save_json(output)
backup_filename = "redv3-{}-{}.tar.gz".format(
instance_name, ctx.message.created_at.strftime("%Y-%m-%d %H-%M-%S")
)
@@ -1139,28 +1140,41 @@ class Core(CoreLogic):
tar.add(str(f), recursive=False)
print(str(backup_file))
await ctx.send(
_("A backup has been made of this instance. It is at {}.").format((backup_file))
_("A backup has been made of this instance. It is at {}.").format(backup_file)
)
if backup_file.stat().st_size > 8_000_000:
await ctx.send(_("This backup is to large to send via DM."))
return
await ctx.send(_("Would you like to receive a copy via DM? (y/n)"))
def same_author_check(m):
return m.author == ctx.author and m.channel == ctx.channel
pred = MessagePredicate.yes_or_no(ctx)
try:
msg = await ctx.bot.wait_for("message", check=same_author_check, timeout=60)
await ctx.bot.wait_for("message", check=pred, timeout=60)
except asyncio.TimeoutError:
await ctx.send(_("Ok then."))
await ctx.send(_("Response timed out."))
else:
if msg.content.lower().strip() == "y":
await ctx.author.send(
_("Here's a copy of the backup"), file=discord.File(str(backup_file))
)
if pred.result is True:
await ctx.send(_("OK, it's on its way!"))
try:
async with ctx.author.typing():
await ctx.author.send(
_("Here's a copy of the backup"),
file=discord.File(str(backup_file)),
)
except discord.Forbidden:
await ctx.send(
_("I don't seem to be able to DM you. Do you have closed DMs?")
)
except discord.HTTPException:
await ctx.send(_("I could not send the backup file."))
else:
await ctx.send(_("OK then."))
else:
await ctx.send(_("That directory doesn't seem to exist..."))
@commands.command()
@commands.cooldown(1, 60, commands.BucketType.user)
async def contact(self, ctx, *, message: str):
async def contact(self, ctx: commands.Context, *, message: str):
"""Sends a message to the owner"""
guild = ctx.message.guild
owner = discord.utils.get(ctx.bot.get_all_members(), id=ctx.bot.owner_id)
@@ -1203,7 +1217,7 @@ class Core(CoreLogic):
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except:
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
else:
await ctx.send(_("Your message has been sent."))
@@ -1215,14 +1229,14 @@ class Core(CoreLogic):
await ctx.send(
_("I cannot send your message, I'm unable to find my owner... *sigh*")
)
except:
except discord.HTTPException:
await ctx.send(_("I'm unable to deliver your message. Sorry."))
else:
await ctx.send(_("Your message has been sent."))
@commands.command()
@checks.is_owner()
async def dm(self, ctx, user_id: int, *, message: str):
async def dm(self, ctx: commands.Context, user_id: int, *, message: str):
"""Sends a DM to a user
This command needs a user id to work.
@@ -1256,7 +1270,7 @@ class Core(CoreLogic):
try:
await destination.send(embed=e)
except:
except discord.HTTPException:
await ctx.send(
_("Sorry, I couldn't deliver your message to {}").format(destination)
)
@@ -1266,7 +1280,7 @@ class Core(CoreLogic):
response = "{}\nMessage:\n\n{}".format(description, message)
try:
await destination.send("{}\n{}".format(box(response), content))
except:
except discord.HTTPException:
await ctx.send(
_("Sorry, I couldn't deliver your message to {}").format(destination)
)
@@ -1275,7 +1289,7 @@ class Core(CoreLogic):
@commands.group()
@checks.is_owner()
async def whitelist(self, ctx):
async def whitelist(self, ctx: commands.Context):
"""
Whitelist management commands.
"""
@@ -1293,7 +1307,7 @@ class Core(CoreLogic):
await ctx.send(_("User added to whitelist."))
@whitelist.command(name="list")
async def whitelist_list(self, ctx):
async def whitelist_list(self, ctx: commands.Context):
"""
Lists whitelisted users.
"""
@@ -1307,7 +1321,7 @@ class Core(CoreLogic):
await ctx.send(box(page))
@whitelist.command(name="remove")
async def whitelist_remove(self, ctx, user: discord.User):
async def whitelist_remove(self, ctx: commands.Context, user: discord.User):
"""
Removes user from whitelist.
"""
@@ -1324,7 +1338,7 @@ class Core(CoreLogic):
await ctx.send(_("User was not in the whitelist."))
@whitelist.command(name="clear")
async def whitelist_clear(self, ctx):
async def whitelist_clear(self, ctx: commands.Context):
"""
Clears the whitelist.
"""
@@ -1333,19 +1347,19 @@ class Core(CoreLogic):
@commands.group()
@checks.is_owner()
async def blacklist(self, ctx):
async def blacklist(self, ctx: commands.Context):
"""
blacklist management commands.
"""
pass
@blacklist.command(name="add")
async def blacklist_add(self, ctx, user: discord.User):
async def blacklist_add(self, ctx: commands.Context, user: discord.User):
"""
Adds a user to the blacklist.
"""
if await ctx.bot.is_owner(user):
ctx.send(_("You cannot blacklist an owner!"))
await ctx.send(_("You cannot blacklist an owner!"))
return
async with ctx.bot.db.blacklist() as curr_list:
@@ -1355,7 +1369,7 @@ class Core(CoreLogic):
await ctx.send(_("User added to blacklist."))
@blacklist.command(name="list")
async def blacklist_list(self, ctx):
async def blacklist_list(self, ctx: commands.Context):
"""
Lists blacklisted users.
"""
@@ -1369,7 +1383,7 @@ class Core(CoreLogic):
await ctx.send(box(page))
@blacklist.command(name="remove")
async def blacklist_remove(self, ctx, user: discord.User):
async def blacklist_remove(self, ctx: commands.Context, user: discord.User):
"""
Removes user from blacklist.
"""
@@ -1386,7 +1400,7 @@ class Core(CoreLogic):
await ctx.send(_("User was not in the blacklist."))
@blacklist.command(name="clear")
async def blacklist_clear(self, ctx):
async def blacklist_clear(self, ctx: commands.Context):
"""
Clears the blacklist.
"""
@@ -1396,14 +1410,14 @@ class Core(CoreLogic):
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(administrator=True)
async def localwhitelist(self, ctx):
async def localwhitelist(self, ctx: commands.Context):
"""
Whitelist management commands.
"""
pass
@localwhitelist.command(name="add")
async def localwhitelist_add(self, ctx, *, user_or_role: str):
async def localwhitelist_add(self, ctx: commands.Context, *, user_or_role: str):
"""
Adds a user or role to the whitelist.
"""
@@ -1424,7 +1438,7 @@ class Core(CoreLogic):
await ctx.send(_("Role added to whitelist."))
@localwhitelist.command(name="list")
async def localwhitelist_list(self, ctx):
async def localwhitelist_list(self, ctx: commands.Context):
"""
Lists whitelisted users and roles.
"""
@@ -1438,7 +1452,7 @@ class Core(CoreLogic):
await ctx.send(box(page))
@localwhitelist.command(name="remove")
async def localwhitelist_remove(self, ctx, *, user_or_role: str):
async def localwhitelist_remove(self, ctx: commands.Context, *, user_or_role: str):
"""
Removes user or role from whitelist.
"""
@@ -1468,7 +1482,7 @@ class Core(CoreLogic):
await ctx.send(_("Role was not in the whitelist."))
@localwhitelist.command(name="clear")
async def localwhitelist_clear(self, ctx):
async def localwhitelist_clear(self, ctx: commands.Context):
"""
Clears the whitelist.
"""
@@ -1478,14 +1492,14 @@ class Core(CoreLogic):
@commands.group()
@commands.guild_only()
@checks.admin_or_permissions(administrator=True)
async def localblacklist(self, ctx):
async def localblacklist(self, ctx: commands.Context):
"""
blacklist management commands.
"""
pass
@localblacklist.command(name="add")
async def localblacklist_add(self, ctx, *, user_or_role: str):
async def localblacklist_add(self, ctx: commands.Context, *, user_or_role: str):
"""
Adds a user or role to the blacklist.
"""
@@ -1498,7 +1512,7 @@ class Core(CoreLogic):
user = True
if user and await ctx.bot.is_owner(obj):
ctx.send(_("You cannot blacklist an owner!"))
await ctx.send(_("You cannot blacklist an owner!"))
return
async with ctx.bot.db.guild(ctx.guild).blacklist() as curr_list:
@@ -1511,7 +1525,7 @@ class Core(CoreLogic):
await ctx.send(_("Role added to blacklist."))
@localblacklist.command(name="list")
async def localblacklist_list(self, ctx):
async def localblacklist_list(self, ctx: commands.Context):
"""
Lists blacklisted users and roles.
"""
@@ -1525,7 +1539,7 @@ class Core(CoreLogic):
await ctx.send(box(page))
@localblacklist.command(name="remove")
async def localblacklist_remove(self, ctx, *, user_or_role: str):
async def localblacklist_remove(self, ctx: commands.Context, *, user_or_role: str):
"""
Removes user or role from blacklist.
"""
@@ -1555,7 +1569,7 @@ class Core(CoreLogic):
await ctx.send(_("Role was not in the blacklist."))
@localblacklist.command(name="clear")
async def localblacklist_clear(self, ctx):
async def localblacklist_clear(self, ctx: commands.Context):
"""
Clears the blacklist.
"""
@@ -1694,6 +1708,82 @@ class Core(CoreLogic):
await ctx.bot.db.disabled_command_msg.set(message)
await ctx.tick()
@commands.guild_only()
@checks.guildowner_or_permissions(manage_guild=True)
@commands.group(name="autoimmune")
async def autoimmune_group(self, ctx: commands.Context):
"""
Server settings for immunity from automated actions
"""
pass
@autoimmune_group.command(name="list")
async def autoimmune_list(self, ctx: commands.Context):
"""
Get's the current members and roles
configured for automatic moderation action immunity
"""
ai_ids = await ctx.bot.db.guild(ctx.guild).autoimmune_ids()
roles = {r.name for r in ctx.guild.roles if r.id in ai_ids}
members = {str(m) for m in ctx.guild.members if m.id in ai_ids}
output = ""
if roles:
output += _("Roles immune from automated moderation actions:\n")
output += ", ".join(roles)
if members:
if roles:
output += "\n"
output += _("Members immune from automated moderation actions:\n")
output += ", ".join(members)
if not output:
output = _("No immunty settings here.")
for page in pagify(output):
await ctx.send(page)
@autoimmune_group.command(name="add")
async def autoimmune_add(
self, ctx: commands.Context, user_or_role: Union[discord.Member, discord.Role]
):
"""
Makes a user or roles immune from automated moderation actions
"""
async with ctx.bot.db.guild(ctx.guild).autoimmune_ids() as ai_ids:
if user_or_role.id in ai_ids:
return await ctx.send(_("Already added."))
ai_ids.append(user_or_role.id)
await ctx.tick()
@autoimmune_group.command(name="remove")
async def autoimmune_remove(
self, ctx: commands.Context, user_or_role: Union[discord.Member, discord.Role]
):
"""
Makes a user or roles immune from automated moderation actions
"""
async with ctx.bot.db.guild(ctx.guild).autoimmune_ids() as ai_ids:
if user_or_role.id not in ai_ids:
return await ctx.send(_("Not in list."))
ai_ids.remove(user_or_role.id)
await ctx.tick()
@autoimmune_group.command(name="isimmune")
async def autoimmune_checkimmune(
self, ctx: commands.Context, user_or_role: Union[discord.Member, discord.Role]
):
"""
Checks if a user or role would be considered immune from automated actions
"""
if await ctx.bot.is_automod_immune(user_or_role):
await ctx.send(_("They are immune"))
else:
await ctx.send(_("They are not Immune"))
# RPC handlers
async def rpc_load(self, request):
cog_name = request.params[0]
@@ -1704,7 +1794,7 @@ class Core(CoreLogic):
self._cleanup_and_refresh_modules(spec.name)
self.bot.load_extension(spec)
await self.bot.load_extension(spec)
async def rpc_unload(self, request):
cog_name = request.params[0]

View File

@@ -1,15 +1,15 @@
import sys
import os
from pathlib import Path
from typing import List
from copy import deepcopy
import hashlib
import shutil
import inspect
import logging
import os
import sys
import tempfile
from copy import deepcopy
from pathlib import Path
import appdirs
import tempfile
from discord.utils import deprecated
from . import commands
from .json_io import JsonIO
__all__ = [
@@ -153,124 +153,28 @@ def core_data_path() -> Path:
return core_path.resolve()
def _find_data_files(init_location: str) -> (Path, List[Path]):
"""
Discovers all files in the bundled data folder of an installed cog.
Parameters
----------
init_location
Returns
-------
(pathlib.Path, list of pathlib.Path)
"""
init_file = Path(init_location)
if not init_file.is_file():
return []
package_folder = init_file.parent.resolve() / "data"
if not package_folder.is_dir():
return []
all_files = list(package_folder.rglob("*"))
return package_folder, [p.resolve() for p in all_files if p.is_file()]
def _compare_and_copy(to_copy: List[Path], bundled_data_dir: Path, cog_data_dir: Path):
"""
Filters out files from ``to_copy`` that already exist, and are the
same, in ``data_dir``. The files that are different are copied into
``data_dir``.
Parameters
----------
to_copy : list of pathlib.Path
bundled_data_dir : pathlib.Path
cog_data_dir : pathlib.Path
"""
def hash_bytestr_iter(bytesiter, hasher, ashexstr=False):
for block in bytesiter:
hasher.update(block)
return hasher.hexdigest() if ashexstr else hasher.digest()
def file_as_blockiter(afile, blocksize=65536):
with afile:
block = afile.read(blocksize)
while len(block) > 0:
yield block
block = afile.read(blocksize)
lookup = {p: cog_data_dir.joinpath(p.relative_to(bundled_data_dir)) for p in to_copy}
for orig, poss_existing in lookup.items():
if not poss_existing.is_file():
poss_existing.parent.mkdir(exist_ok=True, parents=True)
exists_checksum = None
else:
exists_checksum = hash_bytestr_iter(
file_as_blockiter(poss_existing.open("rb")), hashlib.sha256()
)
orig_checksum = ...
if exists_checksum is not None:
orig_checksum = hash_bytestr_iter(file_as_blockiter(orig.open("rb")), hashlib.sha256())
if exists_checksum != orig_checksum:
shutil.copy(str(orig), str(poss_existing))
log.debug("Copying {} to {}".format(orig, poss_existing))
# noinspection PyUnusedLocal
@deprecated("bundled_data_path() without calling this function")
def load_bundled_data(cog_instance, init_location: str):
pass
def bundled_data_path(cog_instance: commands.Cog) -> Path:
"""
This function copies (and overwrites) data from the ``data/`` folder
of the installed cog.
Get the path to the "data" directory bundled with this cog.
The bundled data folder must be located alongside the ``.py`` file
which contains the cog class.
.. important::
This function MUST be called from the ``setup()`` function of your
cog.
Examples
--------
>>> from redbot.core import data_manager
>>>
>>> def setup(bot):
>>> cog = MyCog()
>>> data_manager.load_bundled_data(cog, __file__)
>>> bot.add_cog(cog)
Parameters
----------
cog_instance
An instance of your cog class.
init_location : str
The ``__file__`` attribute of the file where your ``setup()``
function exists.
"""
bundled_data_folder, to_copy = _find_data_files(init_location)
cog_data_folder = cog_data_path(cog_instance) / "bundled_data"
_compare_and_copy(to_copy, bundled_data_folder, cog_data_folder)
def bundled_data_path(cog_instance) -> Path:
"""
The "data" directory that has been copied from installed cogs.
.. important::
You should *NEVER* write to this directory. Data manager will
overwrite files in this directory each time `load_bundled_data`
is called. You should instead write to the directory provided by
`cog_data_path`.
You should *NEVER* write to this directory.
Parameters
----------
cog_instance
An instance of your cog. If calling from a command or method of
your cog, this should be ``self``.
Returns
-------
@@ -280,10 +184,10 @@ def bundled_data_path(cog_instance) -> Path:
Raises
------
FileNotFoundError
If no bundled data folder exists or if it hasn't been loaded yet.
"""
If no bundled data folder exists.
bundled_path = cog_data_path(cog_instance) / "bundled_data"
"""
bundled_path = Path(inspect.getfile(cog_instance.__class__)).parent / "data"
if not bundled_path.is_dir():
raise FileNotFoundError("No such directory {}".format(bundled_path))

View File

@@ -8,9 +8,11 @@ from contextlib import redirect_stdout
from copy import copy
import discord
from . import checks, commands
from .i18n import Translator
from .utils.chat_formatting import box, pagify
from .utils.predicates import MessagePredicate
"""
Notice:
@@ -25,10 +27,11 @@ _ = Translator("Dev", __file__)
START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))")
class Dev:
class Dev(commands.Cog):
"""Various development focused utilities."""
def __init__(self):
super().__init__()
self._last_result = None
self.sessions = set()
@@ -217,12 +220,8 @@ class Dev:
self.sessions.add(ctx.channel.id)
await ctx.send(_("Enter code to execute or evaluate. `exit()` or `quit` to exit."))
msg_check = lambda m: (
m.author == ctx.author and m.channel == ctx.channel and m.content.startswith("`")
)
while True:
response = await ctx.bot.wait_for("message", check=msg_check)
response = await ctx.bot.wait_for("message", check=MessagePredicate.regex(r"^`", ctx))
cleaned = self.cleanup_code(response.content)

View File

@@ -1,7 +1,12 @@
import motor.motor_asyncio
from .red_base import BaseDriver
import re
from typing import Match, Pattern
from urllib.parse import quote_plus
import motor.core
import motor.motor_asyncio
from .red_base import BaseDriver
__all__ = ["Mongo"]
@@ -9,18 +14,24 @@ _conn = None
def _initialize(**kwargs):
uri = kwargs.get("URI", "mongodb")
host = kwargs["HOST"]
port = kwargs["PORT"]
admin_user = kwargs["USERNAME"]
admin_pass = kwargs["PASSWORD"]
db_name = kwargs.get("DB_NAME", "default_db")
if port is 0:
ports = ""
else:
ports = ":{}".format(port)
if admin_user is not None and admin_pass is not None:
url = "mongodb://{}:{}@{}:{}/{}".format(
quote_plus(admin_user), quote_plus(admin_pass), host, port, db_name
url = "{}://{}:{}@{}{}/{}".format(
uri, quote_plus(admin_user), quote_plus(admin_pass), host, ports, db_name
)
else:
url = "mongodb://{}:{}/{}".format(host, port, db_name)
url = "{}://{}{}/{}".format(uri, host, ports, db_name)
global _conn
_conn = motor.motor_asyncio.AsyncIOMotorClient(url)
@@ -74,6 +85,7 @@ class Mongo(BaseDriver):
async def get(self, *identifiers: str):
mongo_collection = self.get_collection()
identifiers = (*map(self._escape_key, identifiers),)
dot_identifiers = ".".join(identifiers)
partial = await mongo_collection.find_one(
@@ -85,10 +97,14 @@ class Mongo(BaseDriver):
for i in identifiers:
partial = partial[i]
if isinstance(partial, dict):
return self._unescape_dict_keys(partial)
return partial
async def set(self, *identifiers: str, value=None):
dot_identifiers = ".".join(identifiers)
dot_identifiers = ".".join(map(self._escape_key, identifiers))
if isinstance(value, dict):
value = self._escape_dict_keys(value)
mongo_collection = self.get_collection()
@@ -99,7 +115,7 @@ class Mongo(BaseDriver):
)
async def clear(self, *identifiers: str):
dot_identifiers = ".".join(identifiers)
dot_identifiers = ".".join(map(self._escape_key, identifiers))
mongo_collection = self.get_collection()
if len(identifiers) > 0:
@@ -109,10 +125,80 @@ class Mongo(BaseDriver):
else:
await mongo_collection.delete_one({"_id": self.unique_cog_identifier})
@staticmethod
def _escape_key(key: str) -> str:
return _SPECIAL_CHAR_PATTERN.sub(_replace_with_escaped, key)
@staticmethod
def _unescape_key(key: str) -> str:
return _CHAR_ESCAPE_PATTERN.sub(_replace_with_unescaped, key)
@classmethod
def _escape_dict_keys(cls, data: dict) -> dict:
"""Recursively escape all keys in a dict."""
ret = {}
for key, value in data.items():
key = cls._escape_key(key)
if isinstance(value, dict):
value = cls._escape_dict_keys(value)
ret[key] = value
return ret
@classmethod
def _unescape_dict_keys(cls, data: dict) -> dict:
"""Recursively unescape all keys in a dict."""
ret = {}
for key, value in data.items():
key = cls._unescape_key(key)
if isinstance(value, dict):
value = cls._unescape_dict_keys(value)
ret[key] = value
return ret
_SPECIAL_CHAR_PATTERN: Pattern[str] = re.compile(r"([.$]|\\U0000002E|\\U00000024)")
_SPECIAL_CHARS = {
".": "\\U0000002E",
"$": "\\U00000024",
"\\U0000002E": "\\U&0000002E",
"\\U00000024": "\\U&00000024",
}
def _replace_with_escaped(match: Match[str]) -> str:
return _SPECIAL_CHARS[match[0]]
_CHAR_ESCAPE_PATTERN: Pattern[str] = re.compile(r"(\\U0000002E|\\U00000024)")
_CHAR_ESCAPES = {
"\\U0000002E": ".",
"\\U00000024": "$",
"\\U&0000002E": "\\U0000002E",
"\\U&00000024": "\\U00000024",
}
def _replace_with_unescaped(match: Match[str]) -> str:
return _CHAR_ESCAPES[match[0]]
def get_config_details():
uri = None
while True:
uri = input("Enter URI scheme (mongodb or mongodb+srv): ")
if uri is "":
uri = "mongodb"
if uri in ["mongodb", "mongodb+srv"]:
break
else:
print("Invalid URI scheme")
host = input("Enter host address: ")
port = int(input("Enter host port: "))
if uri is "mongodb":
port = int(input("Enter host port: "))
else:
port = 0
admin_uname = input("Enter login username: ")
admin_password = input("Enter login password: ")
@@ -128,5 +214,6 @@ def get_config_details():
"USERNAME": admin_uname,
"PASSWORD": admin_password,
"DB_NAME": db_name,
"URI": uri,
}
return ret

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

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

View File

@@ -1,21 +1,22 @@
import contextlib
import sys
import codecs
import datetime
import logging
import traceback
from datetime import timedelta
from distutils.version import StrictVersion
from typing import List
import aiohttp
import discord
import pkg_resources
import traceback
from colorama import Fore, Style, init
from pkg_resources import DistributionNotFound
from . import __version__, commands
from . import __version__ as red_version, version_info as red_version_info, VersionInfo, commands
from .data_manager import storage_type
from .utils.chat_formatting import inline, bordered
from .utils import fuzzy_command_search
from .utils.chat_formatting import inline, bordered, format_perms_list
from .utils import fuzzy_command_search, format_fuzzy_results
log = logging.getLogger("red")
sentry_log = logging.getLogger("red.sentry")
@@ -43,7 +44,7 @@ def should_log_sentry(exception) -> bool:
tb = tb.tb_next
module = tb_frame.f_globals.get("__name__")
return module.startswith("redbot")
return module is not None and module.startswith("redbot")
def init_events(bot, cli_flags):
@@ -67,6 +68,14 @@ def init_events(bot, cli_flags):
packages.extend(cli_flags.load_cogs)
if packages:
# Load permissions first, for security reasons
try:
packages.remove("permissions")
except ValueError:
pass
else:
packages.insert(0, "permissions")
to_remove = []
print("Loading packages...")
for package in packages:
@@ -96,7 +105,6 @@ def init_events(bot, cli_flags):
prefixes = cli_flags.prefix or (await bot.db.prefix())
lang = await bot.db.locale()
red_version = __version__
red_pkg = pkg_resources.get_distribution("Red-DiscordBot")
dpy_version = discord.__version__
@@ -116,24 +124,22 @@ def init_events(bot, cli_flags):
INFO.append("{} cogs with {} commands".format(len(bot.cogs), len(bot.commands)))
try:
with contextlib.suppress(aiohttp.ClientError, discord.HTTPException):
async with aiohttp.ClientSession() as session:
async with session.get("https://pypi.python.org/pypi/red-discordbot/json") as r:
data = await r.json()
if StrictVersion(data["info"]["version"]) > StrictVersion(red_version):
if VersionInfo.from_str(data["info"]["version"]) > red_version_info:
INFO.append(
"Outdated version! {} is available "
"but you're using {}".format(data["info"]["version"], red_version)
)
owner = discord.utils.get(bot.get_all_members(), id=bot.owner_id)
owner = await bot.get_user_info(bot.owner_id)
await owner.send(
"Your Red instance is out of date! {} is the current "
"version, however you are using {}!".format(
data["info"]["version"], red_version
)
)
except:
pass
INFO2 = []
sentry = await bot.db.enable_sentry()
@@ -197,17 +203,6 @@ def init_events(bot, cli_flags):
if disabled_message:
await ctx.send(disabled_message.replace("{command}", ctx.invoked_with))
elif isinstance(error, commands.CommandInvokeError):
# Need to test if the following still works
"""
no_dms = "Cannot send messages to this user"
is_help_cmd = ctx.command.qualified_name == "help"
is_forbidden = isinstance(error.original, discord.Forbidden)
if is_help_cmd and is_forbidden and error.original.text == no_dms:
msg = ("I couldn't send the help message to you in DM. Either"
" you blocked me or you disabled DMs in this server.")
await ctx.send(msg)
return
"""
log.exception(
"Exception in command '{}'" "".format(ctx.command.qualified_name),
exc_info=error.original,
@@ -231,12 +226,23 @@ def init_events(bot, cli_flags):
if not hasattr(ctx.cog, "_{0.command.cog_name}__error".format(ctx)):
await ctx.send(inline(message))
elif isinstance(error, commands.CommandNotFound):
term = ctx.invoked_with + " "
if len(ctx.args) > 1:
term += " ".join(ctx.args[1:])
fuzzy_result = await fuzzy_command_search(ctx, ctx.invoked_with)
if fuzzy_result is not None:
await ctx.maybe_send_embed(fuzzy_result)
fuzzy_commands = await fuzzy_command_search(ctx)
if not fuzzy_commands:
pass
elif await ctx.embed_requested():
await ctx.send(embed=await format_fuzzy_results(ctx, fuzzy_commands, embed=True))
else:
await ctx.send(await format_fuzzy_results(ctx, fuzzy_commands, embed=False))
elif isinstance(error, commands.BotMissingPermissions):
if bin(error.missing.value).count("1") == 1: # Only one perm missing
plural = ""
else:
plural = "s"
await ctx.send(
"I require the {perms} permission{plural} to execute that command.".format(
perms=format_perms_list(error.missing), plural=plural
)
)
elif isinstance(error, commands.CheckFailure):
pass
elif isinstance(error, commands.NoPrivateMessage):

View File

@@ -3,7 +3,7 @@ from . import commands
def init_global_checks(bot):
@bot.check
@bot.check_once
async def global_perms(ctx):
"""Check the user is/isn't globally whitelisted/blacklisted."""
if await bot.is_owner(ctx.author):
@@ -15,7 +15,7 @@ def init_global_checks(bot):
return ctx.author.id not in await bot.db.blacklist()
@bot.check
@bot.check_once
async def local_perms(ctx: commands.Context):
"""Check the user is/isn't locally whitelisted/blacklisted."""
if await bot.is_owner(ctx.author):
@@ -26,14 +26,14 @@ def init_global_checks(bot):
local_blacklist = await guild_settings.blacklist()
local_whitelist = await guild_settings.whitelist()
_ids = [r.id for r in ctx.author.roles if not r.is_default]
_ids = [r.id for r in ctx.author.roles if not r.is_default()]
_ids.append(ctx.author.id)
if local_whitelist:
return any(i in local_whitelist for i in _ids)
return not any(i in local_blacklist for i in _ids)
@bot.check
@bot.check_once
async def bots(ctx):
"""Check the user is not another bot."""
return not ctx.author.bot

View File

@@ -20,25 +20,25 @@ message to help page.
e.g. format_help_for(ctx, ctx.command, "Missing required arguments")
discord.py 1.0.0a
Experimental: compatibility with 0.16.8
Copyrights to logic of code belong to Rapptz (Danny)
Everything else credit to SirThane#1780"""
This help formatter contains work by Rapptz (Danny) and SirThane#1780.
"""
import contextlib
from collections import namedtuple
from typing import List
from typing import List, Optional, Union
import discord
from discord.ext.commands import formatter
from discord.ext.commands import formatter as dpy_formatter
import inspect
import itertools
import re
import sys
import traceback
from . import commands
from redbot.core.utils.chat_formatting import pagify, box
from redbot.core.utils import fuzzy_command_search
from .i18n import Translator
from .utils.chat_formatting import pagify
from .utils import fuzzy_command_search, format_fuzzy_results
_ = Translator("Help", __file__)
EMPTY_STRING = "\u200b"
@@ -49,7 +49,7 @@ _mention_pattern = re.compile("|".join(_mentions_transforms.keys()))
EmbedField = namedtuple("EmbedField", "name value inline")
class Help(formatter.HelpFormatter):
class Help(dpy_formatter.HelpFormatter):
"""Formats help for commands."""
def __init__(self, *args, **kwargs):
@@ -57,15 +57,10 @@ class Help(formatter.HelpFormatter):
self.command = None
super().__init__(*args, **kwargs)
def pm_check(self, ctx):
@staticmethod
def pm_check(ctx):
return isinstance(ctx.channel, discord.DMChannel)
@property
def clean_prefix(self):
maybe_member = self.context.guild.me if self.context.guild else self.context.bot.user
pretty = f"@{maybe_member.display_name}"
return self.context.prefix.replace(maybe_member.mention, pretty)
@property
def me(self):
return self.context.me
@@ -84,6 +79,8 @@ class Help(formatter.HelpFormatter):
else:
return await self.context.embed_colour()
colour = color
@property
def destination(self):
if self.context.bot.pm_help:
@@ -110,7 +107,7 @@ class Help(formatter.HelpFormatter):
continue
if self.is_cog() or self.is_bot():
name = "{0}{1}".format(self.clean_prefix, name)
name = "{0}{1}".format(self.context.clean_prefix, name)
entries += "**{0}** {1}\n".format(name, command.short_doc)
return entries
@@ -120,7 +117,7 @@ class Help(formatter.HelpFormatter):
return (
"Type {0}help <command> for more info on a command.\n"
"You can also type {0}help <category> for more info on a category.".format(
self.clean_prefix
self.context.clean_prefix
)
)
@@ -163,7 +160,7 @@ class Help(formatter.HelpFormatter):
if self.command.help:
splitted = self.command.help.split("\n\n")
name = "__{0}__".format(splitted[0])
value = "\n\n".join(splitted[1:]).replace("[p]", self.clean_prefix)
value = "\n\n".join(splitted[1:]).replace("[p]", self.context.clean_prefix)
if value == "":
value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False)
@@ -213,7 +210,8 @@ class Help(formatter.HelpFormatter):
return emb
def group_fields(self, fields: List[EmbedField], max_chars=1000):
@staticmethod
def group_fields(fields: List[EmbedField], max_chars=1000):
curr_group = []
ret = []
for f in fields:
@@ -227,8 +225,8 @@ class Help(formatter.HelpFormatter):
return ret
async def format_help_for(self, ctx, command_or_bot, reason: str = None):
"""Formats the help page and handles the actual heavy lifting of how ### WTF HAPPENED?
async def format_help_for(self, ctx, command_or_bot, reason: str = ""):
"""Formats the help page and handles the actual heavy lifting of how
the help command looks like. To change the behaviour, override the
:meth:`~.HelpFormatter.format` method.
@@ -247,10 +245,24 @@ class Help(formatter.HelpFormatter):
"""
self.context = ctx
self.command = command_or_bot
# We want the permission state to be set as if the author had run the command he is
# requesting help for. This is so the subcommands shown in the help menu correctly reflect
# any permission rules set.
if isinstance(self.command, commands.Command):
with contextlib.suppress(commands.CommandError):
await self.command.can_run(
self.context, check_all_parents=True, change_permission_state=True
)
elif isinstance(self.command, commands.Cog):
with contextlib.suppress(commands.CommandError):
# Cog's don't have a `can_run` method, so we use the `Requires` object directly.
await self.command.requires.verify(self.context)
emb = await self.format()
if reason:
emb["embed"]["title"] = "{0}".format(reason)
emb["embed"]["title"] = reason
ret = []
@@ -277,158 +289,115 @@ class Help(formatter.HelpFormatter):
return ret
async def simple_embed(self, ctx, title=None, description=None, color=None):
# Shortcut
async def format_command_not_found(
self, ctx: commands.Context, command_name: str
) -> Optional[Union[str, discord.Message]]:
"""Get the response for a user calling help on a missing command."""
self.context = ctx
if color is None:
color = await self.color()
embed = discord.Embed(title=title, description=description, color=color)
embed.set_footer(text=ctx.bot.formatter.get_ending_note())
embed.set_author(**self.author)
return embed
async def cmd_not_found(self, ctx, cmd, description=None, color=None):
# Shortcut for a shortcut. Sue me
embed = await self.simple_embed(
ctx, title="Command {} not found.".format(cmd), description=description, color=color
return await default_command_not_found(
ctx,
command_name,
use_embeds=True,
colour=await self.colour(),
author=self.author,
footer={"text": self.get_ending_note()},
)
return embed
async def cmd_has_no_subcommands(self, ctx, cmd, color=None):
embed = await self.simple_embed(
ctx, title=ctx.bot.command_has_no_subcommands.format(cmd), color=color
)
return embed
@commands.command(hidden=True)
async def help(ctx, *cmds: str):
"""Shows help documentation.
async def help(ctx: commands.Context, *, command_name: str = ""):
"""Show help documentation.
[p]**help**: Shows the help manual.
[p]**help** command: Show help for a command
[p]**help** Category: Show commands and description for a category"""
destination = ctx.author if ctx.bot.pm_help else ctx
def repl(obj):
return _mentions_transforms.get(obj.group(0), "")
- `[p]help`: Show the help manual.
- `[p]help command`: Show help for a command.
- `[p]help Category`: Show commands and description for a category,
"""
bot = ctx.bot
if bot.pm_help:
destination = ctx.author
else:
destination = ctx.channel
use_embeds = await ctx.embed_requested()
f = formatter.HelpFormatter()
# help by itself just lists our own commands.
if len(cmds) == 0:
if use_embeds:
embeds = await ctx.bot.formatter.format_help_for(ctx, ctx.bot)
else:
embeds = await f.format_help_for(ctx, ctx.bot)
elif len(cmds) == 1:
# try to see if it is a cog name
name = _mention_pattern.sub(repl, cmds[0])
command = None
if name in ctx.bot.cogs:
command = ctx.bot.cogs[name]
else:
command = ctx.bot.all_commands.get(name)
if command is None:
if use_embeds:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(
embed=await ctx.bot.formatter.cmd_not_found(
ctx, name, description=fuzzy_result
)
)
else:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(
ctx.bot.command_not_found.format(name, fuzzy_result)
)
return
if use_embeds:
embeds = await ctx.bot.formatter.format_help_for(ctx, command)
else:
embeds = await f.format_help_for(ctx, command)
if use_embeds:
formatter = bot.formatter
else:
name = _mention_pattern.sub(repl, cmds[0])
command = ctx.bot.all_commands.get(name)
if command is None:
if use_embeds:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(
embed=await ctx.bot.formatter.cmd_not_found(
ctx, name, description=fuzzy_result
)
)
else:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(ctx.bot.command_not_found.format(name, fuzzy_result))
return
formatter = dpy_formatter.HelpFormatter()
for key in cmds[1:]:
try:
key = _mention_pattern.sub(repl, key)
command = command.all_commands.get(key)
if command is None:
if use_embeds:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(
embed=await ctx.bot.formatter.cmd_not_found(
ctx, name, description=fuzzy_result
)
)
else:
fuzzy_result = await fuzzy_command_search(ctx, name)
if fuzzy_result is not None:
await destination.send(
ctx.bot.command_not_found.format(name, fuzzy_result)
)
return
except AttributeError:
if use_embeds:
await destination.send(
embed=await ctx.bot.formatter.simple_embed(
ctx,
title='Command "{0.name}" has no subcommands.'.format(command),
color=await ctx.bot.formatter.color(),
)
)
else:
await destination.send(ctx.bot.command_has_no_subcommands.format(command))
return
if use_embeds:
embeds = await ctx.bot.formatter.format_help_for(ctx, command)
if not command_name:
# help by itself just lists our own commands.
pages = await formatter.format_help_for(ctx, bot)
else:
# First check if it's a cog
command = bot.get_cog(command_name)
if command is None:
command = bot.get_command(command_name)
if command is None:
if hasattr(formatter, "format_command_not_found"):
msg = await formatter.format_command_not_found(ctx, command_name)
else:
msg = await default_command_not_found(ctx, command_name, use_embeds=use_embeds)
pages = [msg]
else:
embeds = await f.format_help_for(ctx, command)
pages = await formatter.format_help_for(ctx, command)
max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild()
if len(embeds) > max_pages_in_guild:
if len(pages) > max_pages_in_guild:
destination = ctx.author
if ctx.guild and not ctx.guild.me.permissions_in(ctx.channel).send_messages:
destination = ctx.author
try:
for embed in embeds:
if use_embeds:
try:
await destination.send(embed=embed)
except discord.HTTPException:
destination = ctx.author
await destination.send(embed=embed)
for page in pages:
if isinstance(page, discord.Embed):
await destination.send(embed=page)
else:
try:
await destination.send(embed)
except discord.HTTPException:
destination = ctx.author
await destination.send(embed)
await destination.send(page)
except discord.Forbidden:
await ctx.channel.send(
"I couldn't send the help message to you in DM. Either you blocked me or you disabled DMs in this server."
_(
"I couldn't send the help message to you in DM. Either you blocked me or you "
"disabled DMs in this server."
)
)
@help.error
async def help_error(ctx, error):
destination = ctx.author if ctx.bot.pm_help else ctx
await destination.send("{0.__name__}: {1}".format(type(error), error))
traceback.print_tb(error.original.__traceback__, file=sys.stderr)
async def default_command_not_found(
ctx: commands.Context, command_name: str, *, use_embeds: bool, **embed_options
) -> Optional[Union[str, discord.Embed]]:
"""Default function for formatting the response to a missing command."""
ret = None
cmds = command_name.split()
prev_command = None
for invoked in itertools.accumulate(cmds, lambda *args: " ".join(args)):
command = ctx.bot.get_command(invoked)
if command is None:
if prev_command is not None and not isinstance(prev_command, commands.Group):
ret = _("Command *{command_name}* has no subcommands.").format(
command_name=prev_command.qualified_name
)
break
elif not await command.can_see(ctx):
return
prev_command = command
if ret is None:
fuzzy_commands = await fuzzy_command_search(ctx, command_name, min_score=75)
if fuzzy_commands:
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
else:
ret = _("Command *{command_name}* not found.").format(command_name=command_name)
if use_embeds:
if isinstance(ret, str):
ret = discord.Embed(title=ret)
if "colour" in embed_options:
ret.colour = embed_options.pop("colour")
elif "color" in embed_options:
ret.colour = embed_options.pop("color")
if "author" in embed_options:
ret.set_author(**embed_options.pop("author"))
if "footer" in embed_options:
ret.set_footer(**embed_options.pop("footer"))
return ret

View File

@@ -1,7 +1,7 @@
import os
import re
from pathlib import Path
from . import commands
from typing import Callable, Union
__all__ = ["get_locale", "set_locale", "reload_locales", "cog_i18n", "Translator"]
@@ -113,9 +113,9 @@ def _normalize(string, remove_newline=False):
ends_with_space = s[-1] in " \n\t\r"
if remove_newline:
newline_re = re.compile("[\r\n]+")
s = " ".join(filter(bool, newline_re.split(s)))
s = " ".join(filter(bool, s.split("\t")))
s = " ".join(filter(bool, s.split(" ")))
s = " ".join(filter(None, newline_re.split(s)))
s = " ".join(filter(None, s.split("\t")))
s = " ".join(filter(None, s.split(" ")))
if starts_with_space:
s = " " + s
if ends_with_space:
@@ -149,10 +149,10 @@ def get_locale_path(cog_folder: Path, extension: str) -> Path:
return cog_folder / "locales" / "{}.{}".format(get_locale(), extension)
class Translator:
class Translator(Callable[[str], str]):
"""Function to get translated strings at runtime."""
def __init__(self, name, file_location):
def __init__(self, name: str, file_location: Union[str, Path, os.PathLike]):
"""
Initializes an internationalization object.
@@ -173,7 +173,7 @@ class Translator:
self.load_translations()
def __call__(self, untranslated: str):
def __call__(self, untranslated: str) -> str:
"""Translate the given string.
This will look for the string in the translator's :code:`.pot` file,
@@ -217,6 +217,12 @@ class Translator:
self.translations.update({untranslated: translated})
# This import to be down here to avoid circular import issues.
# This will be cleaned up at a later date
# noinspection PyPep8
from . import commands
def cog_i18n(translator: Translator):
"""Get a class decorator to link the translator to this cog."""

View File

@@ -3,10 +3,11 @@ import json
import os
import asyncio
import logging
from copy import deepcopy
from uuid import uuid4
# This is basically our old DataIO, except that it's now threadsafe
# and just a base for much more elaborate classes
# This is basically our old DataIO and just a base for much more elaborate classes
# This still isn't completely threadsafe, (do not use config in threads)
from pathlib import Path
log = logging.getLogger("red")
@@ -27,18 +28,54 @@ class JsonIO:
# noinspection PyUnresolvedReferences
def _save_json(self, data, settings=PRETTY):
"""
This fsync stuff here is entirely neccessary.
On windows, it is not available in entirety.
If a windows user ends up with tons of temp files, they should consider hosting on
something POSIX compatible, or using the mongo backend instead.
Most users wont encounter this issue, but with high write volumes,
without the fsync on both the temp file, and after the replace on the directory,
There's no real durability or atomicity guarantee from the filesystem.
In depth overview of underlying reasons why this is needed:
https://lwn.net/Articles/457667/
Also see:
http://man7.org/linux/man-pages/man2/open.2.html#NOTES (synchronous I/O section)
And:
https://www.mjmwired.net/kernel/Documentation/filesystems/ext4.txt#310
"""
log.debug("Saving file {}".format(self.path))
filename = self.path.stem
tmp_file = "{}-{}.tmp".format(filename, uuid4().fields[0])
tmp_path = self.path.parent / tmp_file
with tmp_path.open(encoding="utf-8", mode="w") as f:
json.dump(data, f, **settings)
f.flush() # This does get closed on context exit, ...
os.fsync(f.fileno()) # but that needs to happen prior to this line
tmp_path.replace(self.path)
# pylint: disable=E1101
try:
fd = os.open(self.path.parent, os.O_DIRECTORY)
os.fsync(fd)
except AttributeError:
fd = None
finally:
if fd is not None:
os.close(fd)
async def _threadsafe_save_json(self, data, settings=PRETTY):
loop = asyncio.get_event_loop()
func = functools.partial(self._save_json, data, settings)
with await self._lock:
# the deepcopy is needed here. otherwise,
# the dict can change during serialization
# and this will break the encoder.
data_copy = deepcopy(data)
func = functools.partial(self._save_json, data_copy, settings)
async with self._lock:
await loop.run_in_executor(None, func)
# noinspection PyUnresolvedReferences
@@ -51,6 +88,5 @@ class JsonIO:
async def _threadsafe_load_json(self, path):
loop = asyncio.get_event_loop()
func = functools.partial(self._load_json, path)
task = loop.run_in_executor(None, func)
with await self._lock:
return await asyncio.wait_for(task)
async with self._lock:
return await loop.run_in_executor(None, func)

View File

@@ -666,29 +666,30 @@ async def register_casetypes(new_types: List[dict]) -> List[CaseType]:
return type_list
async def get_modlog_channel(guild: discord.Guild) -> Union[discord.TextChannel, None]:
async def get_modlog_channel(guild: discord.Guild) -> discord.TextChannel:
"""
Get the current modlog channel
Get the current modlog channel.
Parameters
----------
guild: `discord.Guild`
The guild to get the modlog channel for
The guild to get the modlog channel for.
Returns
-------
`discord.TextChannel` or `None`
The channel object representing the modlog channel
`discord.TextChannel`
The channel object representing the modlog channel.
Raises
------
RuntimeError
If the modlog channel is not found
If the modlog channel is not found.
"""
if hasattr(guild, "get_channel"):
channel = guild.get_channel(await _conf.guild(guild).mod_log())
else:
# For unit tests only
channel = await _conf.guild(guild).mod_log()
if channel is None:
raise RuntimeError("Failed to get the mod log channel!")

View File

@@ -1,20 +1,42 @@
__all__ = ["bounded_gather", "safe_delete", "fuzzy_command_search", "deduplicate_iterables"]
import asyncio
from asyncio import as_completed, AbstractEventLoop, Semaphore
from asyncio.futures import isfuture
from itertools import chain
import logging
import os
from pathlib import Path
import shutil
from typing import Any, Awaitable, Iterator, List, Optional
from asyncio import AbstractEventLoop, as_completed, Semaphore
from asyncio.futures import isfuture
from itertools import chain
from pathlib import Path
from typing import (
Any,
AsyncIterator,
AsyncIterable,
Awaitable,
Callable,
Iterable,
Iterator,
List,
Optional,
Tuple,
TypeVar,
Union,
)
import discord
from fuzzywuzzy import fuzz, process
from redbot.core import commands
from fuzzywuzzy import process
from .chat_formatting import box
__all__ = [
"bounded_gather",
"safe_delete",
"fuzzy_command_search",
"format_fuzzy_results",
"deduplicate_iterables",
]
_T = TypeVar("_T")
# Benchmarked to be the fastest method.
def deduplicate_iterables(*iterables):
@@ -26,11 +48,11 @@ def deduplicate_iterables(*iterables):
return list(dict.fromkeys(chain.from_iterable(iterables)))
def fuzzy_filter(record):
def _fuzzy_log_filter(record):
return record.funcName != "extractWithoutOrder"
logging.getLogger().addFilter(fuzzy_filter)
logging.getLogger().addFilter(_fuzzy_log_filter)
def safe_delete(pth: Path):
@@ -47,59 +69,222 @@ def safe_delete(pth: Path):
shutil.rmtree(str(pth), ignore_errors=True)
async def filter_commands(ctx: commands.Context, extracted: list):
return [
i
for i in extracted
if i[1] >= 90
and not i[0].hidden
and not any([p.hidden for p in i[0].parents])
and await i[0].can_run(ctx)
and all([await p.can_run(ctx) for p in i[0].parents])
]
class AsyncFilter(AsyncIterator[_T], Awaitable[List[_T]]):
"""Class returned by `async_filter`. See that function for details.
We don't recommend instantiating this class directly.
"""
def __init__(
self,
func: Callable[[_T], Union[bool, Awaitable[bool]]],
iterable: Union[AsyncIterable[_T], Iterable[_T]],
) -> None:
self.__func: Callable[[_T], Union[bool, Awaitable[bool]]] = func
self.__iterable: Union[AsyncIterable[_T], Iterable[_T]] = iterable
# We assign the generator strategy based on the arguments' types
if isinstance(iterable, AsyncIterable):
if asyncio.iscoroutinefunction(func):
self.__generator_instance = self.__async_generator_async_pred()
else:
self.__generator_instance = self.__async_generator_sync_pred()
elif asyncio.iscoroutinefunction(func):
self.__generator_instance = self.__sync_generator_async_pred()
else:
raise TypeError("Must be either an async predicate, an async iterable, or both.")
async def __sync_generator_async_pred(self) -> AsyncIterator[_T]:
for item in self.__iterable:
if await self.__func(item):
yield item
async def __async_generator_sync_pred(self) -> AsyncIterator[_T]:
async for item in self.__iterable:
if self.__func(item):
yield item
async def __async_generator_async_pred(self) -> AsyncIterator[_T]:
async for item in self.__iterable:
if await self.__func(item):
yield item
async def __flatten(self) -> List[_T]:
return [item async for item in self]
def __await__(self):
# Simply return the generator filled into a list
return self.__flatten().__await__()
def __anext__(self) -> Awaitable[_T]:
# This will use the generator strategy set in __init__
return self.__generator_instance.__anext__()
async def fuzzy_command_search(ctx: commands.Context, term: str):
out = []
def async_filter(
func: Callable[[_T], Union[bool, Awaitable[bool]]],
iterable: Union[AsyncIterable[_T], Iterable[_T]],
) -> AsyncFilter[_T]:
"""Filter an (optionally async) iterable with an (optionally async) predicate.
At least one of the arguments must be async.
Parameters
----------
func : Callable[[T], Union[bool, Awaitable[bool]]]
A function or coroutine function which takes one item of ``iterable``
as an argument, and returns ``True`` or ``False``.
iterable : Union[AsyncIterable[_T], Iterable[_T]]
An iterable or async iterable which is to be filtered.
Raises
------
TypeError
If neither of the arguments are async.
Returns
-------
AsyncFilter[T]
An object which can either be awaited to yield a list of the filtered
items, or can also act as an async iterator to yield items one by one.
"""
return AsyncFilter(func, iterable)
async def async_enumerate(
async_iterable: AsyncIterable[_T], start: int = 0
) -> AsyncIterator[Tuple[int, _T]]:
"""Async iterable version of `enumerate`.
Parameters
----------
async_iterable : AsyncIterable[T]
The iterable to enumerate.
start : int
The index to start from. Defaults to 0.
Returns
-------
AsyncIterator[Tuple[int, T]]
An async iterator of tuples in the form of ``(index, item)``.
"""
async for item in async_iterable:
yield start, item
start += 1
async def fuzzy_command_search(
ctx: commands.Context, term: Optional[str] = None, *, min_score: int = 80
) -> Optional[List[commands.Command]]:
"""Search for commands which are similar in name to the one invoked.
Returns a maximum of 5 commands which must all be at least matched
greater than ``min_score``.
Parameters
----------
ctx : `commands.Context <redbot.core.commands.Context>`
The command invocation context.
term : Optional[str]
The name of the invoked command. If ``None``, `Context.invoked_with`
will be used instead.
min_score : int
The minimum score for matched commands to reach. Defaults to 80.
Returns
-------
Optional[List[`commands.Command <redbot.core.commands.Command>`]]
A list of commands which were fuzzily matched with the invoked
command.
"""
if ctx.guild is not None:
enabled = await ctx.bot.db.guild(ctx.guild).fuzzy()
else:
enabled = await ctx.bot.db.fuzzy()
if not enabled:
return None
return
if term is None:
term = ctx.invoked_with
# If the term is an alias or CC, we don't want to send a supplementary fuzzy search.
alias_cog = ctx.bot.get_cog("Alias")
if alias_cog is not None:
is_alias, alias = await alias_cog.is_alias(ctx.guild, term)
if is_alias:
return None
return
customcom_cog = ctx.bot.get_cog("CustomCommands")
if customcom_cog is not None:
cmd_obj = customcom_cog.commandobj
try:
ccinfo = await cmd_obj.get(ctx.message, term)
await cmd_obj.get(ctx.message, term)
except:
pass
else:
return None
return
extracted_cmds = await filter_commands(
ctx, process.extract(term, ctx.bot.walk_commands(), limit=5)
)
# Do the scoring. `extracted` is a list of tuples in the form `(command, score)`
extracted = process.extract(term, ctx.bot.walk_commands(), limit=5, scorer=fuzz.QRatio)
if not extracted:
return
if not extracted_cmds:
return None
# Filter through the fuzzy-matched commands.
matched_commands = []
for command, score in extracted:
if score < min_score:
# Since the list is in decreasing order of score, we can exit early.
break
if await command.can_see(ctx):
matched_commands.append(command)
for pos, extracted in enumerate(extracted_cmds, 1):
short = " - {}".format(extracted[0].short_doc) if extracted[0].short_doc else ""
out.append("{0}. {1.prefix}{2.qualified_name}{3}".format(pos, ctx, extracted[0], short))
return matched_commands
return box("\n".join(out), lang="Perhaps you wanted one of these?")
async def format_fuzzy_results(
ctx: commands.Context,
matched_commands: List[commands.Command],
*,
embed: Optional[bool] = None,
) -> Union[str, discord.Embed]:
"""Format the result of a fuzzy command search.
Parameters
----------
ctx : `commands.Context <redbot.core.commands.Context>`
The context in which this result is being displayed.
matched_commands : List[`commands.Command <redbot.core.commands.Command>`]
A list of commands which have been matched by the fuzzy search, sorted
in order of decreasing similarity.
embed : bool
Whether or not the result should be an embed. If set to ``None``, this
will default to the result of `ctx.embed_requested`.
Returns
-------
Union[str, discord.Embed]
The formatted results.
"""
if embed is not False and (embed is True or await ctx.embed_requested()):
lines = []
for cmd in matched_commands:
lines.append(f"**{ctx.clean_prefix}{cmd.qualified_name}** {cmd.short_doc}")
return discord.Embed(
title="Perhaps you wanted one of these?",
colour=await ctx.embed_colour(),
description="\n".join(lines),
)
else:
lines = []
for cmd in matched_commands:
lines.append(f"{ctx.clean_prefix}{cmd.qualified_name} -- {cmd.short_doc}")
return "Perhaps you wanted one of these? " + box("\n".join(lines), lang="vhdl")
async def _sem_wrapper(sem, task):
@@ -124,9 +309,11 @@ def bounded_gather_iter(
loop : asyncio.AbstractEventLoop
The event loop to use for the semaphore and :meth:`asyncio.gather`.
limit : Optional[`int`]
The maximum number of concurrent tasks. Used when no ``semaphore`` is passed.
The maximum number of concurrent tasks. Used when no ``semaphore``
is passed.
semaphore : Optional[:class:`asyncio.Semaphore`]
The semaphore to use for bounding tasks. If `None`, create one using ``loop`` and ``limit``.
The semaphore to use for bounding tasks. If `None`, create one
using ``loop`` and ``limit``.
Raises
------
@@ -173,9 +360,11 @@ def bounded_gather(
return_exceptions : bool
If true, gather exceptions in the result list instead of raising.
limit : Optional[`int`]
The maximum number of concurrent tasks. Used when no ``semaphore`` is passed.
The maximum number of concurrent tasks. Used when no ``semaphore``
is passed.
semaphore : Optional[:class:`asyncio.Semaphore`]
The semaphore to use for bounding tasks. If `None`, create one using ``loop`` and ``limit``.
The semaphore to use for bounding tasks. If `None`, create one
using ``loop`` and ``limit``.
Raises
------

View File

@@ -1,5 +1,11 @@
import itertools
from typing import Sequence, Iterator
from typing import Sequence, Iterator, List
import discord
from redbot.core.i18n import Translator
_ = Translator("UtilsChatFormatting", __file__)
def error(text: str) -> str:
@@ -64,6 +70,7 @@ def bold(text: str) -> str:
The marked up text.
"""
text = escape(text, formatting=True)
return "**{}**".format(text)
@@ -101,7 +108,10 @@ def inline(text: str) -> str:
The marked up text.
"""
return "`{}`".format(text)
if "`" in text:
return "``{}``".format(text)
else:
return "`{}`".format(text)
def italics(text: str) -> str:
@@ -118,6 +128,7 @@ def italics(text: str) -> str:
The marked up text.
"""
text = escape(text, formatting=True)
return "*{}*".format(text)
@@ -273,6 +284,7 @@ def strikethrough(text: str) -> str:
The marked up text.
"""
text = escape(text, formatting=True)
return "~~{}~~".format(text)
@@ -290,6 +302,7 @@ def underline(text: str) -> str:
The marked up text.
"""
text = escape(text, formatting=True)
return "__{}__".format(text)
@@ -317,3 +330,59 @@ def escape(text: str, *, mass_mentions: bool = False, formatting: bool = False)
if formatting:
text = text.replace("`", "\\`").replace("*", "\\*").replace("_", "\\_").replace("~", "\\~")
return text
def humanize_list(items: Sequence[str]) -> str:
"""Get comma-separted list, with the last element joined with *and*.
This uses an Oxford comma, because without one, items containing
the word *and* would make the output difficult to interpret.
Parameters
----------
items : Sequence[str]
The items of the list to join together.
Examples
--------
.. testsetup::
from redbot.core.utils.chat_formatting import humanize_list
.. doctest::
>>> humanize_list(['One', 'Two', 'Three'])
'One, Two, and Three'
>>> humanize_list(['One'])
'One'
"""
if len(items) == 1:
return items[0]
return ", ".join(items[:-1]) + _(", and ") + items[-1]
def format_perms_list(perms: discord.Permissions) -> str:
"""Format a list of permission names.
This will return a humanized list of the names of all enabled
permissions in the provided `discord.Permissions` object.
Parameters
----------
perms : discord.Permissions
The permissions object with the requested permissions to list
enabled.
Returns
-------
str
The humanized list.
"""
perm_names: List[str] = []
for perm, value in perms:
if value is True:
perm_name = '"' + perm.replace("_", " ").title() + '"'
perm_names.append(perm_name)
return humanize_list(perm_names).replace("Guild", "Server")

View File

@@ -8,6 +8,7 @@ __all__ = [
"filter_invites",
"filter_mass_mentions",
"filter_various_mentions",
"normalize_smartquotes",
]
# regexes
@@ -19,6 +20,16 @@ MASS_MENTION_RE = re.compile(r"(@)(?=everyone|here)") # This only matches the @
OTHER_MENTION_RE = re.compile(r"(<)(@[!&]?|#)(\d+>)")
SMART_QUOTE_REPLACEMENT_DICT = {
"\u2018": "'", # Left single quote
"\u2019": "'", # Right single quote
"\u201C": '"', # Left double quote
"\u201D": '"', # Right double quote
}
SMART_QUOTE_REPLACE_RE = re.compile("|".join(SMART_QUOTE_REPLACEMENT_DICT.keys()))
# convenience wrappers
def filter_urls(to_filter: str) -> str:
"""Get a string with URLs sanitized.
@@ -101,3 +112,24 @@ def filter_various_mentions(to_filter: str) -> str:
The sanitized string.
"""
return OTHER_MENTION_RE.sub(r"\1\\\2\3", to_filter)
def normalize_smartquotes(to_normalize: str) -> str:
"""
Get a string with smart quotes replaced with normal ones
Parameters
----------
to_normalize : str
The string to normalize.
Returns
-------
str
The normalized string.
"""
def replacement_for(obj):
return SMART_QUOTE_REPLACEMENT_DICT.get(obj.group(0), "")
return SMART_QUOTE_REPLACE_RE.sub(replacement_for, to_normalize)

View File

@@ -1,15 +1,14 @@
"""
Original source of reaction-based menu idea from
https://github.com/Lunar-Dust/Dusty-Cogs/blob/master/menu/menu.py
Ported to Red V3 by Palm\_\_ (https://github.com/palmtree5)
"""
# Original source of reaction-based menu idea from
# https://github.com/Lunar-Dust/Dusty-Cogs/blob/master/menu/menu.py
#
# Ported to Red V3 by Palm\_\_ (https://github.com/palmtree5)
import asyncio
import contextlib
from typing import Union, Iterable
from typing import Union, Iterable, Optional
import discord
from redbot.core import commands
from .. import commands
from .predicates import ReactionPredicate
_ReactableEmoji = Union[str, discord.Emoji]
@@ -71,27 +70,35 @@ async def menu(
else:
message = await ctx.send(current_page)
# Don't wait for reactions to be added (GH-1797)
ctx.bot.loop.create_task(_add_menu_reactions(message, controls.keys()))
# noinspection PyAsyncCall
start_adding_reactions(message, controls.keys(), ctx.bot.loop)
else:
if isinstance(current_page, discord.Embed):
await message.edit(embed=current_page)
else:
await message.edit(content=current_page)
def react_check(r, u):
return u == ctx.author and r.message.id == message.id and str(r.emoji) in controls.keys()
try:
if isinstance(current_page, discord.Embed):
await message.edit(embed=current_page)
else:
await message.edit(content=current_page)
except discord.NotFound:
return
try:
react, user = await ctx.bot.wait_for("reaction_add", check=react_check, timeout=timeout)
react, user = await ctx.bot.wait_for(
"reaction_add",
check=ReactionPredicate.with_emojis(tuple(controls.keys()), message, ctx.author),
timeout=timeout,
)
except asyncio.TimeoutError:
try:
await message.clear_reactions()
except discord.Forbidden: # cannot remove all reactions
for key in controls.keys():
await message.remove_reaction(key, ctx.bot.user)
return None
return await controls[react.emoji](ctx, pages, controls, message, page, timeout, react.emoji)
except discord.NotFound:
return
else:
return await controls[react.emoji](
ctx, pages, controls, message, page, timeout, react.emoji
)
async def next_page(
@@ -103,12 +110,10 @@ async def next_page(
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.guild.me)
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
with contextlib.suppress(discord.NotFound):
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
if page == len(pages) - 1:
page = 0 # Loop around to the first item
else:
@@ -125,17 +130,15 @@ async def prev_page(
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.guild.me)
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
with contextlib.suppress(discord.NotFound):
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
if page == 0:
next_page = len(pages) - 1 # Loop around to the last item
page = len(pages) - 1 # Loop around to the last item
else:
next_page = page - 1
return await menu(ctx, pages, controls, message=message, page=next_page, timeout=timeout)
page = page - 1
return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout)
async def close_menu(
@@ -147,17 +150,55 @@ async def close_menu(
timeout: float,
emoji: str,
):
if message:
await message.delete()
return None
async def _add_menu_reactions(message: discord.Message, emojis: Iterable[_ReactableEmoji]):
"""Add the reactions"""
# The task should exit silently if the message is deleted
with contextlib.suppress(discord.NotFound):
for emoji in emojis:
await message.add_reaction(emoji)
await message.delete()
def start_adding_reactions(
message: discord.Message,
emojis: Iterable[_ReactableEmoji],
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> asyncio.Task:
"""Start adding reactions to a message.
This is a non-blocking operation - calling this will schedule the
reactions being added, but the calling code will continue to
execute asynchronously. There is no need to await this function.
This is particularly useful if you wish to start waiting for a
reaction whilst the reactions are still being added - in fact,
this is exactly what `menu` uses to do that.
This spawns a `asyncio.Task` object and schedules it on ``loop``.
If ``loop`` omitted, the loop will be retrieved with
`asyncio.get_event_loop`.
Parameters
----------
message: discord.Message
The message to add reactions to.
emojis : Iterable[Union[str, discord.Emoji]]
The emojis to react to the message with.
loop : Optional[asyncio.AbstractEventLoop]
The event loop.
Returns
-------
asyncio.Task
The task for the coroutine adding the reactions.
"""
async def task():
# The task should exit silently if the message is deleted
with contextlib.suppress(discord.NotFound):
for emoji in emojis:
await message.add_reaction(emoji)
if loop is None:
loop = asyncio.get_event_loop()
return loop.create_task(task())
DEFAULT_CONTROLS = {"": prev_page, "": close_menu, "": next_page}

View File

@@ -1,11 +1,13 @@
import asyncio
from datetime import timedelta
from typing import List, Iterable, Union
from typing import List, Iterable, Union, TYPE_CHECKING, Dict
import discord
from redbot.core import Config
from redbot.core.bot import Red
if TYPE_CHECKING:
from .. import Config
from ..bot import Red
from ..commands import Context
async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel):
@@ -87,7 +89,7 @@ def get_audit_reason(author: discord.Member, reason: str = None):
async def is_allowed_by_hierarchy(
bot: Red, settings: Config, guild: discord.Guild, mod: discord.Member, user: discord.Member
bot: "Red", settings: "Config", guild: discord.Guild, mod: discord.Member, user: discord.Member
):
if not await settings.guild(guild).respect_hierarchy():
return True
@@ -95,7 +97,9 @@ async def is_allowed_by_hierarchy(
return mod.top_role.position > user.top_role.position or is_special
async def is_mod_or_superior(bot: Red, obj: Union[discord.Message, discord.Member, discord.Role]):
async def is_mod_or_superior(
bot: "Red", obj: Union[discord.Message, discord.Member, discord.Role]
):
"""Check if an object has mod or superior permissions.
If a message is passed, its author's permissions are checked. If a role is
@@ -179,7 +183,7 @@ def strfdelta(delta: timedelta):
async def is_admin_or_superior(
bot: Red, obj: Union[discord.Message, discord.Member, discord.Role]
bot: "Red", obj: Union[discord.Message, discord.Member, discord.Role]
):
"""Same as `is_mod_or_superior` except for admin permissions.
@@ -225,3 +229,36 @@ async def is_admin_or_superior(
return True
else:
return False
async def check_permissions(ctx: "Context", perms: Dict[str, bool]) -> bool:
"""Check if the author has required permissions.
This will always return ``True`` if the author is a bot owner, or
has the ``administrator`` permission. If ``perms`` is empty, this
will only check if the user is a bot owner.
Parameters
----------
ctx : Context
The command invokation context to check.
perms : Dict[str, bool]
A dictionary mapping permissions to their required states.
Valid permission names are those listed as properties of
the `discord.Permissions` class.
Returns
-------
bool
``True`` if the author has the required permissions.
"""
if await ctx.bot.is_owner(ctx.author):
return True
elif not perms:
return False
resolved = ctx.channel.permissions_for(ctx.author)
return resolved.administrator or all(
getattr(resolved, name, None) == value for name, value in perms.items()
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,8 @@ import discord
from datetime import datetime
from redbot.core.utils.chat_formatting import pagify
import io
import sys
import weakref
from typing import List
from typing import List, Optional
from .common_filters import filter_mass_mentions
_instances = weakref.WeakValueDictionary({})
@@ -86,7 +85,11 @@ class Tunnel(metaclass=TunnelMeta):
@staticmethod
async def message_forwarder(
*, destination: discord.abc.Messageable, content: str = None, embed=None, files=[]
*,
destination: discord.abc.Messageable,
content: str = None,
embed=None,
files: Optional[List[discord.File]] = None
) -> List[discord.Message]:
"""
This does the actual sending, use this instead of a full tunnel
@@ -95,19 +98,19 @@ class Tunnel(metaclass=TunnelMeta):
Parameters
----------
destination: `discord.abc.Messageable`
destination: discord.abc.Messageable
Where to send
content: `str`
content: str
The message content
embed: `discord.Embed`
embed: discord.Embed
The embed to send
files: `list` of `discord.File`
files: Optional[List[discord.File]]
A list of files to send.
Returns
-------
list of `discord.Message`
The `discord.Message`\ (s) sent as a result
List[discord.Message]
The messages sent as a result.
Raises
------
@@ -117,7 +120,6 @@ class Tunnel(metaclass=TunnelMeta):
see `discord.abc.Messageable.send`
"""
rets = []
files = files if files else None
if content:
for page in pagify(content):
rets.append(await destination.send(page, files=files, embed=embed))
@@ -148,15 +150,12 @@ class Tunnel(metaclass=TunnelMeta):
"""
files = []
size = 0
max_size = 8 * 1024 * 1024
for a in m.attachments:
_fp = io.BytesIO()
await a.save(_fp)
size += sys.getsizeof(_fp)
if size > max_size:
return []
files.append(discord.File(_fp, filename=a.filename))
max_size = 8 * 1000 * 1000
if m.attachments and sum(a.size for a in m.attachments) <= max_size:
for a in m.attachments:
_fp = io.BytesIO()
await a.save(_fp)
files.append(discord.File(_fp, filename=a.filename))
return files
async def communicate(

View File

@@ -8,24 +8,19 @@ 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,
remove_instance,
remove_instance_interaction,
create_backup,
save_config,
)
from redbot.core import __version__
from redbot.core.utils import safe_delete
from redbot.core import __version__, version_info as red_version_info, VersionInfo
from redbot.core.cli import confirm
if sys.platform == "linux":
import distro
PYTHON_OK = sys.version_info >= (3, 6, 2)
INTERACTIVE_MODE = not len(sys.argv) > 1 # CLI flags = non-interactive
INTRO = "==========================\nRed Discord Bot - Launcher\n==========================\n"
@@ -33,6 +28,14 @@ INTRO = "==========================\nRed Discord Bot - Launcher\n===============
IS_WINDOWS = os.name == "nt"
IS_MAC = sys.platform == "darwin"
if IS_WINDOWS:
# Due to issues with ProactorEventLoop prior to 3.6.6 (bpo-26819)
MIN_PYTHON_VERSION = (3, 6, 6)
else:
MIN_PYTHON_VERSION = (3, 6, 2)
PYTHON_OK = sys.version_info >= MIN_PYTHON_VERSION
def is_venv():
"""Return True if the process is in a venv or in a virtualenv."""
@@ -383,7 +386,7 @@ async def is_outdated():
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
return VersionInfo.from_str(new_version) > red_version_info, new_version
def main_menu():
@@ -409,14 +412,14 @@ def main_menu():
choice = user_choice()
if choice == "1":
instance = instance_menu()
cli_flags = cli_flag_getter()
if instance:
cli_flags = cli_flag_getter()
run_red(instance, autorestart=True, cliflags=cli_flags)
wait()
elif choice == "2":
instance = instance_menu()
cli_flags = cli_flag_getter()
if instance:
cli_flags = cli_flag_getter()
run_red(instance, autorestart=False, cliflags=cli_flags)
wait()
elif choice == "3":
@@ -461,9 +464,11 @@ def main_menu():
def main():
if not PYTHON_OK:
raise RuntimeError(
"Red requires Python 3.6.2 or greater. Please install the correct version!"
print(
f"Python {'.'.join(map(str, MIN_PYTHON_VERSION))} is required to run Red, but you "
f"have {sys.version}! Please update Python."
)
sys.exit(1)
if args.debuginfo: # Check first since the function triggers an exit
debug_info()

View File

@@ -0,0 +1,11 @@
import pytest
from redbot.cogs.permissions import Permissions
from redbot.core import Config
@pytest.fixture()
def permissions(config, monkeypatch, red):
with monkeypatch.context() as m:
m.setattr(Config, "get_conf", lambda *args, **kwargs: config)
return Permissions(red)

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import argparse
import asyncio
import json
@@ -177,26 +176,21 @@ def basic_setup():
async def json_to_mongo(current_data_dir: Path, storage_details: dict):
from redbot.core.drivers.red_mongo import Mongo
core_data_file = list(current_data_dir.glob("core/settings.json"))[0]
m = Mongo("Core", "0", **storage_details)
core_data_file = current_data_dir / "core" / "settings.json"
driver = Mongo(cog_name="Core", identifier="0", **storage_details)
with core_data_file.open(mode="r") as f:
core_data = json.loads(f.read())
collection = m.get_collection()
await collection.update_one(
{"_id": m.unique_cog_identifier}, update={"$set": core_data["0"]}, upsert=True
)
data = core_data.get("0", {})
for key, value in data.items():
await driver.set(key, value=value)
for p in current_data_dir.glob("cogs/**/settings.json"):
cog_name = p.parent.stem
with p.open(mode="r") as f:
cog_data = json.loads(f.read())
cog_i = None
for ident in list(cog_data.keys()):
cog_i = str(hash(ident))
cog_m = Mongo(p.parent.stem, cog_i, **storage_details)
cog_c = cog_m.get_collection()
for ident in list(cog_data.keys()):
await cog_c.update_one(
{"_id": cog_m.unique_cog_identifier}, update={"$set": cog_data[cog_i]}, upsert=True
)
cog_data = json.load(f)
for identifier, data in cog_data.items():
driver = Mongo(cog_name, identifier, **storage_details)
for key, value in data.items():
await driver.set(key, value=value)
async def mongo_to_json(current_data_dir: Path, storage_details: dict):

122
setup.py
View File

@@ -5,28 +5,70 @@ import tempfile
from distutils.errors import CCompilerError, DistutilsPlatformError
from setuptools import setup, find_packages
requirements = [
"aiohttp-json-rpc==0.11.1",
"aiohttp==3.3.2",
install_requires = [
"aiohttp-json-rpc==0.11.2",
"aiohttp==3.4.4",
"appdirs==1.4.3",
"async-timeout==3.0.0",
"async-timeout==3.0.1",
"attrs==18.2.0",
"chardet==3.0.4",
"colorama==0.3.9",
"colorama==0.4.1",
"discord.py>=1.0.0a0",
"distro==1.3.0; sys_platform == 'linux'",
"fuzzywuzzy==0.17.0",
"idna-ssl==1.1.0",
"idna==2.7",
"multidict==4.4.0",
"idna==2.8",
"multidict==4.5.2",
"python-levenshtein==0.12.0",
"pyyaml==3.13",
"raven==6.9.0",
"raven==6.10.0",
"raven-aiohttp==0.7.0",
"schema==0.6.8",
"websockets==6.0",
"yarl==1.2.6",
"yarl==1.3.0",
]
extras_require = {
"test": [
"atomicwrites==1.2.1",
"more-itertools==5.0.0",
"pluggy==0.8.1",
"py==1.7.0",
"pytest==4.1.0",
"pytest-asyncio==0.10.0",
"six==1.12.0",
],
"mongo": ["motor==2.0.0", "pymongo==3.7.2", "dnspython==1.16.0"],
"docs": [
"alabaster==0.7.12",
"babel==2.6.0",
"certifi==2018.11.29",
"docutils==0.14",
"imagesize==1.1.0",
"Jinja2==2.10",
"MarkupSafe==1.1.0",
"packaging==18.0",
"pyparsing==2.3.0",
"Pygments==2.3.1",
"pytz==2018.9",
"requests==2.21.0",
"six==1.12.0",
"snowballstemmer==1.2.1",
"sphinx==1.8.3",
"sphinx_rtd_theme==0.4.2",
"sphinxcontrib-asyncio==0.2.0",
"sphinxcontrib-websupport==1.1.0",
"urllib3==1.24.1",
],
"voice": ["red-lavalink==0.1.2"],
"style": ["black==18.9b0", "click==7.0", "toml==0.10.0"],
}
python_requires = ">=3.6.2,<3.8"
if os.name == "nt":
# Due to issues with ProactorEventLoop prior to 3.6.6 (bpo-26819)
python_requires = ">=3.6.6,<3.8"
def get_dependency_links():
with open("dependency_links.txt") as file:
@@ -37,13 +79,12 @@ def check_compiler_available():
m = ccompiler.new_compiler()
with tempfile.TemporaryDirectory() as tdir:
with tempfile.NamedTemporaryFile(prefix="dummy", suffix=".c", dir=tdir) as tfile:
tfile.write(b"int main(int argc, char** argv) {return 0;}")
tfile.seek(0)
try:
m.compile([tfile.name], output_dir=tdir)
except (CCompilerError, DistutilsPlatformError):
return False
with open(os.path.join(tdir, "dummy.c"), "w") as tfile:
tfile.write("int main(int argc, char** argv) {return 0;}")
try:
m.compile([tfile.name], output_dir=tdir)
except (CCompilerError, DistutilsPlatformError):
return False
return True
@@ -57,12 +98,14 @@ def get_version():
if __name__ == "__main__":
if not check_compiler_available():
requirements.remove(
next(r for r in requirements if r.lower().startswith("python-levenshtein"))
install_requires.remove(
next(r for r in install_requires if r.lower().startswith("python-levenshtein"))
)
if "READTHEDOCS" in os.environ:
requirements.remove(next(r for r in requirements if r.lower().startswith("discord.py")))
install_requires.remove(
next(r for r in install_requires if r.lower().startswith("discord.py"))
)
setup(
name="Red-DiscordBot",
@@ -94,43 +137,8 @@ if __name__ == "__main__":
],
"pytest11": ["red-discordbot = redbot.pytest"],
},
python_requires=">=3.6.2,<3.8",
install_requires=requirements,
python_requires=python_requires,
install_requires=install_requires,
dependency_links=get_dependency_links(),
extras_require={
"test": [
"atomicwrites==1.2.1",
"more-itertools==4.3.0",
"pluggy==0.7.1",
"py==1.6.0",
"pytest==3.7.4",
"pytest-asyncio==0.9.0",
"six==1.11.0",
],
"mongo": ["motor==2.0.0", "pymongo==3.7.1"],
"docs": [
"alabaster==0.7.11",
"babel==2.6.0",
"certifi==2018.8.24",
"docutils==0.14",
"imagesize==1.1.0",
"Jinja2==2.10",
"MarkupSafe==1.0",
"packaging==17.1",
"pyparsing==2.2.0",
"six==1.11.0",
"Pygments==2.2.0",
"pytz==2018.5",
"requests==2.19.1",
"urllib3==1.23",
"six==1.11.0",
"snowballstemmer==1.2.1",
"sphinx==1.7.8",
"sphinx_rtd_theme==0.4.1",
"sphinxcontrib-asyncio==0.2.0",
"sphinxcontrib-websupport==1.1.0",
],
"voice": ["red-lavalink==0.1.2"],
"style": ["black==18.6b4", "click==6.7", "toml==0.9.4"],
},
extras_require=extras_require,
)

View File

@@ -28,20 +28,6 @@ def test_existing_git_repo(tmpdir):
assert exists is True
@pytest.mark.asyncio
async def test_clone_repo(repo_norun, capsys):
await repo_norun.clone()
clone_cmd, _ = capsys.readouterr()
clone_cmd = clone_cmd.strip("[']\n").split("', '")
assert clone_cmd[0] == "git"
assert clone_cmd[1] == "clone"
assert clone_cmd[2] == "-b"
assert clone_cmd[3] == "rewrite_cogs"
assert clone_cmd[4] == repo_norun.url
assert ("repos", "squid") == pathlib.Path(clone_cmd[5]).parts[-2:]
@pytest.mark.asyncio
async def test_add_repo(monkeypatch, repo_manager):
monkeypatch.setattr("redbot.cogs.downloader.repo_manager.Repo._run", fake_run_noprint)
@@ -94,3 +80,43 @@ async def test_existing_repo(repo_manager):
await repo_manager.add_repo("http://test.com", "test")
repo_manager.does_repo_exist.assert_called_once_with("test")
def test_tree_url_parse(repo_manager):
cases = [
{
"input": ("https://github.com/Tobotimus/Tobo-Cogs", None),
"expected": ("https://github.com/Tobotimus/Tobo-Cogs", None),
},
{
"input": ("https://github.com/Tobotimus/Tobo-Cogs", "V3"),
"expected": ("https://github.com/Tobotimus/Tobo-Cogs", "V3"),
},
{
"input": ("https://github.com/Tobotimus/Tobo-Cogs/tree/V3", None),
"expected": ("https://github.com/Tobotimus/Tobo-Cogs", "V3"),
},
{
"input": ("https://github.com/Tobotimus/Tobo-Cogs/tree/V3", "V4"),
"expected": ("https://github.com/Tobotimus/Tobo-Cogs", "V4"),
},
]
for test_case in cases:
assert test_case["expected"] == repo_manager._parse_url(*test_case["input"])
def test_tree_url_non_github(repo_manager):
cases = [
{
"input": ("https://gitlab.com/Tobotimus/Tobo-Cogs", None),
"expected": ("https://gitlab.com/Tobotimus/Tobo-Cogs", None),
},
{
"input": ("https://my.usgs.gov/bitbucket/scm/Tobotimus/Tobo-Cogs", "V3"),
"expected": ("https://my.usgs.gov/bitbucket/scm/Tobotimus/Tobo-Cogs", "V3"),
},
]
for test_case in cases:
assert test_case["expected"] == repo_manager._parse_url(*test_case["input"])

View File

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

View File

@@ -10,7 +10,7 @@ def test_trivia_lists():
for l in list_names:
with l.open() as f:
try:
dict_ = yaml.load(f)
dict_ = yaml.safe_load(f)
except yaml.error.YAMLError as e:
problem_lists.append((l.stem, "YAML error:\n{!s}".format(e)))
else:

View File

@@ -224,6 +224,15 @@ async def test_set_dynamic_attr(config):
assert await config.foobar() is True
@pytest.mark.asyncio
async def test_clear_dynamic_attr(config):
await config.foo.set(True)
await config.clear_raw("foo")
with pytest.raises(KeyError):
await config.get_raw("foo")
@pytest.mark.asyncio
async def test_get_dynamic_attr(config):
assert await config.get_raw("foobaz", default=True) is True
@@ -466,3 +475,18 @@ async def test_get_raw_mixes_defaults(config):
subgroup = await config.get_raw("subgroup")
assert subgroup == {"foo": True, "bar": False}
@pytest.mark.asyncio
async def test_cast_str_raw(config):
await config.set_raw(123, 456, value=True)
assert await config.get_raw(123, 456) is True
assert await config.get_raw("123", "456") is True
await config.clear_raw("123", 456)
@pytest.mark.asyncio
async def test_cast_str_nested(config):
config.register_global(foo={})
await config.foo.set({123: True, 456: {789: False}})
assert await config.foo() == {"123": True, "456": {"789": False}}

View File

@@ -2,12 +2,12 @@ import asyncio
import pytest
import random
import textwrap
import warnings
from redbot.core.utils import (
chat_formatting,
bounded_gather,
bounded_gather_iter,
deduplicate_iterables,
common_filters,
)
@@ -101,7 +101,7 @@ async def test_bounded_gather():
if isinstance(result, RuntimeError):
num_failed += 1
else:
assert result == i # verify original orde
assert result == i # verify_permissions original orde
assert 0 <= result < num_tasks
assert 0 < status[1] <= num_concurrent
@@ -191,3 +191,8 @@ async def test_bounded_gather_iter_cancel():
assert 0 < status[1] <= num_concurrent
assert quit_on <= status[2] <= quit_on + num_concurrent
assert num_failed <= num_fail
def test_normalize_smartquotes():
assert common_filters.normalize_smartquotes("Should\u2018 normalize") == "Should' normalize"
assert common_filters.normalize_smartquotes("Same String") == "Same String"

View File

@@ -1,6 +1,36 @@
from redbot import core
from redbot.core import VersionInfo
def test_version_working():
assert hasattr(core, "__version__")
assert core.__version__[0] == "3"
# When adding more of these, ensure they are added in ascending order of precedence
version_tests = (
"3.0.0a32.post10.dev12",
"3.0.0rc1.dev1",
"3.0.0rc1",
"3.0.0",
"3.0.1",
"3.0.1.post1.dev1",
"3.0.1.post1",
"2018.10.6b21",
)
def test_version_info_str_parsing():
for version_str in version_tests:
assert version_str == str(VersionInfo.from_str(version_str))
def test_version_info_lt():
for next_idx, cur in enumerate(version_tests[:-1], start=1):
cur_test = VersionInfo.from_str(cur)
next_test = VersionInfo.from_str(version_tests[next_idx])
assert cur_test < next_test
def test_version_info_gt():
assert VersionInfo.from_str(version_tests[1]) > VersionInfo.from_str(version_tests[0])

View File

@@ -29,8 +29,9 @@ whitelist_externals =
basepython = python3.6
extras = docs, mongo
commands =
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -bhtml
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" -W -blinkcheck
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/html" -W -bhtml
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/linkcheck" -W -blinkcheck
sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out/doctest" -W -bdoctest
[testenv:style]
description = Stylecheck the code with black to see if anything needs changes.