Compare commits

...

47 Commits

Author SHA1 Message Date
Michael H
2f28df2dd0 Merge branch 'V3/feature/mutes' of https://github.com/Cog-Creators/Red-DiscordBot into V3/feature/mutes 2020-02-06 15:09:40 -05:00
Michael H
677d700363 Merge branch 'V3/develop' into V3/feature/mutes 2020-02-06 15:09:19 -05:00
trundleroo
8d73838d80 Update announcer.py (#3514)
* Update announcer.py

* Update announcer.py
2020-02-06 18:27:32 +01:00
Kowlin
1fc4ece14c Updated readme badges. (#3511) 2020-02-05 17:32:35 -05:00
Michael H
0adc960c60 dev bump (#3512) 2020-02-05 17:32:05 -05:00
Michael H
c426aefd1a Version 3.3.1 (#3510)
* 331

* okay sphinx
2020-02-05 23:21:38 +01:00
Michael H
00cf395483 Handle deprecations in asyncio (#3509)
* passing loop to certain things was deprecated. additionally, `asyncio.get_event_loop()` is being deprecated

* awesome, checks are functioning as intended

* fun with fixtures

* we can just stop misuing that anyhow

* Update redbot/pytest/downloader.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-02-05 17:16:13 -05:00
Kowlin
61ed864e02 CI ports from Travis CI (#3435)
* Attempt 1, I suppose.

* Add the remaining 2 out of 3 jobs

* Spacing matters T_T

* So does formatting...

* More formatting fixing.

* First attempt at postgres services.

* Postgres attempt 2

* Update tests.yml

Flatten a python version I suppose.

* Update tests.yml

* Update tests.yml

* Update tests.yml

* Update tests.yml

* I wonder if this works lmao

* this is fun™

* let's go back

* add fail-fast

* Added publishing workflows

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-02-05 16:02:05 -05:00
Lane Babuder
90b099395b Adding CentOS 8 Documentation (#3463)
IUS will not be supporting RHEL 8, so utilizing epel-release and telling the system to use standard git is the best option.
2020-02-03 16:57:09 -05:00
aikaterna
12e6f44135 [Core] No DMing the bot (#3478)
* [Core] No DMing the bot

* Return early if target user is a bot
2020-02-03 16:26:33 -05:00
PredaaA
e44fc69d14 [Core] Add a cli flag for setting a max size of message cache (#3474)
* Add an arg in cli to change message cache size

* Add an arg in cli to change message cache size

* Changelog

* Actually pass None in message_cache_size

* Update cli.py

* Add a cli arg to disable message cache.

* Add a cli arg to disable message cache.

* well go away you useless

* you actually are an int

* Check if message cache is higher than 0 when set it.

* Use sys.maxsize as max cache size.

* Update cli.py

* Add bot.max_messages property.

* typos

* 🤦

* style
2020-02-03 16:14:45 -05:00
jack1142
8454239a98 [Mod] Fix shorthelp for [p]modset dm (#3488)
* Update settings.py

* Update settings.py

* Create 3488.misc.rst

* Update settings.py
2020-02-03 16:14:19 -05:00
jack1142
64106c771a Allow to edit prefixes through redbot --edit (#3486)
* feat: allow to edit prefixes through `redbot --edit`

* enhance: allow to setup multiple prefixes

* fix: gotta break out of the loop

* fix: gotta sort prefixes in reversed order

* fix: editing prefix shouldn't save it as token

* fix: sort prefixes when using flag too

* chore(changelog): add towncrier entry

* docs: update help for `--edit` flag
2020-02-03 16:08:48 -05:00
jack1142
17234ac8fa Add -e flag to journalctl command in systemd guide so that it takes the user to the end of logs automatically. (#3483)
* Make journalctl's pager go to the end of logs automatically

* Aaaaaaaand changelog
2020-02-01 01:26:39 +01:00
Kowlin
b64802b92f Fix for the unknown days argument on hackban. (#3475) 2020-01-30 18:55:11 +01:00
jack1142
6fa02b1a8d [Docs] Trigger update on sudo add-apt-repository (#3464) 2020-01-27 18:41:57 -09:00
Michael H
7420df9598 let's fix this for dev testers (#3458) 2020-01-27 03:35:16 -05:00
Michael H
00bcd480e7 dev bump (#3455) 2020-01-26 20:39:38 -05:00
Michael H
0d3c72f356 changelog and bump (#3454) 2020-01-26 20:18:25 -05:00
Michael H
97a9fde5fd slowmode should properly error out on 7 hours now (#3453) 2020-01-27 02:01:22 +01:00
Michael H
a664615a2d shortdoc should be formatted too, + generic replacement method (#3451) 2020-01-27 01:25:58 +01:00
Michael H
3d4f9500e9 [Permissions] Ordering fix (#3452) 2020-01-27 01:00:08 +01:00
Michael H
a8450580e8 [Commands Module] Improve usability of type hints (#3410)
* [Commands Module] Better Typehint Support

  We now do a lot more with type hints

  - No more rexporting d.py commands submodules
  - New type aliases for GuildContext & DMContext
  - More things are typehinted

  Note: Some things are still not typed, others are still incorrectly
  typed, This is progress.

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-01-26 17:54:39 -05:00
Draper
8654924869 [Audio] Allow lazy searching for playlist across scopes (#3430)
* Allow lazy searching for playlist cross scope

* Chore
2020-01-26 16:38:49 -05:00
jack1142
068585379a docs: deprecation of shared libraries has been postponed to 3.4 (#3449) 2020-01-26 12:16:44 -05:00
jack1142
fc5fc08962 [Downloader] Log errors from initialization task (#3444)
* Update downloader.py

* Create 3444.misc.rst

* enhance(downloader): don't type infinitely on init error

* fix(downloader): unindent `_ready_raised` check

* Update downloader.py
2020-01-26 12:16:13 -05:00
Stonedestroyer
41fdcb2ae8 [Core] Embeds toggle for channels. (#3418)
* [Core] Embedset toggle for channels.

* Typo fix

* Add to contact as well

Thanks Jack.

* Add guild only and check.
2020-01-26 12:15:22 -05:00
Ianardo DiCaprio
de4804863a [Mod] Option to DM user with kick/ban reason. (#2990)
* FUCK

* FUCK

* FUCK

* Update kickban.py

* Update settings.py

* Update kickban.py

* Update kickban.py

* Add files via upload

* black

* Update kickban.py

* Update kickban.py

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update settings.py

* Update kickban.py

* Update and rename 2990.enhance.rst.txt.txt to 2990.enhance.rst.txt

* Update settings.py

* Rename 2990.enhance.rst.txt to 2990.enhance.rst

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: DevilXD <DevilXD@users.noreply.github.com>

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: DevilXD <DevilXD@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update changelog.d/mod/2990.enhance.rst

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update kickban.py

* Update settings.py

* Update kickban.py

* Update kickban.py

* Update redbot/cogs/mod/kickban.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update kickban.py

* Update kickban.py

* Update mod.py

* Update settings.py

* Fix SyntaxError

* Don't pass "No reason was given." to modlog case

* Update settings.py

* Update 2990.enhance.rst

* black

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
Co-authored-by: DevilXD <DevilXD@users.noreply.github.com>
2020-01-26 04:18:13 +01:00
Michael H
2ac4dde729 update for d.py 1.3 (#3445)
* update for d.py 1.3

* Update redbot/core/commands/commands.py

Co-Authored-By: Danny <Rapptz@users.noreply.github.com>

* a few more places we use owner info

* add the cli flag + handling

* set fix

* Handle MaxConcurrencyReached.

* Bump `aiohttp-json-rpc`

Co-authored-by: Danny <Rapptz@users.noreply.github.com>
Co-authored-by: Kowlin <Kowlin@users.noreply.github.com>
Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-01-25 18:59:08 -05:00
Michael H
498d0d22fb resolves #3443 (#3447) 2020-01-25 18:57:07 -05:00
jack1142
2a38777379 [Downloader] Do the initialization in task to avoid timeout on bot startup (#3440)
* enhance(downloader): run init in task

* chore(changelog): add towncrier entry

* fix: address review
2020-01-24 12:38:42 -05:00
jack1142
01c1fdfd16 [Mod] Make [p]hackban use default days setting too. (#3437)
* Update kickban.py

* freaking whitespace
2020-01-24 10:30:32 +00:00
Draper
0a8e7f5663 stop dc interacting with repeat (#3426) 2020-01-23 17:05:50 -05:00
Ianardo DiCaprio
1755334124 [Mod] Default days in [p]ban command are now configurable (#2930)
* Initial Commit

* Added changelog

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Update redbot/cogs/mod/settings.py

Co-Authored-By: jack1142 <6032823+jack1142@users.noreply.github.com>

* Rename 2930.enhance.rst.txt to 2930.enhance.rst

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
2020-01-23 20:37:11 +01:00
zephyrkul
40c0d8d83b [systemd] fix which cmd for pyenv (#3434) 2020-01-22 23:20:35 -05:00
DevilXD
ee53d50c3a Help delete pages delay feature (#3433)
* Added 'deletedelay' feature for help

* Fixes

* More fixes

* Use better message when disabling

* Added changelog entry

* Addressed feedback

* Improved the pages check

* Added additional command check

* Improved command description

* Final feedback improvements
2020-01-22 17:15:51 -05:00
Stonedestroyer
8570971f68 [Core] Make bot name adjustable in bot. (#3429)
* First draft

* Up for discussion

* Revert "Up for discussion"

This reverts commit 2f00b7ded8.
2020-01-22 12:52:06 -05:00
Stonedestroyer
e1a110b1bf [Misc] Typo fixes (#3427)
* [Misc] Typo fixes

* Changelog

* Trivia list

* Update 3427.misc.rst

* Changelog
2020-01-22 10:00:52 +00:00
Michael H
77235f7750 [commands] Implement __call__ to commands.Command (#3241)
* This is technically awesome, but let's not document it for public use rn

* changelog
2020-01-20 23:23:15 +01:00
jack1142
c7fd64e0c8 [Downloader] Improve InstalledCog converter's error message (#3409)
* Update converters.py

* Create 3409.misc.rst
2020-01-20 17:09:55 -05:00
Flame442
8f04fd436f Catches discord.NotFound in utils.mod.mass_purge (#3414)
* Catches `discord.NotFound` in `mass_purge`

* Create 3378.bugfix.rst
2020-01-20 17:09:17 -05:00
Stonedestroyer
b085c1501f [General] Max amount to roll (#3395)
* [General] Rolls max amount

Adds max amount to roll.

* Removed redundant code.

* QA changes

* Add typehinting.
2020-01-20 16:49:46 -05:00
Stonedestroyer
7f390df879 [Customcom] Fix error on exiting customcom interactive menu. (#3417)
* [Customcom] Fixes error on exit

* Changelog

* Fixed spelling.

* Typehinting
2020-01-19 18:08:31 +01:00
Flame442
54e65082bc [Admin] Notify when the user has/doesn't have the role when att… (#3408)
* Update admin.py

* Create 3010.enhance.rst
2020-01-18 18:10:25 +00:00
Michael H
4c77cde249 Merge branch 'V3/develop' into V3/feature/mutes 2020-01-17 20:25:45 -05:00
Michael H
826dae129e dev bump (#3406) 2020-01-17 20:23:16 -05:00
DiscordLiz
1cb43b11a1 Some old work and some new (#3362)
* Some old work, some new

* c:style

* remove wrong version
2020-01-14 22:17:54 -05:00
64 changed files with 2433 additions and 903 deletions

1
.github/CODEOWNERS vendored
View File

@@ -62,3 +62,4 @@ redbot/setup.py @tekulvw
# Others
.travis.yml @Kowlin
crowdin.yml @Kowlin
.github/workflows/* @Kowlin

28
.github/workflows/publish_crowdin.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Publish to Crowdin
on:
push:
tags:
- "*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: '3.8'
- name: Install dependencies
run: |
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==3.1
- name: Publish
env:
CROWDIN_API_KEY: ${{ secrets.crowdin_token}}
CROWDIN_PROJECT_ID: ${{ secrets.crowdin_identifier }}
run: |
make upload_translations

26
.github/workflows/publish_pypi.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Publish to PyPI
on:
push:
tags:
- "*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: '3.8'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.pypi_token }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*

73
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Tests
on: [push, pull_request]
jobs:
tox:
runs-on: ubuntu-latest
strategy:
matrix:
python_version:
- "3.8"
tox_env:
- py
- style
- docs
include:
- tox_env: py
friendly_name: Tests
- tox_env: style
friendly_name: Style
- tox_env: docs
friendly_name: Docs
fail-fast: false
name: Tox - ${{ matrix.friendly_name }}
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python_version }}
- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox
- name: Tox test
env:
TOXENV: ${{ matrix.tox_env }}
run: tox
tox-postgres:
runs-on: ubuntu-latest
strategy:
matrix:
python_version:
- "3.8"
fail-fast: false
name: Tox - Postgres
services:
postgresql:
image: postgres:10
ports:
- 5432:5432
env:
POSTGRES_DB: red_db
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python_version }}
- name: Install tox
run: |
python -m pip install --upgrade pip
pip install tox
- name: Tox test
env:
TOXENV: postgres
PGDATABASE: red_db
PGUSER: postgres
PGPASSWORD: postgres
PGPORT: 5432
run: tox

View File

@@ -26,8 +26,8 @@
</a>
</p>
<p align="center">
<a href="https://travis-ci.com/Cog-Creators/Red-DiscordBot">
<img src="https://api.travis-ci.com/Cog-Creators/Red-DiscordBot.svg?branch=V3/develop" alt="Travis CI">
<a href="https://github.com/Cog-Creators/Red-DiscordBot/actions">
<img src="https://github.com/Cog-Creators/Red-DiscordBot/workflows/Tests/badge.svg" alt="GitHub Actions">
</a>
<a href="http://red-discordbot.readthedocs.io/en/stable/?badge=stable">
<img src="https://readthedocs.org/projects/red-discordbot/badge/?version=stable" alt="Red on readthedocs.org">

View File

@@ -14,11 +14,11 @@ In order to create the service file, you will first need the location of your :c
# If redbot is installed in a virtualenv
source redenv/bin/activate
which python
# If you are using pyenv
pyenv shell <name>
which python
pyenv which python
Then create the new service file:
@@ -71,4 +71,4 @@ type the following command in the terminal, still by adding the instance name af
To view Reds log, you can acccess through journalctl:
:code:`sudo journalctl -u red@instancename`
:code:`sudo journalctl -eu red@instancename`

View File

@@ -238,7 +238,7 @@ Removals
~~~~~~~~
- ``[p]set owner`` and ``[p]set token`` have been removed in favor of managing server side. (`#2928 <https://github.com/Cog-Creators/Red-DiscordBot/issues/2928>`_)
- Shared libraries are marked for removal in Red 3.3. (`#3106 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3106>`_)
- Shared libraries are marked for removal in Red 3.4. (`#3106 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3106>`_)
- Removed ``[p]backup``. Use the cli command ``redbot-setup backup`` instead. (`#3235 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3235>`_)
- Removed the functions ``safe_delete``, ``fuzzy_command_search``, ``format_fuzzy_results`` and ``create_backup`` from ``redbot.core.utils``. (`#3240 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3240>`_)
- Removed a lot of the launcher's handled behavior. (`#3289 <https://github.com/Cog-Creators/Red-DiscordBot/issues/3289>`_)

102
docs/changelog_3_3_0.rst Normal file
View File

@@ -0,0 +1,102 @@
.. 3.3.x Changelogs
Redbot 3.3.1 (2020-02-05)
=========================
Core Bot
--------
- Add a cli flag for setting a max size of message cache
- Allow to edit prefix from command line using ``redbot --edit``.
- Some functions have been changed to no longer use deprecated asyncio functions
Core Commands
-------------
- The short help text for dm has been made more useful
- dm no longer allows owners to have the bot attempt to DM itself
Utils
-----
- Passing the event loop explicitly in utils is deprecated (Removal in 3.4)
Mod Cog
-------
- Hackban now works properly without being provided a number of days
Documentation Changes
---------------------
- Add ``-e`` flag to ``journalctl`` command in systemd guide so that it takes the user to the end of logs automatically.
- Added section to install docs for CentOS 8
- Improve usage of apt update in docs
Redbot 3.3.0 (2020-01-26)
=========================
Core Bot
--------
- The bot's description is now configurable.
- We now use discord.py 1.3.1, this comes with added teams support.
- The commands module has been slightly restructured to provide more useful data to developers.
- Help is now self consistent in the extra formatting used.
Core Commands
-------------
- Slowmode should no longer error on nonsensical time quantities.
- Embed use can be configured per channel as well.
Documentation
-------------
- We've made some small fixes to inaccurate instructions about installing with pyenv.
- Notes about deprecating in 3.3 have been altered to 3.4 to match the intended timeframe.
Admin
-----
- Gives feedback when adding or removing a role doesn't make sense.
Audio
-----
- Playlist finding is more intuitive.
- disconnect and repeat commands no longer interfere with eachother.
CustomCom
---------
- No longer errors when exiting an interactive menu.
Cleanup
-------
- A rare edge case involving messages which are deleted during cleanup and are the only message was fixed.
Downloader
----------
- Some user facing messages were improved.
- Downloader's initialization can no longer time out at startup.
General
-------
- Roll command will no longer attempt to roll obscenely large amounts.
Mod
---
- You can set a default amount of days to clean up when banning.
- Ban and hackban now use that default.
- Users can now optionally be DMed their ban reason.
Permissions
-----------
- Now has stronger enforcement of prioritizing botwide settings.

View File

@@ -6,7 +6,7 @@ Shared API Keys
Red has a central API key storage utilising the core bots config. This allows cog creators to add a single location to store API keys for their cogs which may be shared between other cogs.
There needs to be some consistency between cog creators when using shared API keys between cogs. To help make this easier service should be all **lowercase** and the key names should match the naming convetion of the API being accessed.
There needs to be some consistency between cog creators when using shared API keys between cogs. To help make this easier service should be all **lowercase** and the key names should match the naming convention of the API being accessed.
Example:

View File

@@ -7,7 +7,7 @@ Commands Package
This package acts almost identically to :doc:`discord.ext.commands <dpy:ext/commands/api>`; i.e.
all of the attributes from discord.py's are also in ours.
Some of these attributes, however, have been slightly modified, while others have been added to
extend functionlities used throughout the bot, as outlined below.
extend functionalities used throughout the bot, as outlined below.
.. autofunction:: redbot.core.commands.command
@@ -23,5 +23,14 @@ extend functionlities used throughout the bot, as outlined below.
.. autoclass:: redbot.core.commands.Context
:members:
.. autoclass:: redbot.core.commands.GuildContext
.. autoclass:: redbot.core.commands.DMContext
.. automodule:: redbot.core.commands.requires
:members: PrivilegeLevel, PermState, Requires
.. automodule:: redbot.core.commands.converter
:members:
:exclude-members: convert
:no-undoc-members:

View File

@@ -51,3 +51,9 @@ Common Filters
.. automodule:: redbot.core.utils.common_filters
:members:
Discord Helper Classes
======================
.. automodule:: redbot.core.utils.discord_helpers
:members:

View File

@@ -81,5 +81,5 @@ Keys specific to the cog info.json (case sensitive)
``SHARED_LIBRARY``. If ``SHARED_LIBRARY`` then ``hidden`` will be ``True``.
.. warning::
Shared libraries are deprecated since version 3.2 and are marked for removal in version 3.3.
Shared libraries are deprecated since version 3.2 and are marked for removal in version 3.4.

View File

@@ -57,6 +57,7 @@ Welcome to Red - Discord Bot's documentation!
:maxdepth: 2
:caption: Changelogs:
changelog_3_3_0
release_notes_3_2_0
changelog_3_2_0
changelog_3_1_0

View File

@@ -67,6 +67,25 @@ Complete the rest of the installation by `installing Python 3.8 with pyenv <inst
----
.. _install-centos8:
.. _install-rhel8:
~~~~~~~~~~~~~~~~~
CentOS and RHEL 8
~~~~~~~~~~~~~~~~~
.. code-block:: none
yum -y install epel-release
yum update -y
yum -y groupinstall development
yum -y install git zlib-devel bzip2 bzip2-devel readline-devel sqlite \
sqlite-devel openssl-devel xz xz-devel libffi-devel findutils java-11-openjdk
Complete the rest of the installation by `installing Python 3.8 with pyenv <install-python-pyenv>`.
----
.. _install-debian-stretch:
~~~~~~~~~~~~~~
@@ -231,14 +250,14 @@ We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
.. code-block:: none
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:git-core/ppa
sudo apt -y install software-properties-common
sudo add-apt-repository -yu ppa:git-core/ppa
We recommend adding the ``deadsnakes`` ppa to install Python 3.8.1 or greater:
.. code-block:: none
sudo add-apt-repository ppa:deadsnakes/ppa
sudo add-apt-repository -yu ppa:deadsnakes/ppa
Now install the pre-requirements with apt:
@@ -262,8 +281,8 @@ We recommend adding the ``git-core`` ppa to install Git 2.11 or greater:
.. code-block:: none
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:git-core/ppa
sudo apt -y install software-properties-common
sudo add-apt-repository -yu ppa:git-core/ppa
Now, to install non-native version of python on non-LTS versions of Ubuntu, we recommend
installing pyenv. To do this, first run the following commands:

View File

@@ -191,7 +191,7 @@ def _update_event_loop_policy():
_asyncio.set_event_loop_policy(_uvloop.EventLoopPolicy())
__version__ = "3.2.3"
__version__ = "3.3.2.dev1"
version_info = VersionInfo.from_str(__version__)
# Filter fuzzywuzzy slow sequence matcher warning

View File

@@ -1,7 +1,5 @@
#!/usr/bin/env python
# Discord Version check
import asyncio
import functools
import getpass
@@ -20,7 +18,7 @@ from typing import NoReturn
import discord
# Set the event loop policies here so any subsequent `get_event_loop()`
# Set the event loop policies here so any subsequent `new_event_loop()`
# calls, in particular those as a result of the following imports,
# return the correct loop object.
from redbot import _update_event_loop_policy, __version__
@@ -107,6 +105,7 @@ async def edit_instance(red, cli_flags):
no_prompt = cli_flags.no_prompt
token = cli_flags.token
owner = cli_flags.owner
prefix = cli_flags.prefix
old_name = cli_flags.instance_name
new_name = cli_flags.edit_instance_name
data_path = cli_flags.edit_data_path
@@ -119,14 +118,20 @@ async def edit_instance(red, cli_flags):
if new_name is None and confirm_overwrite:
print("--overwrite-existing-instance can't be used without --edit-instance-name argument")
sys.exit(1)
if no_prompt and all(to_change is None for to_change in (token, owner, new_name, data_path)):
if (
no_prompt
and all(to_change is None for to_change in (token, owner, new_name, data_path))
and not prefix
):
print(
"No arguments to edit were provided. Available arguments (check help for more "
"information): --edit-instance-name, --edit-data-path, --copy-data, --owner, --token"
"No arguments to edit were provided."
" Available arguments (check help for more information):"
" --edit-instance-name, --edit-data-path, --copy-data, --owner, --token, --prefix"
)
sys.exit(1)
await _edit_token(red, token, no_prompt)
await _edit_prefix(red, prefix, no_prompt)
await _edit_owner(red, owner, no_prompt)
data = deepcopy(data_manager.basic_config)
@@ -152,6 +157,26 @@ async def _edit_token(red, token, no_prompt):
print("Token updated.\n")
async def _edit_prefix(red, prefix, no_prompt):
if prefix:
prefixes = sorted(prefix, reverse=True)
await red._config.prefix.set(prefixes)
elif not no_prompt and confirm("Would you like to change instance's prefixes?", default=False):
print(
"Enter the prefixes, separated by a space (please note "
"that prefixes containing a space will need to be added with [p]set prefix)"
)
while True:
prefixes = input("> ").strip().split()
if not prefixes:
print("You need to pass at least one prefix!")
continue
prefixes = sorted(prefixes, reverse=True)
await red._config.prefix.set(prefixes)
print("Prefixes updated.\n")
break
async def _edit_owner(red, owner, no_prompt):
if owner:
if not (15 <= len(str(owner)) <= 21):
@@ -271,7 +296,8 @@ def handle_edit(cli_flags: Namespace):
"""
This one exists to not log all the things like it's a full run of the bot.
"""
loop = asyncio.get_event_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
data_manager.load_basic_configuration(cli_flags.instance_name)
red = Red(cli_flags=cli_flags, description="Red V3", dm_help=None, fetch_offline_members=True)
try:
@@ -283,6 +309,7 @@ def handle_edit(cli_flags: Namespace):
print("Aborted!")
finally:
loop.run_until_complete(asyncio.sleep(1))
asyncio.set_event_loop(None)
loop.stop()
loop.close()
sys.exit(0)
@@ -433,7 +460,8 @@ def main():
handle_edit(cli_flags)
return
try:
loop = asyncio.get_event_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if cli_flags.no_instance:
print(
@@ -497,6 +525,7 @@ def main():
# results in a resource warning instead
log.info("Please wait, cleaning up a bit more")
loop.run_until_complete(asyncio.sleep(2))
asyncio.set_event_loop(None)
loop.stop()
loop.close()
exit_code = red._shutdown_mode if red is not None else 1

View File

@@ -121,8 +121,13 @@ class Admin(commands.Cog):
async def _addrole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if member is None:
member = ctx.author
if role in member.roles:
await ctx.send(
_("{member.display_name} already has the role {role.name}.").format(
role=role, member=member
)
)
return
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_ADD).format(role=role, member=member))
return
@@ -146,8 +151,13 @@ class Admin(commands.Cog):
async def _removerole(
self, ctx: commands.Context, member: discord.Member, role: discord.Role, *, check_user=True
):
if member is None:
member = ctx.author
if role not in member.roles:
await ctx.send(
_("{member.display_name} does not have the role {role.name}.").format(
role=role, member=member
)
)
return
if check_user and not self.pass_user_hierarchy_check(ctx, role):
await ctx.send(_(USER_HIERARCHY_ISSUE_REMOVE).format(role=role, member=member))
return

View File

@@ -70,12 +70,12 @@ class Announcer:
failed.append(str(g.id))
await asyncio.sleep(0.5)
msg = (
_("I could not announce to the following server: ")
if len(failed) == 1
else _("I could not announce to the following servers: ")
)
if failed:
msg = (
_("I could not announce to the following server: ")
if len(failed) == 1
else _("I could not announce to the following servers: ")
)
msg += humanize_list(tuple(map(inline, failed)))
await self.ctx.bot.send_to_owners(msg)
await self.ctx.bot.send_to_owners(msg)
self.active = False

View File

@@ -90,7 +90,7 @@ class Alias(commands.Cog):
def is_command(self, alias_name: str) -> bool:
"""
The logic here is that if this returns true, the name shouldnt be used for an alias
The logic here is that if this returns true, the name should not be used for an alias
The function name can be changed when alias is reworked
"""
command = self.bot.get_command(alias_name)

View File

@@ -67,7 +67,7 @@ from .utils import *
_ = Translator("Audio", __file__)
__version__ = "1.1.0"
__version__ = "1.1.1"
__author__ = ["aikaterna", "Draper"]
log = logging.getLogger("red.audio")
@@ -705,7 +705,6 @@ class Audio(commands.Cog):
msg += _("Auto-disconnection at queue end: {true_or_false}.").format(
true_or_false=_("Enabled") if not disconnect else _("Disabled")
)
await self.config.guild(ctx.guild).repeat.set(not disconnect)
if disconnect is not True and autoplay is True:
msg += _("\nAuto-play has been disabled.")
await self.config.guild(ctx.guild).auto_play.set(False)
@@ -1151,11 +1150,11 @@ class Audio(commands.Cog):
`[p]audioset autoplay PersonalPlaylist --scope User --author Draper`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -3834,7 +3833,7 @@ class Audio(commands.Cog):
author: discord.User,
guild: discord.Guild,
specified_user: bool = False,
) -> Tuple[Optional[int], str]:
) -> Tuple[Optional[int], str, str]:
"""
Parameters
----------
@@ -3863,34 +3862,57 @@ class Audio(commands.Cog):
"""
correct_scope_matches: List[Playlist]
original_input = matches.get("arg")
correct_scope_matches_temp: MutableMapping = matches.get(scope)
lazy_match = False
if scope is None:
correct_scope_matches_temp: MutableMapping = matches.get("all")
lazy_match = True
else:
correct_scope_matches_temp: MutableMapping = matches.get(scope)
guild_to_query = guild.id
user_to_query = author.id
correct_scope_matches_user = []
correct_scope_matches_guild = []
correct_scope_matches_global = []
if not correct_scope_matches_temp:
return None, original_input
if scope == PlaylistScope.USER.value:
correct_scope_matches = [
p for p in correct_scope_matches_temp if user_to_query == p.scope_id
return None, original_input, scope or PlaylistScope.GUILD.value
if lazy_match or (scope == PlaylistScope.USER.value):
correct_scope_matches_user = [
p for p in matches.get(PlaylistScope.USER.value) if user_to_query == p.scope_id
]
elif scope == PlaylistScope.GUILD.value:
if lazy_match or (scope == PlaylistScope.GUILD.value and not correct_scope_matches_user):
if specified_user:
correct_scope_matches = [
correct_scope_matches_guild = [
p
for p in correct_scope_matches_temp
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id and p.author == user_to_query
]
else:
correct_scope_matches = [
p for p in correct_scope_matches_temp if guild_to_query == p.scope_id
correct_scope_matches_guild = [
p
for p in matches.get(PlaylistScope.GUILD.value)
if guild_to_query == p.scope_id
]
else:
if lazy_match or (
scope == PlaylistScope.GLOBAL.value
and not correct_scope_matches_user
and not correct_scope_matches_guild
):
if specified_user:
correct_scope_matches = [
p for p in correct_scope_matches_temp if p.author == user_to_query
correct_scope_matches_global = [
p
for p in matches.get(PlaylistScope.USGLOBALER.value)
if p.author == user_to_query
]
else:
correct_scope_matches = [p for p in correct_scope_matches_temp]
correct_scope_matches_global = [p for p in matches.get(PlaylistScope.GLOBAL.value)]
correct_scope_matches = [
*correct_scope_matches_global,
*correct_scope_matches_guild,
*correct_scope_matches_user,
]
match_count = len(correct_scope_matches)
if match_count > 1:
correct_scope_matches2 = [
@@ -3917,14 +3939,15 @@ class Audio(commands.Cog):
).format(match_count=match_count, original_input=original_input)
)
elif match_count == 1:
return correct_scope_matches[0].id, original_input
return correct_scope_matches[0].id, original_input, correct_scope_matches[0].scope
elif match_count == 0:
return None, original_input
return None, original_input, scope
# TODO : Convert this section to a new paged reaction menu when Toby Menus are Merged
pos_len = 3
playlists = f"{'#':{pos_len}}\n"
number = 0
correct_scope_matches = sorted(correct_scope_matches, key=lambda x: x.name.lower())
for number, playlist in enumerate(correct_scope_matches, 1):
author = self.bot.get_user(playlist.author) or playlist.author or _("Unknown")
line = _(
@@ -3937,7 +3960,7 @@ class Audio(commands.Cog):
).format(
number=number,
playlist=playlist,
scope=humanize_scope(scope),
scope=humanize_scope(playlist.scope),
tracks=len(playlist.tracks),
author=author,
)
@@ -3973,7 +3996,11 @@ class Audio(commands.Cog):
)
with contextlib.suppress(discord.HTTPException):
await msg.delete()
return correct_scope_matches[pred.result].id, original_input
return (
correct_scope_matches[pred.result].id,
original_input,
correct_scope_matches[pred.result].scope,
)
@commands.group()
@commands.guild_only()
@@ -4036,12 +4063,12 @@ class Audio(commands.Cog):
`[p]playlist append MyGlobalPlaylist Hello by Adele --scope Global --Author Draper#6666`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
(scope, author, guild, specified_user) = scope_data
if not await self._playlist_check(ctx):
return
try:
(playlist_id, playlist_arg) = await self._get_correct_playlist_id(
(playlist_id, playlist_arg, scope) = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4223,7 +4250,7 @@ class Audio(commands.Cog):
) = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, from_scope, from_author, from_guild, specified_from_user
)
except TooManyMatches as e:
@@ -4401,11 +4428,11 @@ class Audio(commands.Cog):
`[p]playlist delete MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4489,19 +4516,18 @@ class Audio(commands.Cog):
"""
async with ctx.typing():
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(
@@ -4632,11 +4658,11 @@ class Audio(commands.Cog):
`[p]playlist download MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -4772,19 +4798,19 @@ class Audio(commands.Cog):
`[p]playlist info MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
ctx.command.reset_cooldown(ctx)
return await self._embed_msg(
@@ -5132,18 +5158,18 @@ class Audio(commands.Cog):
`[p]playlist remove MyPersonalPlaylist https://www.youtube.com/watch?v=MN3x-kAbgFU --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
return await self._embed_msg(ctx, title=str(e))
scope_name = humanize_scope(
scope, ctx=guild if scope == PlaylistScope.GUILD.value else author
)
if playlist_id is None:
return await self._embed_msg(
ctx,
@@ -5339,7 +5365,7 @@ class Audio(commands.Cog):
`[p]playlist start MyPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
dj_enabled = self._dj_status_cache.setdefault(
ctx.guild.id, await self.config.guild(ctx.guild).dj_enabled()
@@ -5355,7 +5381,7 @@ class Audio(commands.Cog):
return False
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -5510,10 +5536,10 @@ class Audio(commands.Cog):
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:
@@ -5789,7 +5815,7 @@ class Audio(commands.Cog):
`[p]playlist rename MyPersonalPlaylist RenamedPersonalPlaylist --scope User`
"""
if scope_data is None:
scope_data = [PlaylistScope.GUILD.value, ctx.author, ctx.guild, False]
scope_data = [None, ctx.author, ctx.guild, False]
scope, author, guild, specified_user = scope_data
new_name = new_name.split(" ")[0].strip('"')[:32]
@@ -5805,7 +5831,7 @@ class Audio(commands.Cog):
)
try:
playlist_id, playlist_arg = await self._get_correct_playlist_id(
playlist_id, playlist_arg, scope = await self._get_correct_playlist_id(
ctx, playlist_matches, scope, author, guild, specified_user
)
except TooManyMatches as e:

View File

@@ -158,6 +158,7 @@ class PlaylistConverter(commands.Converter):
PlaylistScope.GLOBAL.value: global_matches,
PlaylistScope.GUILD.value: guild_matches,
PlaylistScope.USER.value: user_matches,
"all": [*global_matches, *guild_matches, *user_matches],
"arg": arg,
}
@@ -170,7 +171,7 @@ class NoExitParser(argparse.ArgumentParser):
class ScopeParser(commands.Converter):
async def convert(
self, ctx: commands.Context, argument: str
) -> Tuple[str, discord.User, Optional[discord.Guild], bool]:
) -> Tuple[Optional[str], discord.User, Optional[discord.Guild], bool]:
target_scope: Optional[str] = None
target_user: Optional[Union[discord.Member, discord.User]] = None
@@ -261,7 +262,7 @@ class ScopeParser(commands.Converter):
elif any(x in argument for x in ["--author", "--user", "--member"]):
raise commands.ArgParserFailure("--scope", "Nothing", custom_help=_USER_HELP)
target_scope: str = target_scope or PlaylistScope.GUILD.value
target_scope: str = target_scope or None
target_user: Union[discord.Member, discord.User] = target_user or ctx.author
target_guild: discord.Guild = target_guild or ctx.guild

View File

@@ -227,6 +227,9 @@ class CustomCommands(commands.Cog):
await ctx.send(_("There already exists a bot command with the same name."))
return
responses = await self.commandobj.get_responses(ctx=ctx)
if not responses:
await ctx.send(_("Custom command process cancelled."))
return
try:
await self.commandobj.create(ctx=ctx, command=command, response=responses)
await ctx.send(_("Custom command successfully added."))

View File

@@ -3,5 +3,5 @@ from .downloader import Downloader
async def setup(bot):
cog = Downloader(bot)
await cog.initialize()
bot.add_cog(cog)
cog.create_init_task()

View File

@@ -15,6 +15,8 @@ class InstalledCog(InstalledModule):
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(
_("Cog `{cog_name}` is not installed.").format(cog_name=arg)
)
return cog

View File

@@ -29,7 +29,7 @@ _ = Translator("Downloader", __file__)
DEPRECATION_NOTICE = _(
"\n**WARNING:** The following repos are using shared libraries"
" which are marked for removal in Red 3.3: {repo_list}.\n"
" which are marked for removal in Red 3.4: {repo_list}.\n"
" You should inform maintainers of these repos about this message."
)
@@ -53,6 +53,9 @@ class Downloader(commands.Cog):
self._create_lib_folder()
self._repo_manager = RepoManager()
self._ready = asyncio.Event()
self._init_task = None
self._ready_raised = False
def _create_lib_folder(self, *, remove_first: bool = False) -> None:
if remove_first:
@@ -62,9 +65,38 @@ class Downloader(commands.Cog):
with self.SHAREDLIB_INIT.open(mode="w", encoding="utf-8") as _:
pass
async def cog_before_invoke(self, ctx: commands.Context) -> None:
async with ctx.typing():
await self._ready.wait()
if self._ready_raised:
await ctx.send(
"There was an error during Downloader's initialization."
" Check logs for more information."
)
raise commands.CheckFailure()
def cog_unload(self):
if self._init_task is not None:
self._init_task.cancel()
def create_init_task(self):
def _done_callback(task: asyncio.Task) -> None:
exc = task.exception()
if exc is not None:
log.error(
"An unexpected error occurred during Downloader's initialization.",
exc_info=exc,
)
self._ready_raised = True
self._ready.set()
self._init_task = asyncio.create_task(self.initialize())
self._init_task.add_done_callback(_done_callback)
async def initialize(self) -> None:
await self._repo_manager.initialize()
await self._maybe_update_config()
self._ready.set()
async def _maybe_update_config(self) -> None:
schema_version = await self.conf.schema_version()
@@ -205,7 +237,7 @@ class Downloader(commands.Cog):
await self.conf.installed_libraries.set(installed_libraries)
async def _shared_lib_load_check(self, cog_name: str) -> Optional[Repo]:
# remove in Red 3.3
# remove in Red 3.4
is_installed, cog = await self.is_installed(cog_name)
# it's not gonna be None when `is_installed` is True
# if we'll use typing_extensions in future, `Literal` can solve this
@@ -430,7 +462,7 @@ class Downloader(commands.Cog):
if not deps:
await ctx.send_help()
return
repo = Repo("", "", "", "", Path.cwd(), loop=ctx.bot.loop)
repo = Repo("", "", "", "", Path.cwd())
async with ctx.typing():
success = await repo.install_raw_requirements(deps, self.LIB_PATH)

View File

@@ -135,7 +135,6 @@ class Repo(RepoJSONMixin):
commit: str,
folder_path: Path,
available_modules: Tuple[Installable, ...] = (),
loop: Optional[asyncio.AbstractEventLoop] = None,
):
self.url = url
self.branch = branch
@@ -154,8 +153,6 @@ class Repo(RepoJSONMixin):
self._repo_lock = asyncio.Lock()
self._loop = loop if loop is not None else asyncio.get_event_loop()
@property
def clean_url(self) -> str:
"""Sanitized repo URL (with removed HTTP Basic Auth)"""
@@ -529,7 +526,7 @@ class Repo(RepoJSONMixin):
env["LANGUAGE"] = "C"
kwargs["env"] = env
async with self._repo_lock:
p: CompletedProcess = await self._loop.run_in_executor(
p: CompletedProcess = await asyncio.get_running_loop().run_in_executor(
self._executor,
functools.partial(sp_run, *args, stdout=PIPE, stderr=PIPE, **kwargs),
)

View File

@@ -2,6 +2,7 @@ import datetime
import time
from enum import Enum
from random import randint, choice
from typing import Final
import aiohttp
import discord
from redbot.core import commands
@@ -31,6 +32,9 @@ class RPSParser:
self.choice = None
MAX_ROLL: Final[int] = 2 ** 64 - 1
@cog_i18n(_)
class General(commands.Cog):
"""General commands."""
@@ -87,15 +91,21 @@ class General(commands.Cog):
`<number>` defaults to 100.
"""
author = ctx.author
if number > 1:
if 1 < number <= MAX_ROLL:
n = randint(1, number)
await ctx.send(
"{author.mention} :game_die: {n} :game_die:".format(
author=author, n=humanize_number(n)
)
)
else:
elif number <= 1:
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author))
else:
await ctx.send(
_("{author.mention} Max allowed number is {maxamount}.").format(
author=author, maxamount=humanize_number(MAX_ROLL)
)
)
@commands.command()
async def flip(self, ctx, user: discord.Member = None):

View File

@@ -101,7 +101,7 @@ class Events(MixinMeta):
while None in name_list: # clean out null entries from a bug
name_list.remove(None)
if after.name in name_list:
# Ensure order is maintained without duplicates occuring
# Ensure order is maintained without duplicates occurring
name_list.remove(after.name)
name_list.append(after.name)
while len(name_list) > 20:

View File

@@ -7,7 +7,7 @@ from typing import cast, Optional, Union
import discord
from redbot.core import commands, i18n, checks, modlog
from redbot.core.utils.chat_formatting import pagify, humanize_number
from redbot.core.utils.chat_formatting import pagify, humanize_number, bold, format_perms_list
from redbot.core.utils.mod import is_allowed_by_hierarchy, get_audit_reason
from .abc import MixinMeta
from .converters import RawUserIds
@@ -21,6 +21,48 @@ class KickBanMixin(MixinMeta):
Kick and ban commands and tasks go here.
"""
@staticmethod
async def _voice_perm_check(
ctx: commands.Context, user_voice_state: Optional[discord.VoiceState], **perms: bool
) -> bool:
"""Check if the bot and user have sufficient permissions for voicebans.
This also verifies that the user's voice state and connected
channel are not ``None``.
Returns
-------
bool
``True`` if the permissions are sufficient and the user has
a valid voice state.
"""
if user_voice_state is None or user_voice_state.channel is None:
await ctx.send(_("That user is not in a voice channel."))
return False
voice_channel: discord.VoiceChannel = user_voice_state.channel
required_perms = discord.Permissions()
required_perms.update(**perms)
if not voice_channel.permissions_for(ctx.me) >= required_perms:
await ctx.send(
_("I require the {perms} permission(s) in that user's channel to do that.").format(
perms=format_perms_list(required_perms)
)
)
return False
if (
ctx.permission_state is commands.PermState.NORMAL
and not voice_channel.permissions_for(ctx.author) >= required_perms
):
await ctx.send(
_(
"You must have the {perms} permission(s) in that user's channel to use this "
"command."
).format(perms=format_perms_list(required_perms))
)
return False
return True
@staticmethod
async def get_invite_for_reinvite(ctx: commands.Context, max_age: int = 86400):
"""Handles the reinvite logic for getting an invite
@@ -82,6 +124,19 @@ class KickBanMixin(MixinMeta):
elif not (0 <= days <= 7):
return _("Invalid days. Must be between 0 and 7.")
toggle = await self.settings.guild(guild).dm_on_kickban()
if toggle:
with contextlib.suppress(discord.HTTPException):
em = discord.Embed(
title=bold(_("You have been banned from {guild}.").format(guild=guild))
)
em.add_field(
name=_("**Reason**"),
value=reason if reason is not None else _("No reason was given."),
inline=False,
)
await user.send(embed=em)
audit_reason = get_audit_reason(author, reason)
queue_entry = (guild.id, user.id)
@@ -95,7 +150,7 @@ class KickBanMixin(MixinMeta):
except discord.Forbidden:
return _("I'm not allowed to do that.")
except Exception as e:
return e # TODO: impproper return type? Is this intended to be re-raised?
return e # TODO: improper return type? Is this intended to be re-raised?
if create_modlog_case:
try:
@@ -186,6 +241,18 @@ class KickBanMixin(MixinMeta):
await ctx.send(_("I cannot do that due to discord hierarchy rules"))
return
audit_reason = get_audit_reason(author, reason)
toggle = await self.settings.guild(guild).dm_on_kickban()
if toggle:
with contextlib.suppress(discord.HTTPException):
em = discord.Embed(
title=bold(_("You have been kicked from {guild}.").format(guild=guild))
)
em.add_field(
name=_("**Reason**"),
value=reason if reason is not None else _("No reason was given."),
inline=False,
)
await user.send(embed=em)
try:
await guild.kick(user, reason=audit_reason)
log.info("{}({}) kicked {}({})".format(author.name, author.id, user.name, user.id))
@@ -218,14 +285,19 @@ class KickBanMixin(MixinMeta):
self,
ctx: commands.Context,
user: discord.Member,
days: Optional[int] = 0,
days: Optional[int] = None,
*,
reason: str = None,
):
"""Ban a user from this server and optionally delete days of messages.
If days is not a number, it's treated as the first word of the reason.
Minimum 0 days, maximum 7. Defaults to 0."""
Minimum 0 days, maximum 7. If not specified, defaultdays setting will be used instead."""
author = ctx.author
guild = ctx.guild
if days is None:
days = await self.settings.guild(guild).default_days()
result = await self.ban_user(
user=user, ctx=ctx, days=days, reason=reason, create_modlog_case=True
@@ -244,7 +316,7 @@ class KickBanMixin(MixinMeta):
self,
ctx: commands.Context,
user_ids: commands.Greedy[RawUserIds],
days: Optional[int] = 0,
days: Optional[int] = None,
*,
reason: str = None,
):
@@ -252,7 +324,6 @@ class KickBanMixin(MixinMeta):
User IDs need to be provided in order to ban
using this command"""
days = cast(int, days)
banned = []
errors = {}
@@ -279,6 +350,9 @@ class KickBanMixin(MixinMeta):
await ctx.send_help()
return
if days is None:
days = await self.settings.guild(guild).default_days()
if not (0 <= days <= 7):
await ctx.send(_("Invalid days. Must be between 0 and 7."))
return

View File

@@ -10,7 +10,6 @@ from .casetypes import CASETYPES
from .events import Events
from .kickban import KickBanMixin
from .movetocore import MoveToCore
from .mutes import MuteMixin
from .names import ModInfo
from .slowmode import Slowmode
from .settings import ModSettings
@@ -35,7 +34,6 @@ class Mod(
Events,
KickBanMixin,
MoveToCore,
MuteMixin,
ModInfo,
Slowmode,
commands.Cog,
@@ -53,6 +51,8 @@ class Mod(
"delete_delay": -1,
"reinvite_on_unban": False,
"current_tempbans": [],
"dm_on_kickban": False,
"default_days": 0,
}
default_channel_settings = {"ignored": False}

View File

@@ -1,465 +0,0 @@
import asyncio
from typing import cast, Optional
import discord
from redbot.core import commands, checks, i18n, modlog
from redbot.core.utils.chat_formatting import format_perms_list
from redbot.core.utils.mod import get_audit_reason, is_allowed_by_hierarchy
from .abc import MixinMeta
T_ = i18n.Translator("Mod", __file__)
_ = lambda s: s
mute_unmute_issues = {
"already_muted": _("That user can't send messages in this channel."),
"already_unmuted": _("That user isn't muted in this channel."),
"hierarchy_problem": _(
"I cannot let you do that. You are not higher than the user in the role hierarchy."
),
"is_admin": _("That user cannot be muted, as they have the Administrator permission."),
"permissions_issue": _(
"Failed to mute user. I need the manage roles "
"permission and the user I'm muting must be "
"lower than myself in the role hierarchy."
),
}
_ = T_
class MuteMixin(MixinMeta):
"""
Stuff for mutes goes here
"""
@staticmethod
async def _voice_perm_check(
ctx: commands.Context, user_voice_state: Optional[discord.VoiceState], **perms: bool
) -> bool:
"""Check if the bot and user have sufficient permissions for voicebans.
This also verifies that the user's voice state and connected
channel are not ``None``.
Returns
-------
bool
``True`` if the permissions are sufficient and the user has
a valid voice state.
"""
if user_voice_state is None or user_voice_state.channel is None:
await ctx.send(_("That user is not in a voice channel."))
return False
voice_channel: discord.VoiceChannel = user_voice_state.channel
required_perms = discord.Permissions()
required_perms.update(**perms)
if not voice_channel.permissions_for(ctx.me) >= required_perms:
await ctx.send(
_("I require the {perms} permission(s) in that user's channel to do that.").format(
perms=format_perms_list(required_perms)
)
)
return False
if (
ctx.permission_state is commands.PermState.NORMAL
and not voice_channel.permissions_for(ctx.author) >= required_perms
):
await ctx.send(
_(
"You must have the {perms} permission(s) in that user's channel to use this "
"command."
).format(perms=format_perms_list(required_perms))
)
return False
return True
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(mute_members=True, deafen_members=True)
async def voiceunban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
"""Unban a user from speaking and listening in the server's voice channels."""
user_voice_state = user.voice
if (
await self._voice_perm_check(
ctx, user_voice_state, deafen_members=True, mute_members=True
)
is False
):
return
needs_unmute = True if user_voice_state.mute else False
needs_undeafen = True if user_voice_state.deaf else False
audit_reason = get_audit_reason(ctx.author, reason)
if needs_unmute and needs_undeafen:
await user.edit(mute=False, deafen=False, reason=audit_reason)
elif needs_unmute:
await user.edit(mute=False, reason=audit_reason)
elif needs_undeafen:
await user.edit(deafen=False, reason=audit_reason)
else:
await ctx.send(_("That user isn't muted or deafened by the server!"))
return
guild = ctx.guild
author = ctx.author
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"voiceunban",
user,
author,
reason,
until=None,
channel=None,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(_("User is now allowed to speak and listen in voice channels"))
@commands.command()
@commands.guild_only()
@checks.admin_or_permissions(mute_members=True, deafen_members=True)
async def voiceban(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
"""Ban a user from speaking and listening in the server's voice channels."""
user_voice_state: discord.VoiceState = user.voice
if (
await self._voice_perm_check(
ctx, user_voice_state, deafen_members=True, mute_members=True
)
is False
):
return
needs_mute = True if user_voice_state.mute is False else False
needs_deafen = True if user_voice_state.deaf is False else False
audit_reason = get_audit_reason(ctx.author, reason)
author = ctx.author
guild = ctx.guild
if needs_mute and needs_deafen:
await user.edit(mute=True, deafen=True, reason=audit_reason)
elif needs_mute:
await user.edit(mute=True, reason=audit_reason)
elif needs_deafen:
await user.edit(deafen=True, reason=audit_reason)
else:
await ctx.send(_("That user is already muted and deafened server-wide!"))
return
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"voiceban",
user,
author,
reason,
until=None,
channel=None,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(_("User has been banned from speaking or listening in voice channels"))
@commands.group()
@commands.guild_only()
@checks.mod_or_permissions(manage_channels=True)
async def mute(self, ctx: commands.Context):
"""Mute users."""
pass
@mute.command(name="voice")
@commands.guild_only()
async def voice_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
"""Mute a user in their current voice channel."""
user_voice_state = user.voice
if (
await self._voice_perm_check(
ctx, user_voice_state, mute_members=True, manage_channels=True
)
is False
):
return
guild = ctx.guild
author = ctx.author
channel = user_voice_state.channel
audit_reason = get_audit_reason(author, reason)
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
if success:
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"vmute",
user,
author,
reason,
until=None,
channel=channel,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(
_("Muted {user} in channel {channel.name}").format(user=user, channel=channel)
)
else:
await ctx.send(issue)
@mute.command(name="channel")
@commands.guild_only()
@commands.bot_has_permissions(manage_roles=True)
@checks.mod_or_permissions(administrator=True)
async def channel_mute(
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
):
"""Mute a user in the current text channel."""
author = ctx.message.author
channel = ctx.message.channel
guild = ctx.guild
audit_reason = get_audit_reason(author, reason)
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
if success:
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"cmute",
user,
author,
reason,
until=None,
channel=channel,
)
except RuntimeError as e:
await ctx.send(e)
await channel.send(_("User has been muted in this channel."))
else:
await channel.send(issue)
@mute.command(name="server", aliases=["guild"])
@commands.guild_only()
@commands.bot_has_permissions(manage_roles=True)
@checks.mod_or_permissions(administrator=True)
async def guild_mute(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
"""Mutes user in the server"""
author = ctx.message.author
guild = ctx.guild
audit_reason = get_audit_reason(author, reason)
mute_success = []
for channel in guild.channels:
success, issue = await self.mute_user(guild, channel, author, user, audit_reason)
mute_success.append((success, issue))
await asyncio.sleep(0.1)
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"smute",
user,
author,
reason,
until=None,
channel=None,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(_("User has been muted in this server."))
@commands.group()
@commands.guild_only()
@commands.bot_has_permissions(manage_roles=True)
@checks.mod_or_permissions(manage_channels=True)
async def unmute(self, ctx: commands.Context):
"""Unmute users."""
pass
@unmute.command(name="voice")
@commands.guild_only()
async def unmute_voice(
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
):
"""Unmute a user in their current voice channel."""
user_voice_state = user.voice
if (
await self._voice_perm_check(
ctx, user_voice_state, mute_members=True, manage_channels=True
)
is False
):
return
guild = ctx.guild
author = ctx.author
channel = user_voice_state.channel
audit_reason = get_audit_reason(author, reason)
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
if success:
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"vunmute",
user,
author,
reason,
until=None,
channel=channel,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(
_("Unmuted {user} in channel {channel.name}").format(user=user, channel=channel)
)
else:
await ctx.send(_("Unmute failed. Reason: {}").format(message))
@checks.mod_or_permissions(administrator=True)
@unmute.command(name="channel")
@commands.bot_has_permissions(manage_roles=True)
@commands.guild_only()
async def unmute_channel(
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
):
"""Unmute a user in this channel."""
channel = ctx.channel
author = ctx.author
guild = ctx.guild
audit_reason = get_audit_reason(author, reason)
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
if success:
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"cunmute",
user,
author,
reason,
until=None,
channel=channel,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(_("User unmuted in this channel."))
else:
await ctx.send(_("Unmute failed. Reason: {}").format(message))
@checks.mod_or_permissions(administrator=True)
@unmute.command(name="server", aliases=["guild"])
@commands.bot_has_permissions(manage_roles=True)
@commands.guild_only()
async def unmute_guild(
self, ctx: commands.Context, user: discord.Member, *, reason: str = None
):
"""Unmute a user in this server."""
guild = ctx.guild
author = ctx.author
audit_reason = get_audit_reason(author, reason)
unmute_success = []
for channel in guild.channels:
success, message = await self.unmute_user(guild, channel, author, user, audit_reason)
unmute_success.append((success, message))
await asyncio.sleep(0.1)
try:
await modlog.create_case(
self.bot,
guild,
ctx.message.created_at,
"sunmute",
user,
author,
reason,
until=None,
)
except RuntimeError as e:
await ctx.send(e)
await ctx.send(_("User has been unmuted in this server."))
async def mute_user(
self,
guild: discord.Guild,
channel: discord.abc.GuildChannel,
author: discord.Member,
user: discord.Member,
reason: str,
) -> (bool, str):
"""Mutes the specified user in the specified channel"""
overwrites = channel.overwrites_for(user)
permissions = channel.permissions_for(user)
if permissions.administrator:
return False, _(mute_unmute_issues["is_admin"])
new_overs = {}
if not isinstance(channel, discord.TextChannel):
new_overs.update(speak=False)
if not isinstance(channel, discord.VoiceChannel):
new_overs.update(send_messages=False, add_reactions=False)
if all(getattr(permissions, p) is False for p in new_overs.keys()):
return False, _(mute_unmute_issues["already_muted"])
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
return False, _(mute_unmute_issues["hierarchy_problem"])
old_overs = {k: getattr(overwrites, k) for k in new_overs}
overwrites.update(**new_overs)
try:
await channel.set_permissions(user, overwrite=overwrites, reason=reason)
except discord.Forbidden:
return False, _(mute_unmute_issues["permissions_issue"])
else:
await self.settings.member(user).set_raw(
"perms_cache", str(channel.id), value=old_overs
)
return True, None
async def unmute_user(
self,
guild: discord.Guild,
channel: discord.abc.GuildChannel,
author: discord.Member,
user: discord.Member,
reason: str,
) -> (bool, str):
overwrites = channel.overwrites_for(user)
perms_cache = await self.settings.member(user).perms_cache()
if channel.id in perms_cache:
old_values = perms_cache[channel.id]
else:
old_values = {"send_messages": None, "add_reactions": None, "speak": None}
if all(getattr(overwrites, k) == v for k, v in old_values.items()):
return False, _(mute_unmute_issues["already_unmuted"])
elif not await is_allowed_by_hierarchy(self.bot, self.settings, guild, author, user):
return False, _(mute_unmute_issues["hierarchy_problem"])
overwrites.update(**old_values)
try:
if overwrites.is_empty():
await channel.set_permissions(
user, overwrite=cast(discord.PermissionOverwrite, None), reason=reason
)
else:
await channel.set_permissions(user, overwrite=overwrites, reason=reason)
except discord.Forbidden:
return False, _(mute_unmute_issues["permissions_issue"])
else:
await self.settings.member(user).clear_raw("perms_cache", str(channel.id))
return True, None

View File

@@ -21,11 +21,14 @@ class ModSettings(MixinMeta):
if ctx.invoked_subcommand is None:
guild = ctx.guild
# Display current settings
delete_repeats = await self.settings.guild(guild).delete_repeats()
ban_mention_spam = await self.settings.guild(guild).ban_mention_spam()
respect_hierarchy = await self.settings.guild(guild).respect_hierarchy()
delete_delay = await self.settings.guild(guild).delete_delay()
reinvite_on_unban = await self.settings.guild(guild).reinvite_on_unban()
data = await self.settings.guild(guild).all()
delete_repeats = data["delete_repeats"]
ban_mention_spam = data["ban_mention_spam"]
respect_hierarchy = data["respect_hierarchy"]
delete_delay = data["delete_delay"]
reinvite_on_unban = data["reinvite_on_unban"]
dm_on_kickban = data["dm_on_kickban"]
default_days = data["default_days"]
msg = ""
msg += _("Delete repeats: {num_repeats}\n").format(
num_repeats=_("after {num} repeats").format(num=delete_repeats)
@@ -48,6 +51,15 @@ class ModSettings(MixinMeta):
msg += _("Reinvite on unban: {yes_or_no}\n").format(
yes_or_no=_("Yes") if reinvite_on_unban else _("No")
)
msg += _("Send message to users on kick/ban: {yes_or_no}\n").format(
yes_or_no=_("Yes") if dm_on_kickban else _("No")
)
if default_days:
msg += _(
"Default message history delete on ban: Previous {num_days} days\n"
).format(num_days=default_days)
else:
msg += _("Default message history delete on ban: Don't delete any\n")
await ctx.send(box(msg))
@modset.command()
@@ -199,3 +211,43 @@ class ModSettings(MixinMeta):
command=f"{ctx.prefix}unban"
)
)
@modset.command()
@commands.guild_only()
async def dm(self, ctx: commands.Context, enabled: bool = None):
"""Toggle whether a message should be sent to a user when they are kicked/banned.
If this option is enabled, the bot will attempt to DM the user with the guild name
and reason as to why they were kicked/banned.
"""
guild = ctx.guild
if enabled is None:
setting = await self.settings.guild(guild).dm_on_kickban()
await ctx.send(
_("DM when kicked/banned is currently set to: {setting}").format(setting=setting)
)
return
await self.settings.guild(guild).dm_on_kickban.set(enabled)
if enabled:
await ctx.send(_("Bot will now attempt to send a DM to user before kick and ban."))
else:
await ctx.send(
_("Bot will no longer attempt to send a DM to user before kick and ban.")
)
@modset.command()
@commands.guild_only()
async def defaultdays(self, ctx: commands.Context, days: int = 0):
"""Set the default number of days worth of messages to be deleted when a user is banned.
The number of days must be between 0 and 7.
"""
guild = ctx.guild
if not (0 <= days <= 7):
return await ctx.send(_("Invalid number of days. Must be between 0 and 7."))
await self.settings.guild(guild).default_days.set(days)
await ctx.send(
_("{days} days worth of messages will be deleted when a user is banned.").format(
days=days
)
)

View File

@@ -0,0 +1,5 @@
from .mutes import Mutes
def setup(bot):
bot.add_cog(Mutes(bot))

View File

@@ -0,0 +1,21 @@
class ControlFlowException(Exception):
"""
The base exception for any exceptions used solely for control flow
If this or any subclass of this ever propogates, something has gone wrong.
"""
pass
class NoChangeError(ControlFlowException):
pass
class PermError(ControlFlowException):
"""
An error to be raised when a permission issue is detected prior to an api call being made
"""
def __init__(self, friendly_error=None, *args):
self.friendly_error = friendly_error
super().__init__(*args)

410
redbot/cogs/mutes/mutes.py Normal file
View File

@@ -0,0 +1,410 @@
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta, datetime
from typing import Awaitable, Dict, NamedTuple, Optional, Tuple, Union, no_type_check
import discord
from redbot.core import commands, checks, modlog
from redbot.core.commands import TimedeltaConverter
from redbot.core.config import Config
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.discord_helpers import OverwriteDiff
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.dbtools import APSWConnectionWrapper as Connection
from . import utils
from .errors import NoChangeError, PermError
TaskDict = Dict[Tuple[int, int], asyncio.Task]
_ = Translator("Mutes", __file__)
log = logging.getLogger("red.mutes")
@cog_i18n(_)
class Mutes(commands.Cog):
"""
A cog to mute users with.
"""
def __init__(self, bot):
self.bot = bot
self.conn = Connection(cog_data_path(self) / "mutes.db")
self.config = Config.get_conf(self, identifier=240961564503441410)
self.config.register_guild(
mute_deny_text=2112, # send, react
mute_deny_voice=2097152, # speak
excluded_channel_ids=[],
)
self._unmute_task = asyncio.create_task(self.unmute_loop())
self._task_queue = asyncio.Queue()
self._server_unmute_tasks: TaskDict = {}
self._channel_unmute_tasks: TaskDict = {}
self._ready = asyncio.Event()
self.bot.loop.create_task(self._cog_init())
async def _cog_init(self):
with self.conn.with_cursor() as cursor:
cursor.execute("""PRAGMA journal_mode=wal""")
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS mutes(
user_id INTEGER NOT NULL,
channel_id INTEGER NOT NULL,
guild_id INTEGER NOT NULL,
allows_added INTEGER,
allows_removed INTEGER,
denies_added INTEGER,
denies_removed INTEGER,
expires_at INTEGER,
PRIMARY KEY (user_id, channel_id)
);
"""
)
self._ready.set()
async def cog_before_invoke(self):
await self._ready.wait()
def cog_unload(self):
self.unmute_task.cancel()
for task in self._server_unmute_tasks.values():
task.cancel()
for task in self._channel_unmute_tasks.values():
task.cancel()
def _clean_task_dict(self, task_dict):
is_debug = log.getEffectiveLevel() <= logging.DEBUG
for k in list(task_dict.keys()):
task = task_dict[k]
if task.canceled():
task_dict.pop(k, None)
continue
if task.done():
try:
r = task.result()
except Exception:
# Log exception info for dead tasks, but only while debugging.
if is_debug:
log.exception("Dead server unmute task.")
task_dict.pop(k, None)
async def unmute_loop(self):
await self.bot.wait_until_ready()
while True:
async with self._task_lock:
self._clean_task_dict(self._server_unmute_tasks)
self._clean_task_dict(self._channel_unmute_tasks)
await self._schedule_unmutes(300)
await asyncio.sleep(300)
async def _schedule_unmutes(self, schedule_by_seconds: int = 300):
"""
Schedules unmuting.
Mutes get scheduled as tasks so that mute extensions or changes to make a mute
permanent can have a scheduled mute be canceled.
"""
raise NotImplementedError() # TODO
async def _cancel_channel_mute_delayed(self, *, delay: float, channel_id: int, member_id: int):
"""
After a delay, attempt to unmute someone
"""
raise NotImplementedError() # TODO
async def _cancel_server_mute_delayed(self, *, delay: float, guild_id: int, member_id: int):
"""
After a delay, attempt to unmute someone.
"""
await asyncio.sleep(delay)
guild = self.bot.get_guild(guild_id)
if not guild:
return
member = guild.get_member(member_id)
if not member: # Still clear this to avoid re-muting on-join after expiration.
pass
# TODO
@staticmethod
async def channel_mute_with_diff(
*,
channel: discord.abc.GuildChannel,
target: Union[discord.Role, discord.Member],
deny_value: int,
reason: Optional[str] = None,
) -> OverwriteDiff:
"""
Parameters
----------
channel : discord.abc.GuildChannel
target : Union[discord.Role, discord.Member]
deny_value : int
The permissions values which should be denied.
reason : str
Returns
-------
OverwriteDiff
Raises
------
discord.Forbidden
see `discord.abc.GuildChannel.set_permissions`
discord.NotFound
see `discord.abc.GuildChannel.set_permissions`
discord.HTTPException
see `discord.abc.GuildChannel.set_permissions`
NoChangeError
the edit was aborted due to no change
in permissions between initial and requested
"""
diff_to_apply = OverwriteDiff(denies_added=deny_value)
start = channel.overwrites_for(target)
new_overwrite = start + diff_to_apply
result_diff = OverwriteDiff.from_overwrites(before=start, after=new_overwrite)
if not result_diff:
raise NoChangeError() from None
await channel.set_permissions(target, overwrite=new_overwrite, reason=reason)
return result_diff
@staticmethod
async def channel_unmute_from_diff(
*,
channel: discord.abc.GuildChannel,
target: Union[discord.Role, discord.Member],
diff: OverwriteDiff,
reason: Optional[str] = None,
):
"""
Parameters
----------
channel : discord.abc.GuildChannel
target : Union[discord.Role, discord.Member]
diff : OverwriteDiff
The recorded difference from a prior mute to undo
reason : str
Raises
------
discord.Forbidden
see `discord.abc.GuildChannel.set_permissions`
discord.NotFound
see `discord.abc.GuildChannel.set_permissions`
discord.HTTPException
see `discord.abc.GuildChannel.set_permissions`
NoChangeError
the edit was aborted due to no change
in permissions between initial and requested
"""
start = channel.overwrites_for(target)
new_overwrite = start - diff
if start == new_overwrite:
raise NoChangeError()
await channel.set_permissions(target, overwrite=new_overwrite, reason=reason)
async def do_command_server_mute(
self,
*,
ctx: commands.Context,
target: discord.Member,
duration: Optional[timedelta] = None,
reason: str,
):
"""
This avoids duplicated logic with the option to use
the command group as one of the commands itself.
Parameters
----------
ctx : commands.Context
The context the command was invoked in
target : discord.Member
The person to mute
duration : Optional[timedelta]
If provided, the amount of time to mute the user for
reason : str
The reason for the mute
"""
raise NotImplementedError() # TODO
async def apply_server_mute(
self,
*,
target: Optional[discord.Member] = None,
mod: discord.Member,
duration: Optional[timedelta],
reason: Optional[str] = None,
target_id: Optional[int] = None,
):
"""
Applies a mute server wide
Parameters
----------
target : Optional[discord.Member]
The member to be muted. This can only be omitted if ``target_id`` is supplied.
target_id : Optional[int]
The member id to mute. This can only be omitted if ``target`` is supplied.
mod : discord.Member
The responisble moderator
duration : Optional[timedelta]
If provided, the mute is considered temporary, and should be scheduled
for unmute after this period of time.
reason : Optional[str]
If provided, the reason for muting a user.
This should be the reason from the moderator's perspective.
All formatting should take place here.
This should be less than 900 characters long.
Longer reasons will be truncated.
Returns
-------
ServerMuteResults
A class which contains the mute results
and some helpers for providing them to users.
Raises
------
NoChangeError
If the server mute would result in zero changes.
ValueError
Raised if not given a target or target id, or if the target is not in the guild
PermError
Raised if we detect an invalid target or bot permissions.
This error will contain a user-friendly error message.
discord.Forbidden
This will only be raised for 2FA related forbiddens,
or if the bot's allowed permissions change mid operation.
discord.HTTPException
Sometimes the API gives these back without a reason.
"""
raise NotImplementedError() # TODO
async def do_command_server_unmute(
self, *, ctx: commands.Context, target: discord.Member, reason: str
):
"""
All actual command logic.
"""
raise NotImplementedError() # TODO
async def do_command_channel_mute(
self,
*,
ctx: commands.Context,
target: discord.Member,
channel: discord.abc.GuildChannel,
duration: Optional[timedelta] = None,
reason: str,
):
"""
All actual command logic.
"""
async def do_command_channel_unmute(
self,
*,
ctx: commands.Context,
target: discord.Member,
channel: discord.abc.GuildChannel,
reason: str,
):
"""
All actual command logic.
"""
raise NotImplementedError() # TODO
@checks.admin_or_permissions(manage_guild=True)
@commands.group()
async def _muteset(self, ctx: commands.Context):
"""
Allows configuring [botname]'s mute behavior.
"""
pass
@checks.mod()
@commands.group(name="mute")
@no_type_check
async def mute_group(self, ctx):
"""
Mutes users.
"""
pass
@checks.mod()
@commands.group(name="tempmute")
@no_type_check
async def tempmute_group(
self,
ctx,
target: discord.Member = None,
duration: TimedeltaConverter = None,
*,
reason: str = None,
):
"""
Mutes users, for some amount of time.
"""
pass
@checks.mod()
@mute_group.command(name="channel")
@no_type_check
async def mute_channel(self, ctx, target: discord.Member, *, reason: str = ""):
"""
Mutes a user in the current channel.
"""
await self.do_command_channel_mute(
ctx=ctx, target=target, reason=reason, channel=ctx.channel, duration=None
)
@checks.mod()
@mute_group.command(name="server", aliases=["guild"])
@no_type_check
async def mute_server(self, ctx, target: discord.Member, *, reason: str = ""):
"""
Mutes a user in the current server.
"""
await self.do_command_server_mute(ctx=ctx, target=target, reason=reason, duration=None)
@checks.mod()
@tempmute_group.command(name="channel")
@no_type_check
async def tempmute_channel(
self, ctx, target: discord.Member, duration: TimedeltaConverter, *, reason: str = ""
):
"""
Mutes a user in the current channel.
"""
await self.do_command_channel_mute(
ctx=ctx, target=target, reason=reason, channel=ctx.channel, duration=duration
)
@checks.mod()
@tempmute_group.command(name="server", aliases=["guild"])
@no_type_check
async def tempmute_server(
self, ctx, target: discord.Member, duration: TimedeltaConverter, *, reason: str = ""
):
"""
Mutes a user in the current server.
"""
await self.do_command_server_mute(ctx=ctx, target=target, reason=reason, duration=duration)

View File

@@ -0,0 +1,53 @@
import discord
from redbot.core.i18n import Translator
from .errors import PermError
_ = Translator("Mutes", __file__)
def ngettext(singular: str, plural: str, count: int, **fmt_kwargs) -> str:
"""
This isn't a full ngettext.
Replace this with babel when Red can use that.
"""
return singular.format(**fmt_kwargs) if count == 1 else plural.format(**fmt_kwargs)
def hierarchy_check(*, mod: discord.Member, target: discord.Member):
"""
Checks that things are hierarchy safe.
This does not check the bot can modify permissions.
This is assumed to be checked prior to command invocation.
Parameters
-----------
mod : discord.Member
The responsible moderator
target : discord.Member
The target of a mute
Raises
------
PermError
Any of:
- The target is above either the mod or bot.
- The target had the administrator perm
- The target is the guild owner
This error will contain a user facing error message.
"""
if target == target.guild.owner:
raise PermError(friendly_error=_("You can't mute the owner of a guild."))
if target.guild_permissions.administrator:
raise PermError(
friendly_error=_("You can't mute someone with the administrator permission.")
)
if target.top_role >= target.guild.me:
raise PermError(friendly_error=_("I can't mute this user. (Discord Hierarchy applies)"))
if target.top_role >= mod.top_role:
raise PermError(friendly_error=_("You can't mute this user. (Discord Hierarchy applies)"))

View File

@@ -61,7 +61,7 @@ At which Arena can you unlock X-Bow?:
- 6
- Builder's Workshop
At which Arena do you get a chance for Legendary cards to appear in the shop?:
- Hog Mountian
- Hog Mountain
- A10
- 10
- Arena 10

View File

@@ -375,7 +375,7 @@ Porky Pig had a girlfriend named ________?:
Randy Travis said his love was 'deeper than the ______'?:
- Holler
Richard Strauss' majestic overture "Also Sprach Zarathustra" was the theme music for which Stanley Kubrick film?:
- "2001: A Space Odyessy"
- "2001: A Space Odyssey"
Rolling Stones first hit was written by what group?:
- The Beatles
Russian modernist Igor _________?:

View File

@@ -22,7 +22,7 @@ class SharedLibImportWarner(MetaPathFinder):
return None
msg = (
"One of cogs uses shared libraries which are"
" deprecated and scheduled for removal in Red 3.3.\n"
" deprecated and scheduled for removal in Red 3.4.\n"
"You should inform author of the cog about this message."
)
warnings.warn(msg, SharedLibDeprecationWarning, stacklevel=2)

View File

@@ -26,7 +26,9 @@ from typing import (
from types import MappingProxyType
import discord
from discord.ext import commands as dpy_commands
from discord.ext.commands import when_mentioned_or
from discord.ext.commands.bot import BotBase
from . import Config, i18n, commands, errors, drivers, modlog, bank
from .cog_manager import CogManager, CogManagerUI
@@ -59,7 +61,9 @@ def _is_submodule(parent, child):
# barely spurious warning caused by our intentional shadowing
class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: disable=no-member
class RedBase(
commands.GroupMixin, dpy_commands.bot.BotBase, RPCMixin
): # pylint: disable=no-member
"""Mixin for the main bot class.
This exists because `Red` inherits from `discord.AutoShardedClient`, which
@@ -88,6 +92,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
custom_info=None,
help__page_char_limit=1000,
help__max_pages_in_guild=2,
help__delete_delay=0,
help__use_menus=False,
help__show_hidden=False,
help__verify_checks=True,
@@ -119,6 +124,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
autoimmune_ids=[],
)
self._config.register_channel(embeds=None)
self._config.register_user(embeds=None)
self._config.init_custom(CUSTOM_GROUPS, 2)
@@ -143,12 +149,19 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
if "command_not_found" not in kwargs:
kwargs["command_not_found"] = "Command {} not found.\n{}"
message_cache_size = cli_flags.message_cache_size
if cli_flags.no_message_cache:
message_cache_size = None
kwargs["max_messages"] = message_cache_size
self._max_messages = message_cache_size
self._uptime = None
self._checked_time_accuracy = None
self._color = discord.Embed.Empty # This is needed or color ends up 0x000000
self._main_dir = bot_dir
self._cog_mgr = CogManager()
self._use_team_features = cli_flags.use_team_features
super().__init__(*args, help_command=None, **kwargs)
# Do not manually use the help formatter attribute here, see `send_help_for`,
# for a documented API. The internals of this object are still subject to change.
@@ -159,6 +172,16 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
self._red_ready = asyncio.Event()
self._red_before_invoke_objs: Set[PreInvokeCoroutine] = set()
def get_command(self, name: str) -> Optional[commands.Command]:
com = super().get_command(name)
assert com is None or isinstance(com, commands.Command)
return com
def get_cog(self, name: str) -> Optional[commands.Cog]:
cog = super().get_cog(name)
assert cog is None or isinstance(cog, commands.Cog)
return cog
@property
def _before_invoke(self): # DEP-WARN
return self._red_before_invoke_method
@@ -186,9 +209,9 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
def before_invoke(self, coro: T_BIC) -> T_BIC:
"""
Overridden decorator method for Red's ``before_invoke`` behavior.
This can safely be used purely functionally as well.
3rd party cogs should remove any hooks which they register at unload
using `remove_before_invoke_hook`
@@ -199,12 +222,12 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
only called if all checks and argument parsing procedures pass
without error. If any check or argument parsing procedures fail
then the hooks are not called.
Parameters
----------
coro: Callable[[commands.Context], Awaitable[Any]]
The coroutine to register as the pre-invoke hook.
Raises
------
TypeError
@@ -254,6 +277,10 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
def colour(self) -> NoReturn:
raise AttributeError("Please fetch the embed colour with `get_embed_colour`")
@property
def max_messages(self) -> Optional[int]:
return self._max_messages
async def allowed_by_whitelist_blacklist(
self,
who: Optional[Union[discord.Member, discord.User]] = None,
@@ -298,7 +325,7 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
------
TypeError
Did not provide ``who`` or ``who_id``
Returns
-------
bool
@@ -619,6 +646,9 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
if user_setting is not None:
return user_setting
else:
channel_setting = await self._config.channel(channel).embeds()
if channel_setting is not None:
return channel_setting
guild_setting = await self._config.guild(channel.guild).embeds()
if guild_setting is not None:
return guild_setting
@@ -626,10 +656,42 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
global_setting = await self._config.embeds()
return global_setting
async def is_owner(self, user) -> bool:
async def is_owner(self, user: Union[discord.User, discord.Member]) -> bool:
"""
Determines if the user should be considered a bot owner.
This takes into account CLI flags and application ownership.
By default,
application team members are not considered owners,
while individual application owners are.
Parameters
----------
user: Union[discord.User, discord.Member]
Returns
-------
bool
"""
if user.id in self._co_owners:
return True
return await super().is_owner(user)
if self.owner_id:
return self.owner_id == user.id
elif self.owner_ids:
return user.id in self.owner_ids
else:
app = await self.application_info()
if app.team:
if self._use_team_features:
self.owner_ids = ids = {m.id for m in app.team.members}
return user.id in ids
else:
self.owner_id = owner_id = app.owner.id
return user.id == owner_id
return False
async def is_admin(self, member: discord.Member) -> bool:
"""Checks if a member is an admin of their guild."""
@@ -1068,10 +1130,11 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): # pylint: d
await self.wait_until_red_ready()
destinations = []
opt_outs = await self._config.owner_opt_out_list()
for user_id in (self.owner_id, *self._co_owners):
team_ids = () if not self._use_team_features else self.owner_ids
for user_id in set((self.owner_id, *self._co_owners, *team_ids)):
if user_id not in opt_outs:
user = self.get_user(user_id)
if user:
if user and not user.bot: # user.bot is possible with flags and teams
destinations.append(user)
else:
log.warning(

View File

@@ -74,6 +74,22 @@ async def interactive_config(red, token_set, prefix_set, *, print_header=True):
return token
def positive_int(arg: str) -> int:
try:
x = int(arg)
except ValueError:
raise argparse.ArgumentTypeError("Message cache size has to be a number.")
if x < 1000:
raise argparse.ArgumentTypeError(
"Message cache size has to be greater than or equal to 1000."
)
if x > sys.maxsize:
raise argparse.ArgumentTypeError(
f"Message cache size has to be lower than or equal to {sys.maxsize}."
)
return x
def parse_cli_flags(args):
parser = argparse.ArgumentParser(
description="Red - Discord Bot", usage="redbot <instance_name> [arguments]"
@@ -90,7 +106,7 @@ def parse_cli_flags(args):
action="store_true",
help="Edit the instance. This can be done without console interaction "
"by passing --no-prompt and arguments that you want to change (available arguments: "
"--edit-instance-name, --edit-data-path, --copy-data, --owner, --token).",
"--edit-instance-name, --edit-data-path, --copy-data, --owner, --token, --prefix).",
)
parser.add_argument(
"--edit-instance-name",
@@ -200,6 +216,27 @@ def parse_cli_flags(args):
parser.add_argument(
"instance_name", nargs="?", help="Name of the bot instance created during `redbot-setup`."
)
parser.add_argument(
"--team-members-are-owners",
action="store_true",
dest="use_team_features",
default=False,
help=(
"Treat application team members as owners. "
"This is off by default. Owners can load and run arbitrary code. "
"Do not enable if you would not trust all of your team members with "
"all of the data on the host machine."
),
)
parser.add_argument(
"--message-cache-size",
type=positive_int,
default=1000,
help="Set the maximum number of messages to store in the internal message cache.",
)
parser.add_argument(
"--no-message-cache", action="store_true", help="Disable the internal message cache.",
)
args = parser.parse_args(args)

View File

@@ -1,7 +1,145 @@
from discord.ext.commands import *
from .commands import *
from .context import *
from .converter import *
from .errors import *
from .requires import *
from .help import *
########## SENSITIVE SECTION WARNING ###########
################################################
# Any edits of any of the exported names #
# may result in a breaking change. #
# Ensure no names are removed without warning. #
################################################
from .commands import (
Cog as Cog,
CogMixin as CogMixin,
CogCommandMixin as CogCommandMixin,
CogGroupMixin as CogGroupMixin,
Command as Command,
Group as Group,
GroupMixin as GroupMixin,
command as command,
group as group,
RESERVED_COMMAND_NAMES as RESERVED_COMMAND_NAMES,
)
from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext
from .converter import (
APIToken as APIToken,
DictConverter as DictConverter,
GuildConverter as GuildConverter,
TimedeltaConverter as TimedeltaConverter,
get_dict_converter as get_dict_converter,
get_timedelta_converter as get_timedelta_converter,
parse_timedelta as parse_timedelta,
NoParseOptional as NoParseOptional,
UserInputOptional as UserInputOptional,
Literal as Literal,
)
from .errors import (
ConversionFailure as ConversionFailure,
BotMissingPermissions as BotMissingPermissions,
UserFeedbackCheckFailure as UserFeedbackCheckFailure,
ArgParserFailure as ArgParserFailure,
)
from .help import (
red_help as red_help,
RedHelpFormatter as RedHelpFormatter,
HelpSettings as HelpSettings,
)
from .requires import (
CheckPredicate as CheckPredicate,
DM_PERMS as DM_PERMS,
GlobalPermissionModel as GlobalPermissionModel,
GuildPermissionModel as GuildPermissionModel,
PermissionModel as PermissionModel,
PrivilegeLevel as PrivilegeLevel,
PermState as PermState,
Requires as Requires,
permissions_check as permissions_check,
bot_has_permissions as bot_has_permissions,
has_permissions as has_permissions,
has_guild_permissions as has_guild_permissions,
is_owner as is_owner,
guildowner as guildowner,
guildowner_or_permissions as guildowner_or_permissions,
admin as admin,
admin_or_permissions as admin_or_permissions,
mod as mod,
mod_or_permissions as mod_or_permissions,
)
from ._dpy_reimplements import (
check as check,
guild_only as guild_only,
cooldown as cooldown,
dm_only as dm_only,
is_nsfw as is_nsfw,
has_role as has_role,
has_any_role as has_any_role,
bot_has_role as bot_has_role,
when_mentioned_or as when_mentioned_or,
when_mentioned as when_mentioned,
bot_has_any_role as bot_has_any_role,
)
### DEP-WARN: Check this *every* discord.py update
from discord.ext.commands import (
BadArgument as BadArgument,
EmojiConverter as EmojiConverter,
InvalidEndOfQuotedStringError as InvalidEndOfQuotedStringError,
MemberConverter as MemberConverter,
BotMissingRole as BotMissingRole,
PrivateMessageOnly as PrivateMessageOnly,
HelpCommand as HelpCommand,
MinimalHelpCommand as MinimalHelpCommand,
DisabledCommand as DisabledCommand,
ExtensionFailed as ExtensionFailed,
Bot as Bot,
NotOwner as NotOwner,
CategoryChannelConverter as CategoryChannelConverter,
CogMeta as CogMeta,
ConversionError as ConversionError,
UserInputError as UserInputError,
Converter as Converter,
InviteConverter as InviteConverter,
ExtensionError as ExtensionError,
Cooldown as Cooldown,
CheckFailure as CheckFailure,
MessageConverter as MessageConverter,
MissingPermissions as MissingPermissions,
BadUnionArgument as BadUnionArgument,
DefaultHelpCommand as DefaultHelpCommand,
ExtensionNotFound as ExtensionNotFound,
UserConverter as UserConverter,
MissingRole as MissingRole,
CommandOnCooldown as CommandOnCooldown,
MissingAnyRole as MissingAnyRole,
ExtensionNotLoaded as ExtensionNotLoaded,
clean_content as clean_content,
CooldownMapping as CooldownMapping,
ArgumentParsingError as ArgumentParsingError,
RoleConverter as RoleConverter,
CommandError as CommandError,
TextChannelConverter as TextChannelConverter,
UnexpectedQuoteError as UnexpectedQuoteError,
Paginator as Paginator,
BucketType as BucketType,
NoEntryPointError as NoEntryPointError,
CommandInvokeError as CommandInvokeError,
TooManyArguments as TooManyArguments,
Greedy as Greedy,
ExpectedClosingQuoteError as ExpectedClosingQuoteError,
ColourConverter as ColourConverter,
VoiceChannelConverter as VoiceChannelConverter,
NSFWChannelRequired as NSFWChannelRequired,
IDConverter as IDConverter,
MissingRequiredArgument as MissingRequiredArgument,
GameConverter as GameConverter,
CommandNotFound as CommandNotFound,
BotMissingAnyRole as BotMissingAnyRole,
NoPrivateMessage as NoPrivateMessage,
AutoShardedBot as AutoShardedBot,
ExtensionAlreadyLoaded as ExtensionAlreadyLoaded,
PartialEmojiConverter as PartialEmojiConverter,
check_any as check_any,
max_concurrency as max_concurrency,
CheckAnyFailure as CheckAnyFailure,
MaxConcurrency as MaxConcurrency,
MaxConcurrencyReached as MaxConcurrencyReached,
bot_has_guild_permissions as bot_has_guild_permissions,
)

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import inspect
import functools
from typing import (
TypeVar,
Callable,
Awaitable,
Coroutine,
Union,
Type,
TYPE_CHECKING,
List,
Any,
Generator,
Protocol,
overload,
)
import discord
from discord.ext import commands as dpy_commands
# So much of this can be stripped right back out with proper stubs.
if not TYPE_CHECKING:
from discord.ext.commands import (
check as check,
guild_only as guild_only,
dm_only as dm_only,
is_nsfw as is_nsfw,
has_role as has_role,
has_any_role as has_any_role,
bot_has_role as bot_has_role,
bot_has_any_role as bot_has_any_role,
cooldown as cooldown,
)
from ..i18n import Translator
from .context import Context
from .commands import Command
_ = Translator("nah", __file__)
"""
Anything here is either a reimplementation or re-export
of a discord.py funtion or class with more lies for mypy
"""
__all__ = [
"check",
# "check_any", # discord.py 1.3
"guild_only",
"dm_only",
"is_nsfw",
"has_role",
"has_any_role",
"bot_has_role",
"bot_has_any_role",
"when_mentioned_or",
"cooldown",
"when_mentioned",
]
_CT = TypeVar("_CT", bound=Context)
_T = TypeVar("_T")
_F = TypeVar("_F")
CheckType = Union[Callable[[_CT], bool], Callable[[_CT], Coroutine[Any, Any, bool]]]
CoroLike = Callable[..., Union[Awaitable[_T], Generator[Any, None, _T]]]
class CheckDecorator(Protocol):
predicate: Coroutine[Any, Any, bool]
@overload
def __call__(self, func: _CT) -> _CT:
...
@overload
def __call__(self, func: CoroLike) -> CoroLike:
...
if TYPE_CHECKING:
def check(predicate: CheckType) -> CheckDecorator:
...
def guild_only() -> CheckDecorator:
...
def dm_only() -> CheckDecorator:
...
def is_nsfw() -> CheckDecorator:
...
def has_role() -> CheckDecorator:
...
def has_any_role() -> CheckDecorator:
...
def bot_has_role() -> CheckDecorator:
...
def bot_has_any_role() -> CheckDecorator:
...
def cooldown(rate: int, per: float, type: dpy_commands.BucketType = ...) -> Callable[[_F], _F]:
...
PrefixCallable = Callable[[dpy_commands.bot.BotBase, discord.Message], List[str]]
def when_mentioned(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]:
return [f"<@{bot.user.id}> ", f"<@!{bot.user.id}> "]
def when_mentioned_or(*prefixes) -> PrefixCallable:
def inner(bot: dpy_commands.bot.BotBase, msg: discord.Message) -> List[str]:
r = list(prefixes)
r = when_mentioned(bot, msg) + r
return r
return inner

View File

@@ -1,24 +1,53 @@
"""Module for command helpers and classes.
This module contains extended classes and functions which are intended to
replace those from the `discord.ext.commands` module.
be used instead of those from the `discord.ext.commands` module.
"""
from __future__ import annotations
import inspect
import re
import weakref
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from typing import (
Awaitable,
Callable,
Coroutine,
TypeVar,
Type,
Dict,
List,
Optional,
Tuple,
Union,
MutableMapping,
TYPE_CHECKING,
cast,
)
import discord
from discord.ext import commands
from discord.ext.commands import (
BadArgument,
CommandError,
CheckFailure,
DisabledCommand,
command as dpy_command_deco,
Command as DPYCommand,
Cog as DPYCog,
CogMeta as DPYCogMeta,
Group as DPYGroup,
Greedy,
)
from . import converter as converters
from .errors import ConversionFailure
from .requires import PermState, PrivilegeLevel, Requires
from .requires import PermState, PrivilegeLevel, Requires, PermStateAllowedStates
from ..i18n import Translator
if TYPE_CHECKING:
# circular import avoidance
from .context import Context
__all__ = [
"Cog",
"CogMixin",
@@ -38,11 +67,17 @@ RESERVED_COMMAND_NAMES = (
)
_ = Translator("commands.commands", __file__)
DisablerDictType = MutableMapping[discord.Guild, Callable[["Context"], Awaitable[bool]]]
class CogCommandMixin:
"""A mixin for cogs and commands."""
@property
def help(self) -> str:
"""To be defined by subclasses"""
...
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if isinstance(self, Command):
@@ -58,6 +93,45 @@ class CogCommandMixin:
checks=getattr(decorated, "__requires_checks__", []),
)
def format_text_for_context(self, ctx: "Context", text: str) -> str:
"""
This formats text based on values in context
The steps are (currently, roughly) the following:
- substitute ``[p]`` with ``ctx.clean_prefix``
- substitute ``[botname]`` with ``ctx.me.display_name``
More steps may be added at a later time.
Cog creators should only override this if they want
help text to be modified, and may also want to
look at `format_help_for_context` and (for commands only)
``format_shortdoc_for_context``
Parameters
----------
ctx: Context
text: str
Returns
-------
str
text which has had some portions replaced based on context
"""
formatting_pattern = re.compile(r"\[p\]|\[botname\]")
def replacement(m: re.Match) -> str:
s = m.group(0)
if s == "[p]":
return ctx.clean_prefix
if s == "[botname]":
return ctx.me.display_name
# We shouldnt get here:
return s
return formatting_pattern.sub(replacement, text)
def format_help_for_context(self, ctx: "Context") -> str:
"""
This formats the help string based on values in context
@@ -88,18 +162,7 @@ class CogCommandMixin:
# Short circuit out on an empty help string
return help_str
formatting_pattern = re.compile(r"\[p\]|\[botname\]")
def replacement(m: re.Match) -> str:
s = m.group(0)
if s == "[p]":
return ctx.clean_prefix
if s == "[botname]":
return ctx.me.display_name
# We shouldnt get here:
return s
return formatting_pattern.sub(replacement, help_str)
return self.format_text_for_context(ctx, help_str)
def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
"""Actively allow this command for the given model.
@@ -182,7 +245,7 @@ class CogCommandMixin:
self.deny_to(Requires.DEFAULT, guild_id=guild_id)
class Command(CogCommandMixin, commands.Command):
class Command(CogCommandMixin, DPYCommand):
"""Command class for Red.
This should not be created directly, and instead via the decorator.
@@ -198,10 +261,21 @@ class Command(CogCommandMixin, commands.Command):
`Requires.checks`.
translator : Translator
A translator for this command's help docstring.
ignore_optional_for_conversion : bool
A value which can be set to not have discord.py's
argument parsing behavior for ``typing.Optional``
(type used will be of the inner type instead)
"""
def __call__(self, *args, **kwargs):
if self.cog:
# We need to inject cog as self here
return self.callback(self.cog, *args, **kwargs)
else:
return self.callback(*args, **kwargs)
def __init__(self, *args, **kwargs):
self.ignore_optional_for_conversion = kwargs.pop("ignore_optional_for_conversion", False)
super().__init__(*args, **kwargs)
self._help_override = kwargs.pop("help_override", None)
self.translator = kwargs.pop("i18n", None)
@@ -222,8 +296,62 @@ class Command(CogCommandMixin, commands.Command):
# Red specific
other.requires = self.requires
other.ignore_optional_for_conversion = self.ignore_optional_for_conversion
return other
@property
def callback(self):
return self._callback
@callback.setter
def callback(self, function):
"""
Below should be mostly the same as discord.py
The only (current) change is to filter out typing.Optional
if a user has specified the desire for this behavior
"""
self._callback = function
self.module = function.__module__
signature = inspect.signature(function)
self.params = signature.parameters.copy()
# PEP-563 allows postponing evaluation of annotations with a __future__
# import. When postponed, Parameter.annotation will be a string and must
# be replaced with the real value for the converters to work later on
for key, value in self.params.items():
if isinstance(value.annotation, str):
self.params[key] = value = value.replace(
annotation=eval(value.annotation, function.__globals__)
)
# fail early for when someone passes an unparameterized Greedy type
if value.annotation is Greedy:
raise TypeError("Unparameterized Greedy[...] is disallowed in signature.")
if not self.ignore_optional_for_conversion:
continue # reduces indentation compared to alternative
try:
vtype = value.annotation.__origin__
if vtype is Union:
_NoneType = type if TYPE_CHECKING else type(None)
args = value.annotation.__args__
if _NoneType in args:
args = tuple(a for a in args if a is not _NoneType)
if len(args) == 1:
# can't have a union of 1 or 0 items
# 1 prevents this from becoming 0
# we need to prevent 2 become 1
# (Don't change that to becoming, it's intentional :musical_note:)
self.params[key] = value = value.replace(annotation=args[0])
else:
# and mypy wretches at the correct Union[args]
temp_type = type if TYPE_CHECKING else Union[args]
self.params[key] = value = value.replace(annotation=temp_type)
except AttributeError:
continue
@property
def help(self):
"""Help string for this command.
@@ -304,7 +432,7 @@ class Command(CogCommandMixin, commands.Command):
for parent in reversed(self.parents):
try:
result = await parent.can_run(ctx, change_permission_state=True)
except commands.CommandError:
except CommandError:
result = False
if result is False:
@@ -323,14 +451,24 @@ class Command(CogCommandMixin, commands.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")
async def prepare(self, ctx):
ctx.command = self
if not (await self.can_run(ctx, change_permission_state=True)):
raise commands.CheckFailure(
f"The check functions for command {self.qualified_name} failed."
)
if not self.enabled:
raise DisabledCommand(f"{self.name} command is disabled")
if not await self.can_run(ctx, change_permission_state=True):
raise CheckFailure(f"The check functions for command {self.qualified_name} failed.")
if self.cooldown_after_parsing:
await self._parse_arguments(ctx)
self._prepare_cooldowns(ctx)
else:
self._prepare_cooldowns(ctx)
await self._parse_arguments(ctx)
if self._max_concurrency is not None:
await self._max_concurrency.acquire(ctx)
await self.call_before_hooks(ctx)
async def do_conversion(
self, ctx: "Context", converter, argument: str, param: inspect.Parameter
@@ -354,7 +492,7 @@ class Command(CogCommandMixin, commands.Command):
try:
return await super().do_conversion(ctx, converter, argument, param)
except commands.BadArgument as exc:
except BadArgument as exc:
raise ConversionFailure(converter, argument, param, *exc.args) from exc
except ValueError as exc:
# Some common converters need special treatment...
@@ -389,7 +527,7 @@ class Command(CogCommandMixin, commands.Command):
can_run = await self.can_run(
ctx, check_all_parents=True, change_permission_state=False
)
except (commands.CheckFailure, commands.errors.DisabledCommand):
except (CheckFailure, DisabledCommand):
return False
else:
if can_run is False:
@@ -509,6 +647,28 @@ class Command(CogCommandMixin, commands.Command):
"""
return super().error(coro)
def format_shortdoc_for_context(self, ctx: "Context") -> str:
"""
This formats the short version of the help
tring based on values in context
See ``format_text_for_context`` for the actual implementation details
Cog creators may override this in their own command classes
as long as the method signature stays the same.
Parameters
----------
ctx: Context
Returns
-------
str
Localized help with some formatting
"""
sh = self.short_doc
return self.format_text_for_context(ctx, sh) if sh else sh
class GroupMixin(discord.ext.commands.GroupMixin):
"""Mixin for `Group` and `Red` classes.
@@ -545,10 +705,9 @@ class GroupMixin(discord.ext.commands.GroupMixin):
class CogGroupMixin:
requires: Requires
all_commands: Dict[str, Command]
def reevaluate_rules_for(
self, model_id: Union[str, int], guild_id: Optional[int]
self, model_id: Union[str, int], guild_id: int = 0
) -> Tuple[PermState, bool]:
"""Re-evaluate a rule by checking subcommand rules.
@@ -571,15 +730,16 @@ class CogGroupMixin:
"""
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:
if cur_rule not in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
# The above three states are unaffected by subcommand rules
# Remaining states can be changed if there exists no actively-allowed
# subcommand (this includes subcommands multiple levels below)
all_commands: Dict[str, Command] = getattr(self, "all_commands", {})
if any(
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermState.ALLOWED_STATES
for cmd in self.all_commands.values()
cmd.requires.get_rule(model_id, guild_id=guild_id) in PermStateAllowedStates
for cmd in all_commands.values()
):
return cur_rule, False
elif cur_rule is PermState.PASSIVE_ALLOW:
@@ -589,8 +749,11 @@ class CogGroupMixin:
self.requires.set_rule(model_id, PermState.ACTIVE_DENY, guild_id=guild_id)
return PermState.ACTIVE_DENY, True
# Default return value
return cur_rule, False
class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
class Group(GroupMixin, Command, CogGroupMixin, DPYGroup):
"""Group command class for Red.
This class inherits from `Command`, with :class:`GroupMixin` and
@@ -618,14 +781,14 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
if ctx.invoked_subcommand is None or self == ctx.invoked_subcommand:
if self.autohelp and not self.invoke_without_command:
await self._verify_checks(ctx)
await self.can_run(ctx, change_permission_state=True)
await ctx.send_help()
elif self.invoke_without_command:
# So invoke_without_command when a subcommand of this group is invoked
# will skip the the invokation of *this* command. However, because of
# how our permissions system works, we don't want it to skip the checks
# as well.
await self._verify_checks(ctx)
await self.can_run(ctx, change_permission_state=True)
# this is actually why we don't prepare earlier.
await super().invoke(ctx)
@@ -634,14 +797,6 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group):
class CogMixin(CogGroupMixin, CogCommandMixin):
"""Mixin class for a cog, intended for use with discord.py's cog class"""
@property
def all_commands(self) -> Dict[str, Command]:
"""
This does not have identical behavior to
Group.all_commands but should return what you expect
"""
return {cmd.name: cmd for cmd in self.__cog_commands__}
@property
def help(self):
doc = self.__doc__
@@ -653,7 +808,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
"""
This really just exists to allow easy use with other methods using can_run
on commands and groups such as help formatters.
kwargs used in that won't apply here as they don't make sense to,
but will be swallowed silently for a compatible signature for ease of use.
@@ -670,7 +825,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
try:
can_run = await self.requires.verify(ctx)
except commands.CommandError:
except CommandError:
return False
return can_run
@@ -699,16 +854,22 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
return await self.can_run(ctx)
class Cog(CogMixin, commands.Cog):
class Cog(CogMixin, DPYCog, metaclass=DPYCogMeta):
"""
Red's Cog base class
This includes a metaclass from discord.py
"""
# NB: Do not move the inheritcance of this. Keeping the mix of that metaclass
# seperate gives us more freedoms in several places.
pass
__cog_commands__: Tuple[Command]
@property
def all_commands(self) -> Dict[str, Command]:
"""
This does not have identical behavior to
Group.all_commands but should return what you expect
"""
return {cmd.name: cmd for cmd in self.__cog_commands__}
def command(name=None, cls=Command, **attrs):
@@ -717,7 +878,8 @@ def command(name=None, cls=Command, **attrs):
Same interface as `discord.ext.commands.command`.
"""
attrs["help_override"] = attrs.pop("help", None)
return commands.command(name, cls, **attrs)
return dpy_command_deco(name, cls, **attrs)
def group(name=None, cls=Group, **attrs):
@@ -725,10 +887,10 @@ def group(name=None, cls=Group, **attrs):
Same interface as `discord.ext.commands.group`.
"""
return command(name, cls, **attrs)
return dpy_command_deco(name, cls, **attrs)
__command_disablers = weakref.WeakValueDictionary()
__command_disablers: DisablerDictType = weakref.WeakValueDictionary()
def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitable[bool]]:
@@ -743,7 +905,7 @@ def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitabl
async def disabler(ctx: "Context") -> bool:
if ctx.guild == guild:
raise commands.DisabledCommand()
raise DisabledCommand()
return True
__command_disablers[guild] = disabler
@@ -771,6 +933,3 @@ class _AlwaysAvailableCommand(Command):
async def can_run(self, ctx, *args, **kwargs) -> bool:
return not ctx.author.bot
async def _verify_checks(self, ctx) -> bool:
return not ctx.author.bot

View File

@@ -1,21 +1,28 @@
from __future__ import annotations
import asyncio
import contextlib
import os
import re
from typing import Iterable, List, Union
from typing import Iterable, List, Union, Optional, TYPE_CHECKING
import discord
from discord.ext import commands
from discord.ext.commands import Context as DPYContext
from .requires import PermState
from ..utils.chat_formatting import box
from ..utils.predicates import MessagePredicate
from ..utils import common_filters
if TYPE_CHECKING:
from .commands import Command
from ..bot import Red
TICK = "\N{WHITE HEAVY CHECK MARK}"
__all__ = ["Context"]
__all__ = ["Context", "GuildContext", "DMContext"]
class Context(commands.Context):
class Context(DPYContext):
"""Command invocation context for Red.
All context passed into commands will be of this type.
@@ -40,6 +47,10 @@ class Context(commands.Context):
The permission state the current context is in.
"""
command: "Command"
invoked_subcommand: "Optional[Command]"
bot: "Red"
def __init__(self, **attrs):
self.assume_yes = attrs.pop("assume_yes", False)
super().__init__(**attrs)
@@ -254,7 +265,7 @@ class Context(commands.Context):
return pattern.sub(f"@{me.display_name}", self.prefix)
@property
def me(self) -> discord.abc.User:
def me(self) -> Union[discord.ClientUser, discord.Member]:
"""discord.abc.User: The bot member or user object.
If the context is DM, this will be a `discord.User` object.
@@ -263,3 +274,63 @@ class Context(commands.Context):
return self.guild.me
else:
return self.bot.user
if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
class DMContext(Context):
"""
At runtime, this will still be a normal context object.
This lies about some type narrowing for type analysis in commands
using a dm_only decorator.
It is only correct to use when those types are already narrowed
"""
@property
def author(self) -> discord.User:
...
@property
def channel(self) -> discord.DMChannel:
...
@property
def guild(self) -> None:
...
@property
def me(self) -> discord.ClientUser:
...
class GuildContext(Context):
"""
At runtime, this will still be a normal context object.
This lies about some type narrowing for type analysis in commands
using a guild_only decorator.
It is only correct to use when those types are already narrowed
"""
@property
def author(self) -> discord.Member:
...
@property
def channel(self) -> discord.TextChannel:
...
@property
def guild(self) -> discord.Guild:
...
@property
def me(self) -> discord.Member:
...
else:
GuildContext = Context
DMContext = Context

View File

@@ -1,14 +1,33 @@
"""
commands.converter
==================
This module contains useful functions and classes for command argument conversion.
Some of the converters within are included provisionaly and are marked as such.
"""
import os
import re
import functools
from datetime import timedelta
from typing import TYPE_CHECKING, Optional, List, Dict
from typing import (
TYPE_CHECKING,
Generic,
Optional,
Optional as NoParseOptional,
Tuple,
List,
Dict,
Type,
TypeVar,
Literal as Literal,
)
import discord
from discord.ext import commands as dpy_commands
from discord.ext.commands import BadArgument
from . import BadArgument
from ..i18n import Translator
from ..utils.chat_formatting import humanize_timedelta
from ..utils.chat_formatting import humanize_timedelta, humanize_list
if TYPE_CHECKING:
from .context import Context
@@ -17,10 +36,13 @@ __all__ = [
"APIToken",
"DictConverter",
"GuildConverter",
"UserInputOptional",
"NoParseOptional",
"TimedeltaConverter",
"get_dict_converter",
"get_timedelta_converter",
"parse_timedelta",
"Literal",
]
_ = Translator("commands.converter", __file__)
@@ -67,7 +89,7 @@ def parse_timedelta(
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands. `weeks` `days` `hours` `minutes` `seconds`
parser understands. (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
Returns
-------
@@ -138,17 +160,18 @@ class APIToken(discord.ext.commands.Converter):
This will parse the input argument separating the key value pairs into a
format to be used for the core bots API token storage.
This will split the argument by either `;` ` `, or `,` and return a dict
This will split the argument by a space, comma, or semicolon and return a dict
to be stored. Since all API's are different and have different naming convention,
this leaves the onus on the cog creator to clearly define how to setup the correct
credential names for their cogs.
Note: Core usage of this has been replaced with DictConverter use instead.
Note: Core usage of this has been replaced with `DictConverter` use instead.
This may be removed at a later date (with warning)
.. warning::
This will be removed in version 3.4.
"""
async def convert(self, ctx, argument) -> dict:
async def convert(self, ctx: "Context", argument) -> dict:
bot = ctx.bot
result = {}
match = re.split(r";|,| ", argument)
@@ -162,140 +185,263 @@ class APIToken(discord.ext.commands.Converter):
return result
class DictConverter(dpy_commands.Converter):
"""
Converts pairs of space seperated values to a dict
"""
# Below this line are a lot of lies for mypy about things that *end up* correct when
# These are used for command conversion purposes. Please refer to the portion
# which is *not* for type checking for the actual implementation
# and ensure the lies stay correct for how the object should look as a typehint
def __init__(self, *expected_keys: str, delims: Optional[List[str]] = None):
self.expected_keys = expected_keys
self.delims = delims or [" "]
self.pattern = re.compile(r"|".join(re.escape(d) for d in self.delims))
if TYPE_CHECKING:
DictConverter = Dict[str, str]
else:
async def convert(self, ctx: "Context", argument: str) -> Dict[str, str]:
class DictConverter(dpy_commands.Converter):
"""
Converts pairs of space seperated values to a dict
"""
ret: Dict[str, str] = {}
args = self.pattern.split(argument)
def __init__(self, *expected_keys: str, delims: Optional[List[str]] = None):
self.expected_keys = expected_keys
self.delims = delims or [" "]
self.pattern = re.compile(r"|".join(re.escape(d) for d in self.delims))
if len(args) % 2 != 0:
raise BadArgument()
async def convert(self, ctx: "Context", argument: str) -> Dict[str, str]:
ret: Dict[str, str] = {}
args = self.pattern.split(argument)
iterator = iter(args)
if len(args) % 2 != 0:
raise BadArgument()
for key in iterator:
if self.expected_keys and key not in self.expected_keys:
raise BadArgument(_("Unexpected key {key}").format(key=key))
iterator = iter(args)
ret[key] = next(iterator)
for key in iterator:
if self.expected_keys and key not in self.expected_keys:
raise BadArgument(_("Unexpected key {key}").format(key=key))
return ret
ret[key] = next(iterator)
return ret
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> type:
"""
Returns a typechecking safe `DictConverter` suitable for use with discord.py
"""
if TYPE_CHECKING:
class PartialMeta(type(DictConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims
)
class ValidatedConverter(DictConverter, metaclass=PartialMeta):
pass
return ValidatedConverter
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> Type[dict]:
...
class TimedeltaConverter(dpy_commands.Converter):
"""
This is a converter for timedeltas.
The units should be in order from largest to smallest.
This works with or without whitespace.
else:
See `parse_timedelta` for more information about how this functions.
def get_dict_converter(*expected_keys: str, delims: Optional[List[str]] = None) -> Type[dict]:
"""
Returns a typechecking safe `DictConverter` suitable for use with discord.py
"""
Attributes
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands: `weeks` `days` `hours` `minutes` `seconds`
default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into
a timedelta, using the unit specified. Same units as in `allowed_units`
apply.
"""
class PartialMeta(type):
__call__ = functools.partialmethod(
type(DictConverter).__call__, *expected_keys, delims=delims
)
def __init__(self, *, minimum=None, maximum=None, allowed_units=None, default_unit=None):
self.allowed_units = allowed_units
self.default_unit = default_unit
self.minimum = minimum
self.maximum = maximum
class ValidatedConverter(DictConverter, metaclass=PartialMeta):
pass
return ValidatedConverter
if TYPE_CHECKING:
TimedeltaConverter = timedelta
else:
class TimedeltaConverter(dpy_commands.Converter):
"""
This is a converter for timedeltas.
The units should be in order from largest to smallest.
This works with or without whitespace.
See `parse_timedelta` for more information about how this functions.
Attributes
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can choose to provide are the same as the
parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into
a timedelta, using the unit specified. Same units as in ``allowed_units``
apply.
"""
def __init__(self, *, minimum=None, maximum=None, allowed_units=None, default_unit=None):
self.allowed_units = allowed_units
self.default_unit = default_unit
self.minimum = minimum
self.maximum = maximum
async def convert(self, ctx: "Context", argument: str) -> timedelta:
if self.default_unit and argument.isdecimal():
argument = argument + self.default_unit
async def convert(self, ctx: "Context", argument: str) -> timedelta:
if self.default_unit and argument.isdecimal():
delta = timedelta(**{self.default_unit: int(argument)})
else:
delta = parse_timedelta(
argument,
minimum=self.minimum,
maximum=self.maximum,
allowed_units=self.allowed_units,
)
if delta is not None:
return delta
raise BadArgument() # This allows this to be a required argument.
if delta is not None:
return delta
raise BadArgument() # This allows this to be a required argument.
def get_timedelta_converter(
*,
default_unit: Optional[str] = None,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = None,
) -> type:
"""
This creates a type suitable for typechecking which works with discord.py's
commands.
See `parse_timedelta` for more information about how this functions.
if TYPE_CHECKING:
Parameters
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can chose to provide are the same as the
parser understands: `weeks` `days` `hours` `minutes` `seconds`
default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into
a timedelta, using the unit specified. Same units as in `allowed_units`
apply.
def get_timedelta_converter(
*,
default_unit: Optional[str] = None,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = None,
) -> Type[timedelta]:
...
Returns
-------
type
The converter class, which will be a subclass of `TimedeltaConverter`
"""
class PartialMeta(type(TimedeltaConverter)):
__call__ = functools.partialmethod(
type(DictConverter).__call__,
allowed_units=allowed_units,
default_unit=default_unit,
minimum=minimum,
maximum=maximum,
)
else:
class ValidatedConverter(TimedeltaConverter, metaclass=PartialMeta):
pass
def get_timedelta_converter(
*,
default_unit: Optional[str] = None,
maximum: Optional[timedelta] = None,
minimum: Optional[timedelta] = None,
allowed_units: Optional[List[str]] = None,
) -> Type[timedelta]:
"""
This creates a type suitable for typechecking which works with discord.py's
commands.
return ValidatedConverter
See `parse_timedelta` for more information about how this functions.
Parameters
----------
maximum : Optional[timedelta]
If provided, any parsed value higher than this will raise an exception
minimum : Optional[timedelta]
If provided, any parsed value lower than this will raise an exception
allowed_units : Optional[List[str]]
If provided, you can constrain a user to expressing the amount of time
in specific units. The units you can choose to provide are the same as the
parser understands: (``weeks``, ``days``, ``hours``, ``minutes``, ``seconds``)
default_unit : Optional[str]
If provided, it will additionally try to match integer-only input into
a timedelta, using the unit specified. Same units as in ``allowed_units``
apply.
Returns
-------
type
The converter class, which will be a subclass of `TimedeltaConverter`
"""
class PartialMeta(type):
__call__ = functools.partialmethod(
type(DictConverter).__call__,
allowed_units=allowed_units,
default_unit=default_unit,
minimum=minimum,
maximum=maximum,
)
class ValidatedConverter(TimedeltaConverter, metaclass=PartialMeta):
pass
return ValidatedConverter
if not TYPE_CHECKING:
class NoParseOptional:
"""
This can be used instead of `typing.Optional`
to avoid discord.py special casing the conversion behavior.
.. warning::
This converter class is still provisional.
.. seealso::
The `ignore_optional_for_conversion` option of commands.
"""
def __class_getitem__(cls, key):
if isinstance(key, tuple):
raise TypeError("Must only provide a single type to Optional")
return key
_T_OPT = TypeVar("_T_OPT", bound=Type)
if TYPE_CHECKING or os.getenv("BUILDING_DOCS", False):
class UserInputOptional(Generic[_T_OPT]):
"""
This can be used when user input should be converted as discord.py
treats `typing.Optional`, but the type should not be equivalent to
``typing.Union[DesiredType, None]`` for type checking.
.. warning::
This converter class is still provisional.
This class may not play well with mypy yet
and may still require you guard this in a
type checking conditional import vs the desired types
We're aware and looking into improving this.
"""
def __class_getitem__(cls, key: _T_OPT) -> _T_OPT:
if isinstance(key, tuple):
raise TypeError("Must only provide a single type to Optional")
return key
else:
UserInputOptional = Optional
if not TYPE_CHECKING:
class Literal(dpy_commands.Converter):
"""
This can be used as a converter for `typing.Literal`.
In a type checking context it is `typing.Literal`.
In a runtime context, it's a converter which only matches the literals it was given.
.. warning::
This converter class is still provisional.
"""
def __init__(self, valid_names: Tuple[str]):
self.valid_names = valid_names
def __call__(self, ctx, arg):
# Callable's are treated as valid types:
# https://github.com/python/cpython/blob/3.8/Lib/typing.py#L148
# Without this, ``typing.Union[Literal["clear"], bool]`` would fail
return self.convert(ctx, arg)
async def convert(self, ctx, arg):
if arg in self.valid_names:
return arg
raise BadArgument(_("Expected one of: {}").format(humanize_list(self.valid_names)))
def __class_getitem__(cls, k):
if not k:
raise ValueError("Need at least one value for Literal")
if isinstance(k, tuple):
return cls(k)
else:
return cls((k,))

View File

@@ -44,6 +44,7 @@ from . import commands
from .context import Context
from ..i18n import Translator
from ..utils import menus
from ..utils.mod import mass_purge
from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results
from ..utils.chat_formatting import box, pagify
@@ -223,7 +224,7 @@ class RedHelpFormatter:
return a_line[:67] + "..."
subtext = "\n".join(
shorten_line(f"**{name}** {command.short_doc}")
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
for name, command in sorted(subcommands.items())
)
for i, page in enumerate(pagify(subtext, page_length=500, shorten_by=0)):
@@ -248,7 +249,7 @@ class RedHelpFormatter:
doc_max_width = 80 - max_width
for nm, com in sorted(cmds):
width_gap = discord.utils._string_width(nm) - len(nm)
doc = com.short_doc
doc = com.format_shortdoc_for_context(ctx)
if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap
@@ -398,7 +399,7 @@ class RedHelpFormatter:
return a_line[:67] + "..."
command_text = "\n".join(
shorten_line(f"**{name}** {command.short_doc}")
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
for name, command in sorted(coms.items())
)
for i, page in enumerate(pagify(command_text, page_length=500, shorten_by=0)):
@@ -422,7 +423,7 @@ class RedHelpFormatter:
doc_max_width = 80 - max_width
for nm, com in sorted(cmds):
width_gap = discord.utils._string_width(nm) - len(nm)
doc = com.short_doc
doc = com.format_shortdoc_for_context(ctx)
if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap
@@ -465,7 +466,7 @@ class RedHelpFormatter:
return a_line[:67] + "..."
cog_text = "\n".join(
shorten_line(f"**{name}** {command.short_doc}")
shorten_line(f"**{name}** {command.format_shortdoc_for_context(ctx)}")
for name, command in sorted(data.items())
)
@@ -493,7 +494,7 @@ class RedHelpFormatter:
doc_max_width = 80 - max_width
for nm, com in cmds:
width_gap = discord.utils._string_width(nm) - len(nm)
doc = com.short_doc
doc = com.format_shortdoc_for_context(ctx)
if len(doc) > doc_max_width:
doc = doc[: doc_max_width - 3] + "..."
yield nm, doc, max_width - width_gap
@@ -627,36 +628,52 @@ class RedHelpFormatter:
Sends pages based on settings.
"""
if not (
ctx.channel.permissions_for(ctx.me).add_reactions
and await ctx.bot._config.help.use_menus()
):
# save on config calls
config_help = await ctx.bot._config.help()
channel_permissions = ctx.channel.permissions_for(ctx.me)
max_pages_in_guild = await ctx.bot._config.help.max_pages_in_guild()
destination = ctx.author if len(pages) > max_pages_in_guild else ctx
if not (channel_permissions.add_reactions and config_help["use_menus"]):
if embed:
for page in pages:
try:
await destination.send(embed=page)
except discord.Forbidden:
return await ctx.send(
T_(
"I couldn't send the help message to you in DM. "
"Either you blocked me or you disabled DMs in this server."
)
)
else:
for page in pages:
try:
await destination.send(page)
except discord.Forbidden:
return await ctx.send(
T_(
"I couldn't send the help message to you in DM. "
"Either you blocked me or you disabled DMs in this server."
)
max_pages_in_guild = config_help["max_pages_in_guild"]
use_DMs = len(pages) > max_pages_in_guild
destination = ctx.author if use_DMs else ctx.channel
delete_delay = config_help["delete_delay"]
messages: List[discord.Message] = []
for page in pages:
try:
if embed:
msg = await destination.send(embed=page)
else:
msg = await destination.send(page)
except discord.Forbidden:
return await ctx.send(
T_(
"I couldn't send the help message to you in DM. "
"Either you blocked me or you disabled DMs in this server."
)
)
else:
messages.append(msg)
# The if statement takes into account that 'destination' will be
# the context channel in non-DM context, reusing 'channel_permissions' to avoid
# computing the permissions twice.
if (
not use_DMs # we're not in DMs
and delete_delay > 0 # delete delay is enabled
and channel_permissions.manage_messages # we can manage messages here
):
# We need to wrap this in a task to not block after-sending-help interactions.
# The channel has to be TextChannel as we can't bulk-delete from DMs
async def _delete_delay_help(
channel: discord.TextChannel, messages: List[discord.Message], delay: int
):
await asyncio.sleep(delay)
await mass_purge(messages, channel)
asyncio.create_task(_delete_delay_help(destination, messages, delete_delay))
else:
# Specifically ensuring the menu's message is sent prior to returning
m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0]))

View File

@@ -8,6 +8,8 @@ checks like bot permissions checks.
"""
import asyncio
import enum
import inspect
from collections import ChainMap
from typing import (
Union,
Optional,
@@ -20,6 +22,7 @@ from typing import (
TypeVar,
Tuple,
ClassVar,
Mapping,
)
import discord
@@ -45,6 +48,7 @@ __all__ = [
"permissions_check",
"bot_has_permissions",
"has_permissions",
"has_guild_permissions",
"is_owner",
"guildowner",
"guildowner_or_permissions",
@@ -52,6 +56,9 @@ __all__ = [
"admin_or_permissions",
"mod",
"mod_or_permissions",
"transition_permstate_to",
"PermStateTransitions",
"PermStateAllowedStates",
]
_T = TypeVar("_T")
@@ -182,11 +189,6 @@ class PermState(enum.Enum):
"""This command has been actively denied by a permission hook
check validation doesn't need this, but is useful to developers"""
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``."""
@@ -211,7 +213,11 @@ class PermState(enum.Enum):
# 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 = {
TransitionResult = Tuple[Optional[bool], Union[PermState, Dict[bool, PermState]]]
TransitionDict = Dict[PermState, Dict[PermState, TransitionResult]]
PermStateTransitions: TransitionDict = {
PermState.ACTIVE_ALLOW: {
PermState.ACTIVE_ALLOW: (True, PermState.ACTIVE_ALLOW),
PermState.NORMAL: (True, PermState.ACTIVE_ALLOW),
@@ -248,13 +254,18 @@ PermState.TRANSITIONS = {
PermState.ACTIVE_DENY: (False, PermState.ACTIVE_DENY),
},
}
PermState.ALLOWED_STATES = (
PermStateAllowedStates = (
PermState.ACTIVE_ALLOW,
PermState.PASSIVE_ALLOW,
PermState.CAUTIOUS_ALLOW,
)
def transition_permstate_to(prev: PermState, next_state: PermState) -> TransitionResult:
return PermStateTransitions[prev][next_state]
class Requires:
"""This class describes the requirements for executing a specific command.
@@ -326,13 +337,13 @@ class Requires:
@staticmethod
def get_decorator(
privilege_level: Optional[PrivilegeLevel], user_perms: Dict[str, bool]
privilege_level: Optional[PrivilegeLevel], user_perms: Optional[Dict[str, bool]]
) -> Callable[["_CommandOrCoro"], "_CommandOrCoro"]:
if not user_perms:
user_perms = None
def decorator(func: "_CommandOrCoro") -> "_CommandOrCoro":
if asyncio.iscoroutinefunction(func):
if inspect.iscoroutinefunction(func):
func.__requires_privilege_level__ = privilege_level
func.__requires_user_perms__ = user_perms
else:
@@ -341,6 +352,7 @@ class Requires:
func.requires.user_perms = None
else:
_validate_perms_dict(user_perms)
assert func.requires.user_perms is not None
func.requires.user_perms.update(**user_perms)
return func
@@ -357,6 +369,8 @@ class Requires:
guild_id : int
The ID of the guild for the rule's scope. Set to
`Requires.GLOBAL` for a global rule.
If a global rule is set for a model,
it will be prefered over the guild rule.
Returns
-------
@@ -367,8 +381,9 @@ class Requires:
"""
if not isinstance(model, (str, int)):
model = model.id
rules: Mapping[Union[int, str], PermState]
if guild_id:
rules = self._guild_rules.get(guild_id, _RulesDict())
rules = ChainMap(self._global_rules, self._guild_rules.get(guild_id, _RulesDict()))
else:
rules = self._global_rules
return rules.get(model, PermState.NORMAL)
@@ -488,7 +503,7 @@ class Requires:
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)
should_invoke, next_state = transition_permstate_to(prev_state, cur_state)
if should_invoke is None:
# NORMAL invokation, we simply follow standard procedure
should_invoke = await self._verify_user(ctx)
@@ -509,6 +524,7 @@ class Requires:
would_invoke = await self._verify_user(ctx)
next_state = next_state[would_invoke]
assert isinstance(next_state, PermState)
ctx.permission_state = next_state
return should_invoke
@@ -635,6 +651,20 @@ def permissions_check(predicate: CheckPredicate):
return decorator
def has_guild_permissions(**perms):
"""Restrict the command to users with these guild permissions.
This check can be overridden by rules.
"""
_validate_perms_dict(perms)
def predicate(ctx):
return ctx.guild and ctx.author.guild_permissions >= discord.Permissions(**perms)
return permissions_check(predicate)
def bot_has_permissions(**perms: bool):
"""Complain if the bot is missing permissions.
@@ -757,16 +787,10 @@ class _RulesDict(Dict[Union[int, str], PermState]):
def _validate_perms_dict(perms: Dict[str, bool]) -> None:
invalid_keys = set(perms.keys()) - set(discord.Permissions.VALID_FLAGS)
if invalid_keys:
raise TypeError(f"Invalid perm name(s): {', '.join(invalid_keys)}")
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.

View File

@@ -979,7 +979,7 @@ class Config:
"""
return self._get_base_group(self.CHANNEL, str(channel_id))
def channel(self, channel: discord.TextChannel) -> Group:
def channel(self, channel: discord.abc.GuildChannel) -> Group:
"""Returns a `Group` for the given channel.
This does not discriminate between text and voice channels.

View File

@@ -126,7 +126,7 @@ class CoreLogic:
else:
await bot.add_loaded_package(name)
loaded_packages.append(name)
# remove in Red 3.3
# remove in Red 3.4
downloader = bot.get_cog("Downloader")
if downloader is None:
continue
@@ -319,7 +319,10 @@ class Core(commands.Cog, CoreLogic):
python_version = "[{}.{}.{}]({})".format(*sys.version_info[:3], python_url)
red_version = "[{}]({})".format(__version__, red_pypi)
app_info = await self.bot.application_info()
owner = app_info.owner
if app_info.team:
owner = app_info.team.name
else:
owner = app_info.owner
custom_info = await self.bot._config.custom_info()
async with aiohttp.ClientSession() as session:
@@ -358,7 +361,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
async def uptime(self, ctx: commands.Context):
"""Shows Red's uptime"""
"""Shows [botname]'s uptime"""
since = ctx.bot.uptime.strftime("%Y-%m-%d %H:%M:%S")
delta = datetime.datetime.utcnow() - self.bot.uptime
uptime_str = humanize_timedelta(timedelta=delta) or _("Less than one second")
@@ -385,6 +388,9 @@ class Core(commands.Cog, CoreLogic):
if ctx.guild:
guild_setting = await self.bot._config.guild(ctx.guild).embeds()
text += _("Guild setting: {}\n").format(guild_setting)
if ctx.channel:
channel_setting = await self.bot._config.channel(ctx.channel).embeds()
text += _("Channel setting: {}\n").format(channel_setting)
user_setting = await self.bot._config.user(ctx.author).embeds()
text += _("User setting: {}").format(user_setting)
await ctx.send(box(text))
@@ -430,6 +436,31 @@ class Core(commands.Cog, CoreLogic):
)
)
@embedset.command(name="channel")
@checks.guildowner_or_permissions(administrator=True)
@commands.guild_only()
async def embedset_channel(self, ctx: commands.Context, enabled: bool = None):
"""
Toggle the channel's embed setting.
If enabled is None, the setting will be unset and
the guild default will be used instead.
If set, this is used instead of the guild default
to determine whether or not to use embeds. This is
used for all commands done in a channel except
for help commands.
"""
await self.bot._config.channel(ctx.channel).embeds.set(enabled)
if enabled is None:
await ctx.send(_("Embeds will now fall back to the global setting."))
else:
await ctx.send(
_("Embeds are now {} for this channel.").format(
_("enabled") if enabled else _("disabled")
)
)
@embedset.command(name="user")
async def embedset_user(self, ctx: commands.Context, enabled: bool = None):
"""
@@ -471,7 +502,7 @@ class Core(commands.Cog, CoreLogic):
@commands.command()
@commands.check(CoreLogic._can_get_invite_url)
async def invite(self, ctx):
"""Show's Red's invite url"""
"""Show's [botname]'s invite url"""
try:
await ctx.author.send(await self._invite_url())
except discord.errors.Forbidden:
@@ -674,13 +705,13 @@ class Core(commands.Cog, CoreLogic):
if len(repos_with_shared_libs) == 1:
formed = _(
"**WARNING**: The following repo is using shared libs"
" which are marked for removal in Red 3.3: {repo}.\n"
" which are marked for removal in Red 3.4: {repo}.\n"
"You should inform maintainer of the repo about this message."
).format(repo=inline(repos_with_shared_libs.pop()))
else:
formed = _(
"**WARNING**: The following repos are using shared libs"
" which are marked for removal in Red 3.3: {repos}.\n"
" which are marked for removal in Red 3.4: {repos}.\n"
"You should inform maintainers of these repos about this message."
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
output.append(formed)
@@ -792,13 +823,13 @@ class Core(commands.Cog, CoreLogic):
if len(repos_with_shared_libs) == 1:
formed = _(
"**WARNING**: The following repo is using shared libs"
" which are marked for removal in Red 3.3: {repo}.\n"
" which are marked for removal in Red 3.4: {repo}.\n"
"You should inform maintainers of these repos about this message."
).format(repo=inline(repos_with_shared_libs.pop()))
else:
formed = _(
"**WARNING**: The following repos are using shared libs"
" which are marked for removal in Red 3.3: {repos}.\n"
" which are marked for removal in Red 3.4: {repos}.\n"
"You should inform maintainers of these repos about this message."
).format(repos=humanize_list([inline(repo) for repo in repos_with_shared_libs]))
output.append(formed)
@@ -834,7 +865,7 @@ class Core(commands.Cog, CoreLogic):
@commands.group(name="set")
async def _set(self, ctx: commands.Context):
"""Changes Red's settings"""
"""Changes [botname]'s settings"""
if ctx.invoked_subcommand is None:
if ctx.guild:
guild = ctx.guild
@@ -1020,7 +1051,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command()
@checks.is_owner()
async def avatar(self, ctx: commands.Context, url: str):
"""Sets Red's avatar"""
"""Sets [botname]'s avatar"""
async with aiohttp.ClientSession() as session:
async with session.get(url) as r:
data = await r.read()
@@ -1044,7 +1075,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild()
@checks.is_owner()
async def _game(self, ctx: commands.Context, *, game: str = None):
"""Sets Red's playing status"""
"""Sets [botname]'s playing status"""
if game:
game = discord.Game(name=game)
@@ -1058,7 +1089,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild()
@checks.is_owner()
async def _listening(self, ctx: commands.Context, *, listening: str = None):
"""Sets Red's listening status"""
"""Sets [botname]'s listening status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
if listening:
@@ -1072,7 +1103,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild()
@checks.is_owner()
async def _watching(self, ctx: commands.Context, *, watching: str = None):
"""Sets Red's watching status"""
"""Sets [botname]'s watching status"""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else discord.Status.online
if watching:
@@ -1086,7 +1117,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild()
@checks.is_owner()
async def status(self, ctx: commands.Context, *, status: str):
"""Sets Red's status
"""Sets [botname]'s status
Available statuses:
online
@@ -1115,7 +1146,7 @@ class Core(commands.Cog, CoreLogic):
@checks.bot_in_a_guild()
@checks.is_owner()
async def stream(self, ctx: commands.Context, streamer=None, *, stream_title=None):
"""Sets Red's streaming status
"""Sets [botname]'s streaming status
Leaving both streamer and stream_title empty will clear it."""
status = ctx.bot.guilds[0].me.status if len(ctx.bot.guilds) > 0 else None
@@ -1136,7 +1167,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(name="username", aliases=["name"])
@checks.is_owner()
async def _username(self, ctx: commands.Context, *, username: str):
"""Sets Red's username"""
"""Sets [botname]'s username"""
try:
await self._name(name=username)
except discord.HTTPException:
@@ -1155,7 +1186,7 @@ class Core(commands.Cog, CoreLogic):
@checks.admin()
@commands.guild_only()
async def _nickname(self, ctx: commands.Context, *, nickname: str = None):
"""Sets Red's nickname"""
"""Sets [botname]'s nickname"""
try:
await ctx.guild.me.edit(nick=nickname)
except discord.Forbidden:
@@ -1166,7 +1197,7 @@ class Core(commands.Cog, CoreLogic):
@_set.command(aliases=["prefixes"])
@checks.is_owner()
async def prefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's global prefix(es)"""
"""Sets [botname]'s global prefix(es)"""
if not prefixes:
await ctx.send_help()
return
@@ -1177,7 +1208,7 @@ class Core(commands.Cog, CoreLogic):
@checks.admin()
@commands.guild_only()
async def serverprefix(self, ctx: commands.Context, *prefixes: str):
"""Sets Red's server prefix(es)"""
"""Sets [botname]'s server prefix(es)"""
if not prefixes:
await ctx.bot._prefix_cache.set_prefixes(guild=ctx.guild, prefixes=[])
await ctx.send(_("Guild prefixes have been reset."))
@@ -1368,6 +1399,30 @@ class Core(commands.Cog, CoreLogic):
await ctx.bot._config.help.max_pages_in_guild.set(pages)
await ctx.send(_("Done. The page limit has been set to {}.").format(pages))
@helpset.command(name="deletedelay")
@commands.bot_has_permissions(manage_messages=True)
async def helpset_deletedelay(self, ctx: commands.Context, seconds: int):
"""Set the delay after which help pages will be deleted.
The setting is disabled by default, and only applies to non-menu help,
sent in server text channels.
Setting the delay to 0 disables this feature.
The bot has to have MANAGE_MESSAGES permission for this to work.
"""
if seconds < 0:
await ctx.send(_("You must give a value of zero or greater!"))
return
if seconds > 60 * 60 * 24 * 14: # 14 days
await ctx.send(_("The delay cannot be longer than 14 days!"))
return
await ctx.bot._config.help.delete_delay.set(seconds)
if seconds == 0:
await ctx.send(_("Done. Help messages will not be deleted now."))
else:
await ctx.send(_("Done. The delete delay has been set to {} seconds.").format(seconds))
@helpset.command(name="tagline")
async def helpset_tagline(self, ctx: commands.Context, *, tagline: str = None):
"""
@@ -1457,7 +1512,9 @@ class Core(commands.Cog, CoreLogic):
if not destination.permissions_for(destination.guild.me).send_messages:
continue
if destination.permissions_for(destination.guild.me).embed_links:
send_embed = await ctx.bot._config.guild(destination.guild).embeds()
send_embed = await ctx.bot._config.channel(destination).embeds()
if send_embed is None:
send_embed = await ctx.bot._config.guild(destination.guild).embeds()
else:
send_embed = False
@@ -1524,12 +1581,12 @@ class Core(commands.Cog, CoreLogic):
settings, 'appearance' tab. Then right click a user
and copy their id"""
destination = discord.utils.get(ctx.bot.get_all_members(), id=user_id)
if destination is None:
if destination is None or destination.bot:
await ctx.send(
_(
"Invalid ID or user not found. You can only "
"send messages to people I share a server "
"with."
"Invalid ID, user not found, or user is a bot. "
"You can only send messages to people I share "
"a server with."
)
)
return

View File

@@ -271,7 +271,7 @@ class BaseDriver(abc.ABC):
The driver must be initialized before this operation.
The BaseDriver provides a generic method which may be overriden
The BaseDriver provides a generic method which may be overridden
by subclasses.
Parameters

View File

@@ -217,7 +217,7 @@ class JsonDriver(BaseDriver):
def _save_json(path: Path, data: Dict[str, Any]) -> None:
"""
This fsync stuff here is entirely neccessary.
This fsync stuff here is entirely necessary.
On windows, it is not available in entirety.
If a windows user ends up with tons of temp files, they should consider hosting on

View File

@@ -49,8 +49,13 @@ def init_events(bot, cli_flags):
users = len(set([m for m in bot.get_all_members()]))
app_info = await bot.application_info()
if bot.owner_id is None:
bot.owner_id = app_info.owner.id
if app_info.team:
if bot._use_team_features:
bot.owner_ids = {m.id for m in app_info.team.members}
else:
if bot.owner_id is None:
bot.owner_id = app_info.owner.id
try:
invite_url = discord.utils.oauth_url(app_info.id)
@@ -213,6 +218,12 @@ def init_events(bot, cli_flags):
),
delete_after=error.retry_after,
)
elif isinstance(error, commands.MaxConcurrencyReached):
await ctx.send(
"Too many people using this command. It can only be used {} time(s) per {} concurrently.".format(
error.number, error.per.name
)
)
else:
log.exception(type(error).__name__, exc_info=error)

View File

@@ -324,9 +324,7 @@ class Case:
if embed:
emb = discord.Embed(title=title, description=reason)
if avatar_url is not None:
emb.set_author(name=user, icon_url=avatar_url)
emb.set_author(name=user)
emb.add_field(name=_("Moderator"), value=moderator, inline=False)
if until and duration:
emb.add_field(name=_("Until"), value=until)

View File

@@ -1,4 +1,5 @@
import asyncio
import warnings
from asyncio import AbstractEventLoop, as_completed, Semaphore
from asyncio.futures import isfuture
from itertools import chain
@@ -177,14 +178,20 @@ def bounded_gather_iter(
TypeError
When invalid parameters are passed
"""
if loop is None:
loop = asyncio.get_event_loop()
if loop is not None:
warnings.warn(
"Explicitly passing the loop will not work in Red 3.4+ and is currently ignored."
"Call this from the related event loop.",
DeprecationWarning,
)
loop = asyncio.get_running_loop()
if semaphore is None:
if not isinstance(limit, int) or limit <= 0:
raise TypeError("limit must be an int > 0")
semaphore = Semaphore(limit, loop=loop)
semaphore = Semaphore(limit)
pending = []
@@ -195,7 +202,7 @@ def bounded_gather_iter(
cof = _sem_wrapper(semaphore, cof)
pending.append(cof)
return as_completed(pending, loop=loop)
return as_completed(pending)
def bounded_gather(
@@ -228,15 +235,21 @@ def bounded_gather(
TypeError
When invalid parameters are passed
"""
if loop is None:
loop = asyncio.get_event_loop()
if loop is not None:
warnings.warn(
"Explicitly passing the loop will not work in Red 3.4+ and is currently ignored."
"Call this from the related event loop.",
DeprecationWarning,
)
loop = asyncio.get_running_loop()
if semaphore is None:
if not isinstance(limit, int) or limit <= 0:
raise TypeError("limit must be an int > 0")
semaphore = Semaphore(limit, loop=loop)
semaphore = Semaphore(limit)
tasks = (_sem_wrapper(semaphore, task) for task in coros_or_futures)
return asyncio.gather(*tasks, loop=loop, return_exceptions=return_exceptions)
return asyncio.gather(*tasks, return_exceptions=return_exceptions)

View File

@@ -21,7 +21,7 @@ class AntiSpam:
# TODO : Decorator interface for command check using `spammy`
# with insertion of the antispam element into context
# for manual stamping on succesful command completion
# for manual stamping on successful command completion
default_intervals = [
(timedelta(seconds=5), 3),

View File

@@ -0,0 +1,152 @@
import discord
from typing import Dict
__all__ = ["OverwriteDiff"]
class OverwriteDiff:
"""
Represents a change in PermissionOverwrites.
All math operations done with the values contained are bitwise.
This object is considered False for boolean logic when representing no change.
Attributes
----------
allows_added : int
allows_removed : int
denies_added : int
denies_removed : int
"""
def __init__(self, **data: int):
self.allows_added = data.pop("allows_added", 0)
self.allows_removed = data.pop("allows_removed", 0)
self.denies_added = data.pop("denies_added", 0)
self.denies_removed = data.pop("denies_removed", 0)
if (
(self.allows_added & self.denies_added)
or (self.allows_removed & self.denies_removed)
or (self.allows_added & self.allows_removed)
or (self.denies_added & self.denies_removed)
):
raise ValueError(
"It is impossible for this to be the difference of two valid overwrite objects."
)
def __repr__(self):
return (
f"<OverwriteDiff "
f"allows_added={self.allows_added} allows_removed={self.allows_removed} "
f"denies_added={self.denies_added} denies_removed={self.denies_removed}>"
)
def __bool__(self):
return self.allows_added or self.allows_removed or self.denies_added or self.denies_removed
def to_dict(self) -> Dict[str, int]:
return {
"allows_added": self.allows_added,
"allows_removed": self.allows_removed,
"denies_added": self.denies_added,
"denies_removed": self.denies_removed,
}
def __radd__(self, other: discord.PermissionOverwrite) -> discord.PermissionOverwrite:
if not isinstance(other, discord.PermissionOverwrite):
return NotImplemented
return self.apply_to_overwirte(other)
def __rsub__(self, other: discord.PermissionOverwrite) -> discord.PermissionOverwrite:
if not isinstance(other, discord.PermissionOverwrite):
return NotImplemented
return self.remove_from_overwrite(other)
@classmethod
def from_dict(cls, data: Dict[str, int]):
return cls(**data)
@classmethod
def from_overwrites(
cls, before: discord.PermissionOverwrite, after: discord.PermissionOverwrite
):
"""
Returns the difference between two permission overwrites.
Parameters
----------
before : discord.PermissionOverwrite
after : discord.PermissionOverwrite
"""
b_allow, b_deny = before.pair()
a_allow, a_deny = after.pair()
b_allow_val, b_deny_val = b_allow.value, b_deny.value
a_allow_val, a_deny_val = a_allow.value, a_deny.value
allows_added = a_allow_val & ~b_allow_val
allows_removed = b_allow_val & ~a_allow_val
denies_added = a_deny_val & ~b_deny_val
denies_removed = b_deny_val & ~a_deny_val
return cls(
allows_added=allows_added,
allows_removed=allows_removed,
denies_added=denies_added,
denies_removed=denies_removed,
)
def apply_to_overwirte(
self, overwrite: discord.PermissionOverwrite
) -> discord.PermissionOverwrite:
"""
Creates a new overwrite by applying a diff to existing overwrites.
Parameters
----------
overwrite : discord.PermissionOverwrite
Returns
-------
discord.PermissionOverwrite
A new overwrite object with the diff applied to it.
"""
current_allow, current_deny = overwrite.pair()
allow_value = (current_allow.value | self.allows_added) & ~self.allows_removed
deny_value = (current_deny.value | self.denies_added) & ~self.denies_removed
na = discord.Permissions(allow_value)
nd = discord.Permissions(deny_value)
return discord.PermissionOverwrite.from_pair(na, nd)
def remove_from_overwrite(
self, overwrite: discord.PermissionOverwrite
) -> discord.PermissionOverwrite:
"""
If given the after for the current diff object, this should return the before.
This can be used to roll back changes.
Parameters
----------
overwrite : discord.PermissionOverwrite
Returns
-------
discord.PermissionOverwrite
A new overwrite object with the diff removed from it.
"""
current_allow, current_deny = overwrite.pair()
allow_value = (current_allow.value | self.allows_removed) & ~self.allows_added
deny_value = (current_deny.value | self.denies_removed) & ~self.denies_added
na = discord.Permissions(allow_value)
nd = discord.Permissions(deny_value)
return discord.PermissionOverwrite.from_pair(na, nd)

View File

@@ -5,6 +5,7 @@
import asyncio
import contextlib
import functools
import warnings
from typing import Union, Iterable, Optional
import discord
@@ -200,7 +201,9 @@ def start_adding_reactions(
await message.add_reaction(emoji)
if loop is None:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
else:
warnings.warn("Explicitly passing the loop will not work in Red 3.4+", DeprecationWarning)
return loop.create_task(task())

View File

@@ -38,12 +38,13 @@ async def mass_purge(messages: List[discord.Message], channel: discord.TextChann
"""
while messages:
if len(messages) > 1:
# discord.NotFound can be raised when `len(messages) == 1` and the message does not exist.
# As a result of this obscure behavior, this error needs to be caught just in case.
try:
await channel.delete_messages(messages[:100])
messages = messages[100:]
else:
await messages[0].delete()
messages = []
except discord.errors.HTTPException:
pass
messages = messages[100:]
await asyncio.sleep(1.5)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import re
from typing import Callable, ClassVar, List, Optional, Pattern, Sequence, Tuple, Union, cast

View File

@@ -76,7 +76,6 @@ def bot_repo(event_loop):
commit="",
url="https://empty.com/something.git",
folder_path=cwd,
loop=event_loop,
)
@@ -163,14 +162,7 @@ def _init_test_repo(destination: Path):
async def _session_git_repo(tmp_path_factory, event_loop):
# we will import repo only once once per session and duplicate the repo folder
repo_path = tmp_path_factory.mktemp("session_git_repo")
repo = Repo(
name="redbot-testrepo",
url="",
branch="master",
commit="",
folder_path=repo_path,
loop=event_loop,
)
repo = Repo(name="redbot-testrepo", url="", branch="master", commit="", folder_path=repo_path)
git_dirparams = _init_test_repo(repo_path)
fast_import = sp.Popen((*git_dirparams, "fast-import", "--quiet"), stdin=sp.PIPE)
with TEST_REPO_EXPORT_PTH.open(mode="rb") as f:
@@ -193,7 +185,6 @@ async def git_repo(_session_git_repo, tmp_path, event_loop):
branch=_session_git_repo.branch,
commit=_session_git_repo.commit,
folder_path=repo_path,
loop=event_loop,
)
return repo
@@ -208,7 +199,6 @@ async def cloned_git_repo(_session_git_repo, tmp_path, event_loop):
branch=_session_git_repo.branch,
commit=_session_git_repo.commit,
folder_path=repo_path,
loop=event_loop,
)
sp.run(("git", "clone", str(_session_git_repo.folder_path), str(repo_path)), check=True)
return repo
@@ -224,7 +214,6 @@ async def git_repo_with_remote(git_repo, tmp_path, event_loop):
branch=git_repo.branch,
commit=git_repo.commit,
folder_path=repo_path,
loop=event_loop,
)
sp.run(("git", "clone", str(git_repo.folder_path), str(repo_path)), check=True)
return repo

View File

@@ -371,8 +371,7 @@ def delete(
remove_datapath: Optional[bool],
):
"""Removes an instance."""
loop = asyncio.get_event_loop()
loop.run_until_complete(
asyncio.run(
remove_instance(
instance, interactive, delete_data, _create_backup, drop_db, remove_datapath
)
@@ -391,14 +390,12 @@ def convert(instance, backend):
default_dirs = deepcopy(data_manager.basic_config_default)
default_dirs["DATA_PATH"] = str(Path(instance_data[instance]["DATA_PATH"]))
loop = asyncio.get_event_loop()
if current_backend == BackendType.MONGOV1:
raise RuntimeError("Please see the 3.2 release notes for upgrading a bot using mongo.")
elif current_backend == BackendType.POSTGRES: # TODO: GH-3115
raise RuntimeError("Converting away from postgres isn't currently supported")
else:
new_storage_details = loop.run_until_complete(do_migration(current_backend, target))
new_storage_details = asyncio.run(do_migration(current_backend, target))
if new_storage_details is not None:
default_dirs["STORAGE_TYPE"] = target.value
@@ -422,8 +419,7 @@ def convert(instance, backend):
)
def backup(instance: str, destination_folder: Union[str, Path]) -> None:
"""Backup instance's data."""
loop = asyncio.get_event_loop()
loop.run_until_complete(create_backup(instance, Path(destination_folder)))
asyncio.run(create_backup(instance, Path(destination_folder)))
def run_cli():

View File

@@ -27,7 +27,7 @@ packages = find_namespace:
python_requires = >=3.8.1
install_requires =
aiohttp==3.6.2
aiohttp-json-rpc==0.12.1
aiohttp-json-rpc==0.12.2
aiosqlite==0.11.0
appdirs==1.4.3
apsw-wheels==3.30.1.post3
@@ -38,7 +38,7 @@ install_requires =
Click==7.0
colorama==0.4.3
contextlib2==0.5.5
discord.py==1.2.5
discord.py==1.3.1
distro==1.4.0; sys_platform == "linux"
fuzzywuzzy==0.17.0
idna==2.8
@@ -46,7 +46,7 @@ install_requires =
python-Levenshtein-wheels==0.13.1
pytz==2019.3
PyYAML==5.3
Red-Lavalink==0.4.1
Red-Lavalink==0.4.2
schema==0.7.1
tqdm==4.41.1
uvloop==0.14.0; sys_platform != "win32" and platform_python_implementation == "CPython"

View File

@@ -12,8 +12,10 @@ _update_event_loop_policy()
@pytest.fixture(scope="session")
def event_loop(request):
"""Create an instance of the default event loop for entire session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
asyncio.set_event_loop(None)
loop.close()