aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/pull_request_template.md30
-rw-r--r--.github/workflows/build.yaml2
-rw-r--r--.gitignore2
-rw-r--r--CODE_OF_CONDUCT.md3
-rw-r--r--CONTRIBUTING.md126
-rw-r--r--Dockerfile6
-rw-r--r--Pipfile5
-rw-r--r--Pipfile.lock472
-rw-r--r--SECURITY.md3
-rw-r--r--bot/__init__.py14
-rw-r--r--bot/bot.py41
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py44
-rw-r--r--bot/exts/easter/april_fools_vids.py26
-rw-r--r--bot/exts/easter/avatar_easterifier.py128
-rw-r--r--bot/exts/easter/easter_riddle.py13
-rw-r--r--bot/exts/easter/egg_decorating.py4
-rw-r--r--bot/exts/evergreen/8bitify.py54
-rw-r--r--bot/exts/evergreen/avatar_modification/__init__.py0
-rw-r--r--bot/exts/evergreen/avatar_modification/_effects.py287
-rw-r--r--bot/exts/evergreen/avatar_modification/avatar_modify.py370
-rw-r--r--bot/exts/evergreen/battleship.py2
-rw-r--r--bot/exts/evergreen/catify.py88
-rw-r--r--bot/exts/evergreen/error_handler.py10
-rw-r--r--bot/exts/evergreen/fun.py3
-rw-r--r--bot/exts/evergreen/githubinfo.py143
-rw-r--r--bot/exts/evergreen/help.py4
-rw-r--r--bot/exts/evergreen/issues.py302
-rw-r--r--bot/exts/evergreen/latex.py94
-rw-r--r--bot/exts/evergreen/ping.py44
-rw-r--r--bot/exts/evergreen/reddit.py425
-rw-r--r--bot/exts/evergreen/timed.py46
-rw-r--r--bot/exts/evergreen/uptime.py33
-rw-r--r--bot/exts/evergreen/wolfram.py3
-rw-r--r--bot/exts/halloween/candy_collection.py3
-rw-r--r--bot/exts/halloween/spookyavatar.py52
-rw-r--r--bot/exts/internal_eval/__init__.py10
-rw-r--r--bot/exts/internal_eval/_helpers.py249
-rw-r--r--bot/exts/internal_eval/_internal_eval.py176
-rw-r--r--bot/exts/pride/pride_avatar.py177
-rw-r--r--bot/group.py18
-rw-r--r--bot/resources/easter/april_fools_vids.json251
-rw-r--r--bot/resources/easter/easter_riddle.json8
-rw-r--r--bot/resources/evergreen/py_topics.yaml6
-rw-r--r--bot/resources/evergreen/starter.yaml20
-rw-r--r--bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3bin118125 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3bin112365 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3bin137385 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3bin135405 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3bin162421 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3bin131625 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3bin163257 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3bin131566 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3bin153226 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3bin114773 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3bin298717 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3bin177049 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3bin148276 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3bin62171 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3bin133651 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3bin74718 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/sources.txt41
-rw-r--r--bot/resources/pride/gender_options.json41
-rw-r--r--bot/utils/converters.py32
-rw-r--r--bot/utils/exceptions.py2
-rw-r--r--bot/utils/helpers.py8
-rw-r--r--bot/utils/messages.py19
-rw-r--r--bot/utils/pagination.py6
68 files changed, 2747 insertions, 1217 deletions
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e2739287..ba418166 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,30 +1,20 @@
## Relevant Issues
-<!-- List relevant issue tickets here. -->
-<!-- Say "Closes #0" for issues that the PR resolves, replacing 0 with the issue number. -->
+<!--
+It is mandatory to link to an issue that has been approved by a Core Developer, indicated by an "approved" label.
+Issues can be skipped with explicit core dev approval, but you have to link the discussion.
+-->
-
-## Description
-<!-- Describe how you've implemented your changes. -->
-
-
-## Reasoning
-<!-- Outline the reasoning for how it's been implemented. -->
+<!-- Link the issue by typing: "Closes #<number>" (Closes #0 to close issue 0 for example). -->
-## Screenshots
-<!-- Remove this section if the changes don't impact anything user-facing. -->
-<!-- You can add images by just copy pasting them directly in the editor. -->
-
-
-## Additional Details
-<!-- Delete this section if not applicable. -->
-
+## Description
+<!-- Describe what changes you made, and how you've implemented them. -->
## Did you:
<!-- These are required when contributing. -->
<!-- Replace [ ] with [x] to mark items as done. -->
- [ ] Join the [**Python Discord Community**](https://discord.gg/python)?
-- [ ] If dependencies have been added or updated, run `pipenv lock`?
-- [ ] **Lint your code** (`pipenv run lint`)?
-- [ ] Set the PR to **allow edits from contributors**?
+- [ ] Read all the comments in this template?
+- [ ] Ensure there is an issue open, or link relevant discord discussions?
+- [ ] Read the [contributing guidelines](https://pythondiscord.com/pages/contributing/contributing-guidelines/)?
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 0a006eb9..baa046ce 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -36,7 +36,7 @@ jobs:
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
- password: ${{ secrets.GHCR_TOKEN }}
+ password: ${{ secrets.GITHUB_TOKEN }}
# Build and push the container to the GitHub Container
# Repository. The container will be tagged as "latest"
diff --git a/.gitignore b/.gitignore
index d3d2bb8d..ce122d29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
# bot (project-specific)
log/*
data/*
-
+_latex_cache/*
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..57ccd80e
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,3 @@
+# Code of Conduct
+
+The Python Discord Code of Conduct can be found [on our website](https://pydis.com/coc).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3a1803e2..f20b5316 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,125 +1,3 @@
-# Contributing to Sir Lancebot
+# Contributing Guidelines
-Sir Lancebot is a community project for the Python Discord community over at https://discord.gg/python. We will be providing support for those of you who are new to Git, and this project is to be considered educational.
-
-Our projects are open-source and are automatically deployed whenever commits are pushed to the `main` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order.
-
-Note that contributions may be rejected on the basis of a contributor failing to follow these guidelines.
-
-## Rules
-
-1. You must be a member of [our Discord community](https://discord.gg/python) in order to contribute to this project.
-2. Your pull request must solve an issue created or approved by a staff member. These will be labeled with the `approved` label. Feel free to suggest issues of your own, which staff can review for approval.
-3. **No force-pushes** or modifying the Git history in any way.
-4. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there.
- * It's common practice for a repository to reject direct pushes to `main`, so make branching a habit!
- * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
-5. **Adhere to the prevailing code style**, which we enforce using [`flake8`](http://flake8.pycqa.org/en/latest/index.html) and [`pre-commit`](https://pre-commit.com/).
- * Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint.
- * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful git feature for executing custom scripts when certain important git actions occur. The pre-commit hook is the first hook executed during the commit process and can be used to check the code being committed & abort the commit if issues, such as linting failures, are detected. While git hooks can seem daunting to configure, the `pre-commit` framework abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about committing code that fails linting.
-6. **Make great commits**. A well structured git log is key to a project's maintainability; it efficiently provides insight into when and *why* things were done for future maintainers of the project.
- * Commits should be as narrow in scope as possible. Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. After about a week they'll probably be hard for you to follow too.
- * Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway.
- * A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/)
-7. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed.
- * This includes merging main into your branch. Try to leave merging from main for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to main for your branch, or something was pushed to main that could potentionally affect the functionality of what you're writing.
-8. **Don't fight the framework**. Every framework has its flaws, but the frameworks we've picked out have been carefully chosen for their particular merits. If you can avoid it, please resist reimplementing swathes of framework logic - the work has already been done for you!
-9. If someone is working on an issue or pull request, **do not open your own pull request for the same task**. Instead, collaborate with the author(s) of the existing pull request. Duplicate PRs opened without communicating with the other author(s) and/or PyDis staff will be closed. Communication is key, and there's no point in two separate implementations of the same thing.
- * One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well.
- * The author(s) of inactive PRs and claimed issues will be be pinged after a week of inactivity for an update. Continued inactivity may result in the issue being released back to the community and/or PR closure.
-10. **Work as a team** and collaborate wherever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on.
-11. All static content, such as images or audio, **must be licensed for open public use**.
- * Static content must be hosted by a service designed to do so. Failing to do so is known as "leeching" and is frowned upon, as it generates extra bandwidth costs to the host without providing benefit. It would be best if appropriately licensed content is added to the repository itself so it can be served by PyDis' infrastructure.
-
-Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role.
-
-## Changes to this Arrangement
-
-All projects evolve over time, and this contribution guide is no different. This document is open to pull requests or changes by contributors. If you believe you have something valuable to add or change, please don't hesitate to do so in a PR.
-
-## Supplemental Information
-### Developer Environment
-Sir Lancebot utilizes [Pipenv](https://pipenv.readthedocs.io/en/latest/) for installation and dependency management. For users unfamiliar with the Pipenv workflow, Pipenv's documentation provides a [Basic Usage](https://pipenv.readthedocs.io/en/latest/basics/) tutorial, along with some of the more advanced workflows. A project-specific installation guide can be found in [Sir Lancebot's README](https://github.com/python-discord/sir-lancebot/blob/main/README.md).
-
-When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies.
-
-### Type Hinting
-[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types.
-
-For example:
-
-```py
-import typing as t
-
-
-def foo(input_1: int, input_2: t.Dict[str, str]) -> bool:
- ...
-```
-
-Tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`.
-
-All function declarations should be type hinted in code contributed to the PyDis organization.
-
-For more information, see *[PEP 483](https://www.python.org/dev/peps/pep-0483/) - The Theory of Type Hints* and Python's documentation for the [`typing`](https://docs.python.org/3/library/typing.html) module.
-
-### AutoDoc Formatting Directives
-Many documentation packages provide support for automatic documentation generation from the codebase's docstrings. These tools utilize special formatting directives to enable richer formatting in the generated documentation.
-
-For example:
-
-```py
-import typing as t
-
-
-def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool:
- """
- Does some things with some stuff.
-
- :param bar: Some input
- :param baz: Optional, some dictionary with string keys and values
-
- :return: Some boolean
- """
- ...
-```
-
-Since PyDis does not utilize automatic documentation generation, use of this syntax should not be used in code contributed to the organization. Should the purpose and type of the input variables not be easily discernable from the variable name and type annotation, a prose explanation can be used. Explicit references to variables, functions, classes, etc. should be wrapped with backticks (`` ` ``).
-
-For example, the above docstring would become:
-
-```py
-import typing as t
-
-
-def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool:
- """
- Does some things with some stuff.
-
- This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. Returns `False` if `baz` is not passed.
- """
- ...
-```
-
-### Logging Levels
-The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows, from lowest to highest severity:
-* **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code.
- * **Note:** This is a PyDis-implemented logging level.
-* **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while working on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity.
-* **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up.
-* **WARNING:** These events are out of the ordinary and should be fixed, but have not caused a failure.
- * **NOTE:** Events at this logging level and higher should be reserved for events that require the attention of the DevOps team.
-* **ERROR:** These events have caused a failure in a specific part of the application and require urgent attention.
-* **CRITICAL:** These events have caused the whole application to fail and require immediate intervention.
-
-Ensure that log messages are succinct. Should you want to pass additional useful information that would otherwise make the log message overly verbose the `logging` module accepts an `extra` kwarg, which can be used to pass a dictionary. This is used to populate the `__dict__` of the `LogRecord` created for the logging event with user-defined attributes that can be accessed by a log handler. Additional information and caveats may be found [in Python's `logging` documentation](https://docs.python.org/3/library/logging.html#logging.Logger.debug).
-
-### Work in Progress (WIP) PRs
-Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review.
-
-This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
-
-As stated earlier, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
-
-## Footnotes
-
-This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md).
+The Contributing Guidelines for Python Discord projects can be found [on our website](https://pydis.com/contributing.md).
diff --git a/Dockerfile b/Dockerfile
index 0db0b0ef..8c4920a9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,12 +6,6 @@ ENV PIP_NO_CACHE_DIR=false \
PIPENV_IGNORE_VIRTUALENVS=1 \
PIPENV_NOSPIN=1
-# Install git to be able to dowload git dependencies in the Pipfile
-RUN apt-get -y update \
- && apt-get install -y \
- ffmpeg \
- && rm -rf /var/lib/apt/lists/*
-
# Install pipenv
RUN pip install -U pipenv
diff --git a/Pipfile b/Pipfile
index 2e922ec4..f6118f1a 100644
--- a/Pipfile
+++ b/Pipfile
@@ -11,10 +11,11 @@ fuzzywuzzy = "~=0.17"
pillow = "~=8.1"
pytz = "~=2019.2"
sentry-sdk = "~=0.19"
-PyYAML = "~=5.3.1"
-"discord.py" = {extras = ["voice"], version = "~=1.5.1"}
+PyYAML = "~=5.4"
+"discord.py" = "~=1.5.1"
async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"}
emojis = "~=0.6.0"
+matplotlib = "~=3.4.1"
[dev-packages]
flake8 = "~=3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
index d09588cf..915c3784 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "cf7535796e0d4f4dd6a414d8db4781caaab4a624380d29abea17577209043742"
+ "sha256": "96cd9674aea76763df9582acd392eece6546876698fffaf9024e5a2daccb8f6f"
},
"pipfile-spec": 6,
"requires": {
@@ -40,6 +40,7 @@
"sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81",
"sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5"
],
+ "markers": "python_full_version >= '3.5.3'",
"version": "==3.6.3"
},
"aioredis": {
@@ -73,6 +74,7 @@
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
+ "markers": "python_full_version >= '3.5.3'",
"version": "==3.0.1"
},
"attrs": {
@@ -80,6 +82,7 @@
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0"
},
"beautifulsoup4": {
@@ -147,10 +150,15 @@
],
"version": "==3.0.4"
},
- "discord.py": {
- "extras": [
- "voice"
+ "cycler": {
+ "hashes": [
+ "sha256:1d8a5ae1ff6c5cf9b93e8811e581232ad8920aeec647c37316ceac982b08cb2d",
+ "sha256:cd7b2d1018258d7247a71425e9f26463dfb444d411c39569972f4ce586b0c9d8"
],
+ "version": "==0.10.0"
+ },
+ "discord.py": {
+ "extras": [],
"hashes": [
"sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563",
"sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b"
@@ -168,10 +176,10 @@
},
"fakeredis": {
"hashes": [
- "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a",
- "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73"
+ "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623",
+ "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"
],
- "version": "==1.4.5"
+ "version": "==1.5.0"
},
"fuzzywuzzy": {
"hashes": [
@@ -183,62 +191,122 @@
},
"hiredis": {
"hashes": [
- "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680",
- "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0",
- "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0",
- "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01",
- "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a",
- "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b",
- "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6",
- "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73",
- "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee",
- "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55",
- "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12",
- "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b",
- "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323",
- "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c",
- "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655",
- "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5",
- "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75",
- "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb",
- "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23",
- "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1",
- "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f",
- "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872",
- "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058",
- "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454",
- "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882",
- "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2",
- "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132",
- "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6",
- "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c",
- "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363",
- "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3",
- "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4",
- "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919",
- "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349",
- "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae",
- "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da",
- "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f",
- "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed",
- "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628",
- "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64",
- "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86",
- "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf",
- "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c",
- "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded",
- "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
- "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
- ],
- "version": "==1.1.0"
+ "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e",
+ "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27",
+ "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163",
+ "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc",
+ "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26",
+ "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e",
+ "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579",
+ "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a",
+ "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048",
+ "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87",
+ "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63",
+ "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54",
+ "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05",
+ "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb",
+ "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea",
+ "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5",
+ "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e",
+ "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc",
+ "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99",
+ "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a",
+ "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581",
+ "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426",
+ "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db",
+ "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a",
+ "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a",
+ "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d",
+ "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443",
+ "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79",
+ "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d",
+ "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9",
+ "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d",
+ "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485",
+ "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5",
+ "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048",
+ "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0",
+ "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6",
+ "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41",
+ "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298",
+ "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce",
+ "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0",
+ "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==2.0.0"
},
"idna": {
"hashes": [
"sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
"sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
],
+ "markers": "python_version >= '3.4'",
"version": "==3.1"
},
+ "kiwisolver": {
+ "hashes": [
+ "sha256:0cd53f403202159b44528498de18f9285b04482bab2a6fc3f5dd8dbb9352e30d",
+ "sha256:1e1bc12fb773a7b2ffdeb8380609f4f8064777877b2225dec3da711b421fda31",
+ "sha256:225e2e18f271e0ed8157d7f4518ffbf99b9450fca398d561eb5c4a87d0986dd9",
+ "sha256:232c9e11fd7ac3a470d65cd67e4359eee155ec57e822e5220322d7b2ac84fbf0",
+ "sha256:31dfd2ac56edc0ff9ac295193eeaea1c0c923c0355bf948fbd99ed6018010b72",
+ "sha256:33449715e0101e4d34f64990352bce4095c8bf13bed1b390773fc0a7295967b3",
+ "sha256:401a2e9afa8588589775fe34fc22d918ae839aaaf0c0e96441c0fdbce6d8ebe6",
+ "sha256:44a62e24d9b01ba94ae7a4a6c3fb215dc4af1dde817e7498d901e229aaf50e4e",
+ "sha256:50af681a36b2a1dee1d3c169ade9fdc59207d3c31e522519181e12f1b3ba7000",
+ "sha256:563c649cfdef27d081c84e72a03b48ea9408c16657500c312575ae9d9f7bc1c3",
+ "sha256:5989db3b3b34b76c09253deeaf7fbc2707616f130e166996606c284395da3f18",
+ "sha256:5a7a7dbff17e66fac9142ae2ecafb719393aaee6a3768c9de2fd425c63b53e21",
+ "sha256:5c3e6455341008a054cccee8c5d24481bcfe1acdbc9add30aa95798e95c65621",
+ "sha256:5f6ccd3dd0b9739edcf407514016108e2280769c73a85b9e59aa390046dbf08b",
+ "sha256:72c99e39d005b793fb7d3d4e660aed6b6281b502e8c1eaf8ee8346023c8e03bc",
+ "sha256:78751b33595f7f9511952e7e60ce858c6d64db2e062afb325985ddbd34b5c131",
+ "sha256:834ee27348c4aefc20b479335fd422a2c69db55f7d9ab61721ac8cd83eb78882",
+ "sha256:8be8d84b7d4f2ba4ffff3665bcd0211318aa632395a1a41553250484a871d454",
+ "sha256:950a199911a8d94683a6b10321f9345d5a3a8433ec58b217ace979e18f16e248",
+ "sha256:a357fd4f15ee49b4a98b44ec23a34a95f1e00292a139d6015c11f55774ef10de",
+ "sha256:a53d27d0c2a0ebd07e395e56a1fbdf75ffedc4a05943daf472af163413ce9598",
+ "sha256:acef3d59d47dd85ecf909c359d0fd2c81ed33bdff70216d3956b463e12c38a54",
+ "sha256:b38694dcdac990a743aa654037ff1188c7a9801ac3ccc548d3341014bc5ca278",
+ "sha256:b9edd0110a77fc321ab090aaa1cfcaba1d8499850a12848b81be2222eab648f6",
+ "sha256:c08e95114951dc2090c4a630c2385bef681cacf12636fb0241accdc6b303fd81",
+ "sha256:c5518d51a0735b1e6cee1fdce66359f8d2b59c3ca85dc2b0813a8aa86818a030",
+ "sha256:c8fd0f1ae9d92b42854b2979024d7597685ce4ada367172ed7c09edf2cef9cb8",
+ "sha256:ca3820eb7f7faf7f0aa88de0e54681bddcb46e485beb844fcecbcd1c8bd01689",
+ "sha256:cf8b574c7b9aa060c62116d4181f3a1a4e821b2ec5cbfe3775809474113748d4",
+ "sha256:d3155d828dec1d43283bd24d3d3e0d9c7c350cdfcc0bd06c0ad1209c1bbc36d0",
+ "sha256:f8d6f8db88049a699817fd9178782867bf22283e3813064302ac59f61d95be05",
+ "sha256:fd34fbbfbc40628200730bc1febe30631347103fc8d3d4fa012c21ab9c11eca9"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==1.3.1"
+ },
+ "matplotlib": {
+ "hashes": [
+ "sha256:1f83a32e4b6045191f9d34e4dc68c0a17c870b57ef9cca518e516da591246e79",
+ "sha256:2eee37340ca1b353e0a43a33da79d0cd4bcb087064a0c3c3d1329cdea8fbc6f3",
+ "sha256:53ceb12ef44f8982b45adc7a0889a7e2df1d758e8b360f460e435abe8a8cd658",
+ "sha256:574306171b84cd6854c83dc87bc353cacc0f60184149fb00c9ea871eca8c1ecb",
+ "sha256:7561fd541477d41f3aa09457c434dd1f7604f3bd26d7858d52018f5dfe1c06d1",
+ "sha256:7a54efd6fcad9cb3cd5ef2064b5a3eeb0b63c99f26c346bdcf66e7c98294d7cc",
+ "sha256:7f16660edf9a8bcc0f766f51c9e1b9d2dc6ceff6bf636d2dbd8eb925d5832dfd",
+ "sha256:81e6fe8b18ef5be67f40a1d4f07d5a4ed21d3878530193898449ddef7793952f",
+ "sha256:84a10e462120aa7d9eb6186b50917ed5a6286ee61157bfc17c5b47987d1a9068",
+ "sha256:84d4c4f650f356678a5d658a43ca21a41fca13f9b8b00169c0b76e6a6a948908",
+ "sha256:86dc94e44403fa0f2b1dd76c9794d66a34e821361962fe7c4e078746362e3b14",
+ "sha256:90dbc007f6389bcfd9ef4fe5d4c78c8d2efe4e0ebefd48b4f221cdfed5672be2",
+ "sha256:9f374961a3996c2d1b41ba3145462c3708a89759e604112073ed6c8bdf9f622f",
+ "sha256:a18cc1ab4a35b845cf33b7880c979f5c609fd26c2d6e74ddfacb73dcc60dd956",
+ "sha256:a97781453ac79409ddf455fccf344860719d95142f9c334f2a8f3fff049ffec3",
+ "sha256:a989022f89cda417f82dbf65e0a830832afd8af743d05d1414fb49549287ff04",
+ "sha256:ac2a30a09984c2719f112a574b6543ccb82d020fd1b23b4d55bf4759ba8dd8f5",
+ "sha256:be4430b33b25e127fc4ea239cc386389de420be4d63e71d5359c20b562951ce1",
+ "sha256:c45e7bf89ea33a2adaef34774df4e692c7436a18a48bcb0e47a53e698a39fa39"
+ ],
+ "index": "pypi",
+ "version": "==3.4.1"
+ },
"multidict": {
"hashes": [
"sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a",
@@ -259,46 +327,77 @@
"sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255",
"sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"
],
+ "markers": "python_version >= '3.5'",
"version": "==4.7.6"
},
+ "numpy": {
+ "hashes": [
+ "sha256:2428b109306075d89d21135bdd6b785f132a1f5a3260c371cee1fae427e12727",
+ "sha256:377751954da04d4a6950191b20539066b4e19e3b559d4695399c5e8e3e683bf6",
+ "sha256:4703b9e937df83f5b6b7447ca5912b5f5f297aba45f91dbbbc63ff9278c7aa98",
+ "sha256:471c0571d0895c68da309dacee4e95a0811d0a9f9f532a48dc1bea5f3b7ad2b7",
+ "sha256:61d5b4cf73622e4d0c6b83408a16631b670fc045afd6540679aa35591a17fe6d",
+ "sha256:6c915ee7dba1071554e70a3664a839fbc033e1d6528199d4621eeaaa5487ccd2",
+ "sha256:6e51e417d9ae2e7848314994e6fc3832c9d426abce9328cf7571eefceb43e6c9",
+ "sha256:719656636c48be22c23641859ff2419b27b6bdf844b36a2447cb39caceb00935",
+ "sha256:780ae5284cb770ade51d4b4a7dce4faa554eb1d88a56d0e8b9f35fca9b0270ff",
+ "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee",
+ "sha256:924dc3f83de20437de95a73516f36e09918e9c9c18d5eac520062c49191025fb",
+ "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042",
+ "sha256:9c0fab855ae790ca74b27e55240fe4f2a36a364a3f1ebcfd1fb5ac4088f1cec3",
+ "sha256:9cab23439eb1ebfed1aaec9cd42b7dc50fc96d5cd3147da348d9161f0501ada5",
+ "sha256:a8e6859913ec8eeef3dbe9aed3bf475347642d1cdd6217c30f28dee8903528e6",
+ "sha256:aa046527c04688af680217fffac61eec2350ef3f3d7320c07fd33f5c6e7b4d5f",
+ "sha256:abc81829c4039e7e4c30f7897938fa5d4916a09c2c7eb9b244b7a35ddc9656f4",
+ "sha256:bad70051de2c50b1a6259a6df1daaafe8c480ca98132da98976d8591c412e737",
+ "sha256:c73a7975d77f15f7f68dacfb2bca3d3f479f158313642e8ea9058eea06637931",
+ "sha256:d15007f857d6995db15195217afdbddfcd203dfaa0ba6878a2f580eaf810ecd6",
+ "sha256:d76061ae5cab49b83a8cf3feacefc2053fac672728802ac137dd8c4123397677",
+ "sha256:e8e4fbbb7e7634f263c5b0150a629342cc19b47c5eba8d1cd4363ab3455ab576",
+ "sha256:e9459f40244bb02b2f14f6af0cd0732791d72232bbb0dc4bab57ef88e75f6935",
+ "sha256:edb1f041a9146dcf02cd7df7187db46ab524b9af2515f392f337c7cbbf5b52cd"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==1.20.2"
+ },
"pillow": {
"hashes": [
- "sha256:01bb0a34f1a6689b138c0089d670ae2e8f886d2666a9b2f2019031abdea673c4",
- "sha256:07872f1d8421db5a3fe770f7480835e5e90fddb58f36c216d4a2ac0d594de474",
- "sha256:1022f8f6dc3c5b0dcf928f1c49ba2ac73051f576af100d57776e2b65c1f76a8d",
- "sha256:14415e9e28410232370615dbde0cf0a00e526f522f665460344a5b96973a3086",
- "sha256:172acfaf00434a28dddfe592d83f2980e22e63c769ff4a448ddf7b7a38ffd165",
- "sha256:1c5e3c36f02c815766ae9dd91899b1c5b4652f2a37b7a51609f3bd467c0f11fb",
- "sha256:292f2aa1ae5c5c1451cb4b558addb88c257411d3fd71c6cf45562911baffc979",
- "sha256:2a40d7d4b17db87f5b9a1efc0aff56000e1d0d5ece415090c102aafa0ccbe858",
- "sha256:2f0d7034d5faae9a8d1019d152ede924f653df2ce77d3bba4ce62cd21b5f94ae",
- "sha256:33fdbd4f5608c852d97264f9d2e3b54e9e9959083d008145175b86100b275e5b",
- "sha256:3b13d89d97b551e02549d1f0edf22bed6acfd6fd2e888cd1e9a953bf215f0e81",
- "sha256:3e759bcc03d6f39bc751e56d86bc87252b9a21c689a27c5ed753717a87d53a5b",
- "sha256:3ec87bd1248b23a2e4e19e774367fbe30fddc73913edc5f9b37470624f55dc1f",
- "sha256:436b0a2dd9fe3f7aa6a444af6bdf53c1eb8f5ced9ea3ef104daa83f0ea18e7bc",
- "sha256:43b3c859912e8bf754b3c5142df624794b18eb7ae07cfeddc917e1a9406a3ef2",
- "sha256:4fe74636ee71c57a7f65d7b21a9f127d842b4fb75511e5d256ace258826eb352",
- "sha256:59445af66b59cc39530b4f810776928d75e95f41e945f0c32a3de4aceb93c15d",
- "sha256:69da5b1d7102a61ce9b45deb2920a2012d52fd8f4201495ea9411d0071b0ec22",
- "sha256:7094bbdecb95ebe53166e4c12cf5e28310c2b550b08c07c5dc15433898e2238e",
- "sha256:8211cac9bf10461f9e33fe9a3af6c5131f3fdd0d10672afc2abb2c70cf95c5ca",
- "sha256:8cf77e458bd996dc85455f10fe443c0c946f5b13253773439bcbec08aa1aebc2",
- "sha256:924fc33cb4acaf6267b8ca3b8f1922620d57a28470d5e4f49672cea9a841eb08",
- "sha256:99ce3333b40b7a4435e0a18baad468d44ab118a4b1da0af0a888893d03253f1d",
- "sha256:a7d690b2c5f7e4a932374615fedceb1e305d2dd5363c1de15961725fe10e7d16",
- "sha256:b9af590adc1e46898a1276527f3cfe2da8048ae43fbbf9b1bf9395f6c99d9b47",
- "sha256:bb18422ad00c1fecc731d06592e99c3be2c634da19e26942ba2f13d805005cf2",
- "sha256:c10af40ee2f1a99e1ae755ab1f773916e8bca3364029a042cd9161c400416bd8",
- "sha256:c143c409e7bc1db784471fe9d0bf95f37c4458e879ad84cfae640cb74ee11a26",
- "sha256:c448d2b335e21951416a30cd48d35588d122a912d5fe9e41900afacecc7d21a1",
- "sha256:d30f30c044bdc0ab8f3924e1eeaac87e0ff8a27e87369c5cac4064b6ec78fd83",
- "sha256:df534e64d4f3e84e8f1e1a37da3f541555d947c1c1c09b32178537f0f243f69d",
- "sha256:f6fc18f9c9c7959bf58e6faf801d14fafb6d4717faaf6f79a68c8bb2a13dcf20",
- "sha256:ff83dfeb04c98bb3e7948f876c17513a34e9a19fd92e292288649164924c1b39"
+ "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5",
+ "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4",
+ "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9",
+ "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a",
+ "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9",
+ "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727",
+ "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120",
+ "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c",
+ "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2",
+ "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797",
+ "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b",
+ "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f",
+ "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef",
+ "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232",
+ "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb",
+ "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9",
+ "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812",
+ "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178",
+ "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b",
+ "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5",
+ "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b",
+ "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1",
+ "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713",
+ "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4",
+ "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484",
+ "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c",
+ "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9",
+ "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388",
+ "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d",
+ "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602",
+ "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9",
+ "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e",
+ "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"
],
"index": "pypi",
- "version": "==8.1.1"
+ "version": "==8.2.0"
},
"pycares": {
"hashes": [
@@ -339,39 +438,23 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20"
},
- "pynacl": {
- "hashes": [
- "sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255",
- "sha256:0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c",
- "sha256:0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e",
- "sha256:1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae",
- "sha256:2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621",
- "sha256:2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56",
- "sha256:30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39",
- "sha256:37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310",
- "sha256:4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1",
- "sha256:53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5",
- "sha256:57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a",
- "sha256:5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786",
- "sha256:6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b",
- "sha256:7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b",
- "sha256:a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f",
- "sha256:a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20",
- "sha256:aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415",
- "sha256:bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715",
- "sha256:bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92",
- "sha256:e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1",
- "sha256:f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0"
- ],
- "version": "==1.3.0"
+ "pyparsing": {
+ "hashes": [
+ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
+ "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
+ ],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.4.7"
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1"
},
"pytz": {
@@ -384,28 +467,45 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
- "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
- "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
- "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
- "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
- "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
- "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
+ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
+ "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
+ "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
+ "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
+ "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
+ "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
+ "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
+ "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
+ "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
+ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
+ "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
+ "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347",
+ "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
+ "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541",
+ "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
+ "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
+ "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc",
+ "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
+ "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa",
+ "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
+ "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122",
+ "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
+ "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
+ "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc",
+ "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247",
+ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6",
+ "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"
],
"index": "pypi",
- "version": "==5.3.1"
+ "version": "==5.4.1"
},
"redis": {
"hashes": [
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==3.5.3"
},
"sentry-sdk": {
@@ -421,6 +521,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"sortedcontainers": {
@@ -432,17 +533,18 @@
},
"soupsieve": {
"hashes": [
- "sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd",
- "sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6"
+ "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
+ "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
],
"markers": "python_version >= '3.0'",
- "version": "==2.2"
+ "version": "==2.2.1"
},
"urllib3": {
"hashes": [
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.4"
},
"yarl": {
@@ -465,6 +567,7 @@
"sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317",
"sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.5.1"
}
},
@@ -481,6 +584,7 @@
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0"
},
"cfgv": {
@@ -488,6 +592,7 @@
"sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
"sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
],
+ "markers": "python_full_version >= '3.6.1'",
"version": "==3.2.0"
},
"distlib": {
@@ -506,19 +611,19 @@
},
"flake8": {
"hashes": [
- "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
- "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+ "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378",
+ "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"
],
"index": "pypi",
- "version": "==3.8.4"
+ "version": "==3.9.1"
},
"flake8-annotations": {
"hashes": [
- "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055",
- "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"
+ "sha256:0d6cd2e770b5095f09689c9d84cc054c51b929c41a68969ea1beb4b825cac515",
+ "sha256:d10c4638231f8a50c0a597c4efce42bd7b7d85df4f620a0ddaca526138936a4f"
],
"index": "pypi",
- "version": "==2.5.0"
+ "version": "==2.6.2"
},
"flake8-bugbear": {
"hashes": [
@@ -530,11 +635,11 @@
},
"flake8-docstrings": {
"hashes": [
- "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717",
- "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"
+ "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde",
+ "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"
],
"index": "pypi",
- "version": "==1.5.0"
+ "version": "==1.6.0"
},
"flake8-import-order": {
"hashes": [
@@ -576,10 +681,11 @@
},
"identify": {
"hashes": [
- "sha256:46d1816c6a4fc2d1e8758f293a5dcc1ae6404ab344179d7c1e73637bf283beb1",
- "sha256:ed4a05fb80e3cbd12e83c959f9ff7f729ba6b66ab8d6178850fd5cb4c1cf6c5d"
+ "sha256:398cb92a7599da0b433c65301a1b62b9b1f4bb8248719b84736af6c0b22289d6",
+ "sha256:4537474817e0bbb8cea3e5b7504b7de6d44e3f169a90846cbc6adb0fc8294502"
],
- "version": "==2.1.3"
+ "markers": "python_full_version >= '3.6.1'",
+ "version": "==2.2.3"
},
"mccabe": {
"hashes": [
@@ -590,10 +696,10 @@
},
"nodeenv": {
"hashes": [
- "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
- "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
+ "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b",
+ "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"
],
- "version": "==1.5.0"
+ "version": "==1.6.0"
},
"pep8-naming": {
"hashes": [
@@ -605,57 +711,77 @@
},
"pre-commit": {
"hashes": [
- "sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e",
- "sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a"
+ "sha256:029d53cb83c241fe7d66eeee1e24db426f42c858f15a38d20bcefd8d8e05c9da",
+ "sha256:46b6ffbab37986c47d0a35e40906ae029376deed89a0eb2e446fb6e67b220427"
],
"index": "pypi",
- "version": "==2.10.1"
+ "version": "==2.12.0"
},
"pycodestyle": {
"hashes": [
- "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
- "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+ "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068",
+ "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"
],
- "version": "==2.6.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.7.0"
},
"pydocstyle": {
"hashes": [
- "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
- "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
+ "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f",
+ "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"
],
- "version": "==5.1.1"
+ "markers": "python_version >= '3.6'",
+ "version": "==6.0.0"
},
"pyflakes": {
"hashes": [
- "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
- "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+ "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3",
+ "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"
],
- "version": "==2.2.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.3.1"
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
- "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
- "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
- "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
- "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
- "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
- "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
+ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
+ "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
+ "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
+ "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
+ "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
+ "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
+ "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
+ "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
+ "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
+ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
+ "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
+ "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347",
+ "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
+ "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541",
+ "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
+ "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
+ "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc",
+ "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
+ "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa",
+ "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
+ "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122",
+ "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
+ "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
+ "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc",
+ "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247",
+ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6",
+ "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"
],
"index": "pypi",
- "version": "==5.3.1"
+ "version": "==5.4.1"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"snowballstemmer": {
@@ -670,6 +796,7 @@
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"virtualenv": {
@@ -677,6 +804,7 @@
"sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107",
"sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4.3"
}
}
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..fa5a88a3
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,3 @@
+# Security Notice
+
+The Security Notice for Python Discord projects can be found [on our website](https://pydis.com/security.md).
diff --git a/bot/__init__.py b/bot/__init__.py
index bdb18666..71b7c8a3 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,11 +2,15 @@ import asyncio
import logging
import logging.handlers
import os
+from functools import partial, partialmethod
from pathlib import Path
import arrow
+from discord.ext import commands
+from bot.command import Command
from bot.constants import Client
+from bot.group import Group
# Configure the "TRACE" logging level (e.g. "log.trace(message)")
@@ -56,6 +60,7 @@ if root.handlers:
logging.getLogger("discord").setLevel(logging.ERROR)
logging.getLogger("websockets").setLevel(logging.ERROR)
logging.getLogger("PIL").setLevel(logging.ERROR)
+logging.getLogger("matplotlib").setLevel(logging.ERROR)
# Setup new logging configuration
logging.basicConfig(
@@ -70,3 +75,12 @@ logging.getLogger().info('Logging initialization complete')
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+# Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases.
+# Must be patched before any cogs are added.
+commands.command = partial(commands.command, cls=Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
+
+commands.group = partial(commands.group, cls=Group)
+commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=Group)
diff --git a/bot/bot.py b/bot/bot.py
index e9750697..7e495940 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -64,6 +64,26 @@ class Bot(commands.Bot):
super().add_cog(cog)
log.info(f"Cog loaded: {cog.qualified_name}")
+ def add_command(self, command: commands.Command) -> None:
+ """Add `command` as normal and then add its root aliases to the bot."""
+ super().add_command(command)
+ self._add_root_aliases(command)
+
+ def remove_command(self, name: str) -> Optional[commands.Command]:
+ """
+ Remove a command/alias as normal and then remove its root aliases from the bot.
+
+ Individual root aliases cannot be removed by this function.
+ To remove them, either remove the entire command or manually edit `bot.all_commands`.
+ """
+ command = super().remove_command(name)
+ if command is None:
+ # Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
+ return
+
+ self._remove_root_aliases(command)
+ return command
+
async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:
"""Check command errors for UserInputError and reset the cooldown if thrown."""
if isinstance(exception, commands.UserInputError):
@@ -139,6 +159,27 @@ class Bot(commands.Bot):
"""
await self._guild_available.wait()
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
+
_allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 00000000..0fb900f7
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index 416dd0e7..549d01b6 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -8,6 +8,7 @@ from typing import Dict, NamedTuple
__all__ = (
"AdventOfCode",
"Branding",
+ "Cats",
"Channels",
"Categories",
"Client",
@@ -19,6 +20,7 @@ __all__ = (
"Roles",
"Tokens",
"Wolfram",
+ "Reddit",
"RedisConfig",
"MODERATION_ROLES",
"STAFF_ROLES",
@@ -93,6 +95,10 @@ class Branding:
cycle_frequency = int(environ.get("CYCLE_FREQUENCY", 3)) # 0: never, 1: every day, 2: every other day, ...
+class Cats:
+ cats = ["ᓚᘏᗢ", "ᘡᘏᗢ", "🐈", "ᓕᘏᗢ", "ᓇᘏᗢ", "ᓂᘏᗢ", "ᘣᘏᗢ", "ᕦᘏᗢ", "ᕂᘏᗢ"]
+
+
class Channels(NamedTuple):
advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306))
advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 607247579608121354))
@@ -110,6 +116,7 @@ class Channels(NamedTuple):
voice_chat_0 = 412357430186344448
voice_chat_1 = 799647045886541885
staff_voice = 541638762007101470
+ reddit = int(environ.get("CHANNEL_REDDIT", 458224812528238616))
class Categories(NamedTuple):
@@ -147,13 +154,30 @@ class Colours:
python_yellow = 0xFFD43B
grass_green = 0x66ff00
+ easter_like_colours = [
+ (255, 247, 0),
+ (255, 255, 224),
+ (0, 255, 127),
+ (189, 252, 201),
+ (255, 192, 203),
+ (255, 160, 122),
+ (181, 115, 220),
+ (221, 160, 221),
+ (200, 162, 200),
+ (238, 130, 238),
+ (135, 206, 235),
+ (0, 204, 204),
+ (64, 224, 208),
+ ]
+
class Emojis:
+ cross_mark = "\u274C"
star = "\u2B50"
christmas_tree = "\U0001F384"
check = "\u2611"
envelope = "\U0001F4E8"
- trashcan = "<:trashcan:637136429717389331>"
+ trashcan = environ.get("TRASHCAN_EMOJI", "<:trashcan:637136429717389331>")
ok_hand = ":ok_hand:"
hand_raised = "\U0001f64b"
@@ -168,6 +192,7 @@ class Emojis:
issue_closed = "<:IssueClosed:629695470570307614>"
pull_request = "<:PROpen:629695470175780875>"
pull_request_closed = "<:PRClosed:629695470519713818>"
+ pull_request_draft = "<:PRDraft:829755345425399848>"
merge = "<:PRMerged:629695470570176522>"
number_emojis = {
@@ -194,6 +219,15 @@ class Emojis:
status_dnd = "<:status_dnd:470326272082313216>"
status_offline = "<:status_offline:470326266537705472>"
+ # Reddit emojis
+ reddit = "<:reddit:676030265734332427>"
+ reddit_post_text = "<:reddit_post_text:676030265910493204>"
+ reddit_post_video = "<:reddit_post_video:676030265839190047>"
+ reddit_post_photo = "<:reddit_post_photo:676030265734201344>"
+ reddit_upvote = "<:reddit_upvote:755845219890757644>"
+ reddit_comments = "<:reddit_comments:755845255001014384>"
+ reddit_users = "<:reddit_users:755845303822974997>"
+
class Icons:
questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png"
@@ -270,6 +304,14 @@ class Source:
github_avatar_url = "https://avatars1.githubusercontent.com/u/9919"
+class Reddit:
+ subreddits = ["r/Python"]
+
+ client_id = environ.get("REDDIT_CLIENT_ID")
+ secret = environ.get("REDDIT_SECRET")
+ webhook = int(environ.get("REDDIT_WEBHOOK", 635408384794951680))
+
+
# Default role combinations
MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py
index efe7e677..c7a3c014 100644
--- a/bot/exts/easter/april_fools_vids.py
+++ b/bot/exts/easter/april_fools_vids.py
@@ -1,36 +1,26 @@
import logging
import random
from json import load
-from pathlib import Path
from discord.ext import commands
log = logging.getLogger(__name__)
+with open("bot/resources/easter/april_fools_vids.json", encoding="utf-8") as f:
+ ALL_VIDS = load(f)
+
class AprilFoolVideos(commands.Cog):
"""A cog for April Fools' that gets a random April Fools' video from Youtube."""
- def __init__(self, bot: commands.Bot):
- self.bot = bot
- self.yt_vids = self.load_json()
- self.youtubers = ['google'] # will add more in future
-
- @staticmethod
- def load_json() -> dict:
- """A function to load JSON data."""
- p = Path('bot/resources/easter/april_fools_vids.json')
- with p.open(encoding="utf-8") as json_file:
- all_vids = load(json_file)
- return all_vids
-
@commands.command(name='fool')
async def april_fools(self, ctx: commands.Context) -> None:
"""Get a random April Fools' video from Youtube."""
- random_youtuber = random.choice(self.youtubers)
- category = self.yt_vids[random_youtuber]
- random_vid = random.choice(category)
- await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}")
+ video = random.choice(ALL_VIDS)
+
+ channel, url = video["channel"], video["url"]
+
+ await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}")
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py
deleted file mode 100644
index 8e8a3500..00000000
--- a/bot/exts/easter/avatar_easterifier.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import asyncio
-import logging
-from io import BytesIO
-from pathlib import Path
-from typing import Tuple, Union
-
-import discord
-from PIL import Image
-from PIL.ImageOps import posterize
-from discord.ext import commands
-
-log = logging.getLogger(__name__)
-
-COLOURS = [
- (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203),
- (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238),
- (135, 206, 235), (0, 204, 204), (64, 224, 208)
-] # Pastel colours - Easter-like
-
-
-class AvatarEasterifier(commands.Cog):
- """Put an Easter spin on your avatar or image!"""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @staticmethod
- def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]:
- """
- Finds the closest easter colour to a given pixel.
-
- Returns a merge between the original colour and the closest colour
- """
- r1, g1, b1 = x
-
- def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]:
- """Finds the difference between a pastel colour and the original pixel colour."""
- r2, g2, b2 = point
- return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2)
-
- closest_colours = sorted(COLOURS, key=lambda point: distance(point))
- r2, g2, b2 = closest_colours[0]
- r = (r1 + r2) // 2
- g = (g1 + g2) // 2
- b = (b1 + b2) // 2
-
- return (r, g, b)
-
- @commands.command(pass_context=True, aliases=["easterify"])
- async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None:
- """
- This "Easterifies" the user's avatar.
-
- Given colours will produce a personalised egg in the corner, similar to the egg_decorate command.
- If colours are not given, a nice little chocolate bunny will sit in the corner.
- Colours are split by spaces, unless you wrap the colour name in double quotes.
- Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
- """
- async def send(*args, **kwargs) -> str:
- """
- This replaces the original ctx.send.
-
- When invoking the egg decorating command, the egg itself doesn't print to to the channel.
- Returns the message content so that if any errors occur, the error message can be output.
- """
- if args:
- return args[0]
-
- async with ctx.typing():
-
- # Grabs image of avatar
- image_bytes = await ctx.author.avatar_url_as(size=256).read()
-
- old = Image.open(BytesIO(image_bytes))
- old = old.convert("RGBA")
-
- # Grabs alpha channel since posterize can't be used with an RGBA image.
- alpha = old.getchannel("A").getdata()
- old = old.convert("RGB")
- old = posterize(old, 6)
-
- data = old.getdata()
- setted_data = set(data)
- new_d = {}
-
- for x in setted_data:
- new_d[x] = self.closest(x)
- await asyncio.sleep(0) # Ensures discord doesn't break in the background.
- new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)]
-
- im = Image.new("RGBA", old.size)
- im.putdata(new_data)
-
- if colours:
- send_message = ctx.send
- ctx.send = send # Assigns ctx.send to a fake send
- egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours)
- if isinstance(egg, str): # When an error message occurs in eggdecorate.
- return await send_message(egg)
-
- ratio = 64 / egg.height
- egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio)))
- egg = egg.convert("RGBA")
- im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre.
- ctx.send = send_message # Reassigns ctx.send
- else:
- bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png"))
- im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre.
-
- bufferedio = BytesIO()
- im.save(bufferedio, format="PNG")
-
- bufferedio.seek(0)
-
- file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed
- embed = discord.Embed(
- name="Your Lovely Easterified Avatar",
- description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D"
- )
- embed.set_image(url="attachment://easterified_avatar.png")
- embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
-
- await ctx.send(file=file, embed=embed)
-
-
-def setup(bot: commands.Bot) -> None:
- """Avatar Easterifier Cog load."""
- bot.add_cog(AvatarEasterifier(bot))
diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
index 3c612eb1..a93b3745 100644
--- a/bot/exts/easter/easter_riddle.py
+++ b/bot/exts/easter/easter_riddle.py
@@ -7,7 +7,7 @@ from pathlib import Path
import discord
from discord.ext import commands
-from bot.constants import Colours
+from bot.constants import Colours, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -36,6 +36,17 @@ class EasterRiddle(commands.Cog):
if self.current_channel:
return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+ # Don't let users start in a DM
+ if not ctx.guild:
+ await ctx.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="You can't start riddles in DMs",
+ colour=discord.Colour.red()
+ )
+ )
+ return
+
self.current_channel = ctx.message.channel
random_question = random.choice(RIDDLE_QUESTIONS)
diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py
index b18e6636..a847388d 100644
--- a/bot/exts/easter/egg_decorating.py
+++ b/bot/exts/easter/egg_decorating.py
@@ -10,6 +10,8 @@ import discord
from PIL import Image
from discord.ext import commands
+from bot.utils import helpers
+
log = logging.getLogger(__name__)
with open(Path("bot/resources/evergreen/html_colours.json"), encoding="utf8") as f:
@@ -65,7 +67,7 @@ class EggDecorating(commands.Cog):
if value:
colours[idx] = discord.Colour(value)
else:
- invalid.append(colour)
+ invalid.append(helpers.suppress_links(colour))
if len(invalid) > 1:
return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}")
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
deleted file mode 100644
index 54e68f80..00000000
--- a/bot/exts/evergreen/8bitify.py
+++ /dev/null
@@ -1,54 +0,0 @@
-from io import BytesIO
-
-import discord
-from PIL import Image
-from discord.ext import commands
-
-
-class EightBitify(commands.Cog):
- """Make your avatar 8bit!"""
-
- def __init__(self, bot: commands.Bot) -> None:
- self.bot = bot
-
- @staticmethod
- def pixelate(image: Image) -> Image:
- """Takes an image and pixelates it."""
- return image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST)
-
- @staticmethod
- def quantize(image: Image) -> Image:
- """Reduces colour palette to 256 colours."""
- return image.quantize()
-
- @commands.command(name="8bitify")
- async def eightbit_command(self, ctx: commands.Context) -> None:
- """Pixelates your avatar and changes the palette to an 8bit one."""
- async with ctx.typing():
- image_bytes = await ctx.author.avatar_url.read()
- avatar = Image.open(BytesIO(image_bytes))
- avatar = avatar.convert("RGBA").resize((1024, 1024))
-
- eightbit = self.pixelate(avatar)
- eightbit = self.quantize(eightbit)
-
- bufferedio = BytesIO()
- eightbit.save(bufferedio, format="PNG")
- bufferedio.seek(0)
-
- file = discord.File(bufferedio, filename="8bitavatar.png")
-
- embed = discord.Embed(
- title="Your 8-bit avatar",
- description='Here is your avatar. I think it looks all cool and "retro"'
- )
-
- embed.set_image(url="attachment://8bitavatar.png")
- embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
-
- await ctx.send(file=file, embed=embed)
-
-
-def setup(bot: commands.Bot) -> None:
- """Cog load."""
- bot.add_cog(EightBitify(bot))
diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/__init__.py
diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py
new file mode 100644
index 00000000..d2370b4b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/_effects.py
@@ -0,0 +1,287 @@
+import math
+import random
+import typing as t
+from io import BytesIO
+from pathlib import Path
+
+import discord
+from PIL import Image, ImageDraw, ImageOps
+
+from bot.constants import Colours
+
+
+class PfpEffects:
+ """
+ Implements various image modifying effects, for the PfpModify cog.
+
+ All of these fuctions are slow, and blocking, so they should be ran in executors.
+ """
+
+ @staticmethod
+ def apply_effect(image_bytes: bytes, effect: t.Callable, filename: str, *args) -> discord.File:
+ """Applies the given effect to the image passed to it."""
+ im = Image.open(BytesIO(image_bytes))
+ im = im.convert("RGBA")
+ im = effect(im, *args)
+
+ bufferedio = BytesIO()
+ im.save(bufferedio, format="PNG")
+ bufferedio.seek(0)
+
+ return discord.File(bufferedio, filename=filename)
+
+ @staticmethod
+ def closest(x: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]:
+ """
+ Finds the closest "easter" colour to a given pixel.
+
+ Returns a merge between the original colour and the closest colour.
+ """
+ r1, g1, b1 = x
+
+ def distance(point: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]:
+ """Finds the difference between a pastel colour and the original pixel colour."""
+ r2, g2, b2 = point
+ return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2
+
+ closest_colours = sorted(Colours.easter_like_colours, key=distance)
+ r2, g2, b2 = closest_colours[0]
+ r = (r1 + r2) // 2
+ g = (g1 + g2) // 2
+ b = (b1 + b2) // 2
+
+ return r, g, b
+
+ @staticmethod
+ def crop_avatar_circle(avatar: Image.Image) -> Image.Image:
+ """This crops the avatar given into a circle."""
+ mask = Image.new("L", avatar.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + avatar.size, fill=255)
+ avatar.putalpha(mask)
+ return avatar
+
+ @staticmethod
+ def crop_ring(ring: Image.Image, px: int) -> Image.Image:
+ """This crops the given ring into a circle."""
+ mask = Image.new("L", ring.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + ring.size, fill=255)
+ draw.ellipse((px, px, 1024-px, 1024-px), fill=0)
+ ring.putalpha(mask)
+ return ring
+
+ @staticmethod
+ def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image:
+ """Applies the given pride effect to the given image."""
+ image = image.resize((1024, 1024))
+ image = PfpEffects.crop_avatar_circle(image)
+
+ ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024))
+ ring = ring.convert("RGBA")
+ ring = PfpEffects.crop_ring(ring, pixels)
+
+ image.alpha_composite(ring, (0, 0))
+ return image
+
+ @staticmethod
+ def eight_bitify_effect(image: Image.Image) -> Image.Image:
+ """
+ Applies the 8bit effect to the given image.
+
+ This is done by reducing the image to 32x32 and then back up to 1024x1024.
+ We then quantize the image before returning too.
+ """
+ image = image.resize((32, 32), resample=Image.NEAREST)
+ image = image.resize((1024, 1024), resample=Image.NEAREST)
+ return image.quantize()
+
+ @staticmethod
+ def easterify_effect(image: Image.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.Image:
+ """
+ Applies the easter effect to the given image.
+
+ This is done by getting the closest "easter" colour to each pixel and changing the colour
+ to the half-way RGBvalue.
+
+ We also then add an overlay image on top in middle right, a chocolate bunny by default.
+ """
+ if overlay_image:
+ ratio = 64 / overlay_image.height
+ overlay_image = overlay_image.resize((
+ round(overlay_image.width * ratio),
+ round(overlay_image.height * ratio)
+ ))
+ overlay_image = overlay_image.convert("RGBA")
+ else:
+ overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png"))
+
+ alpha = image.getchannel("A").getdata()
+ image = image.convert("RGB")
+ image = ImageOps.posterize(image, 6)
+
+ data = image.getdata()
+ data_set = set(data)
+ easterified_data_set = {}
+
+ for x in data_set:
+ easterified_data_set[x] = PfpEffects.closest(x)
+ new_pixel_data = [
+ (*easterified_data_set[x], alpha[i])
+ if x in easterified_data_set else x
+ for i, x in enumerate(data)
+ ]
+
+ im = Image.new("RGBA", image.size)
+ im.putdata(new_pixel_data)
+ im.alpha_composite(
+ overlay_image,
+ (im.width - overlay_image.width, (im.height - overlay_image.height) // 2)
+ )
+ return im
+
+ @staticmethod
+ def split_image(img: Image.Image, squares: int) -> list:
+ """
+ Split an image into a selection of squares, specified by the squares argument.
+
+ Explanation:
+
+ 1. It gets the width and the height of the Image passed to the function.
+
+ 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say
+ 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed
+ to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it
+ as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows
+ and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the
+ program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed.
+
+ 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares,
+ the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares)
+ passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note:
+ x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width
+ in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width.
+ x_frac and y_frac are width and height of a single square (split piece).
+
+ 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial
+ square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding
+ value to right and bottom, it's creating the initial square (split piece).
+
+ 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is
+ True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between
+ them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list
+ where it stores them. The program keeps repeating this process till all 25 squares get added to the list.
+
+ 6. It returns new_imgs, a list of squares (split pieces).
+ """
+ width, heigth = img.size
+
+ xy = math.sqrt(squares)
+
+ x_frac = width // xy
+ y_frac = heigth // xy
+
+ left, top, right, bottom, = 0, 0, x_frac, y_frac
+
+ new_imgs = []
+
+ for index in range(squares):
+ new_img = img.crop((left, top, right, bottom))
+ new_imgs.append(new_img)
+
+ if (index + 1) % xy == 0:
+ top += y_frac
+ bottom += y_frac
+ left = 0
+ right = x_frac
+ else:
+ left += x_frac
+ right += x_frac
+
+ return new_imgs
+
+ @staticmethod
+ def join_images(images: t.List[Image.Image]) -> Image.Image:
+ """
+ Stitches all the image squares into a new image.
+
+ Explanation:
+
+ 1. Shuffles the passed images to randomize the pieces.
+
+ 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width
+ and single_height as the square's height.
+
+ 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier.
+ Program then proceeds to calculate total height and width of the new image that it's creating using the same
+ multiplier.
+
+ 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width
+ and total_height.
+
+ 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position
+ squares (split pieces) onto the new_image canvas.
+
+ 6. Similar to how in the split_image function, the program gets the root of number of images in the list.
+ In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the
+ list that it got the square of here.
+
+ 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly
+ position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece)
+ onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get
+ pasted in the same spot and the positioning would move accordingly. It makes sure to increase the
+ width_multiplier before the check, which checks if the end of a row has been reached, -
+ (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of
+ the row). If the check returns True, the height gets increased by a single square's (split piece) height to
+ lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will
+ then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were
+ positioned accordingly.
+
+ 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the
+ original image - user's PFP.
+ """
+ random.shuffle(images)
+ single_img = images[0]
+
+ single_wdith = single_img.size[0]
+ single_height = single_img.size[1]
+
+ multiplier = int(math.sqrt(len(images)))
+
+ total_width = multiplier * single_wdith
+ total_height = multiplier * single_height
+
+ new_image = Image.new('RGBA', (total_width, total_height), (250, 250, 250))
+
+ width_multiplier = 0
+ height = 0
+
+ squares = math.sqrt(len(images))
+
+ for index, image in enumerate(images):
+ width = single_wdith * width_multiplier
+
+ new_image.paste(image, (width, height))
+
+ width_multiplier += 1
+
+ if (index + 1) % squares == 0:
+ width_multiplier = 0
+ height += single_height
+
+ return new_image
+
+ @staticmethod
+ def mosaic_effect(img_bytes: bytes, squares: int, file_name: str) -> discord.File:
+ """Seperate function run from an executor which turns an image into a mosaic."""
+ avatar = Image.open(BytesIO(img_bytes))
+ avatar = avatar.convert('RGBA').resize((1024, 1024))
+
+ img_squares = PfpEffects.split_image(avatar, squares)
+ new_img = PfpEffects.join_images(img_squares)
+
+ bufferedio = BytesIO()
+ new_img.save(bufferedio, format='PNG')
+ bufferedio.seek(0)
+
+ return discord.File(bufferedio, filename=file_name)
diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py
new file mode 100644
index 00000000..693d15c7
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py
@@ -0,0 +1,370 @@
+import asyncio
+import json
+import logging
+import math
+import string
+import typing as t
+import unicodedata
+from concurrent.futures import ThreadPoolExecutor
+
+import discord
+from aiohttp import client_exceptions
+from discord.ext import commands
+from discord.ext.commands.errors import BadArgument
+
+from bot.constants import Colours, Emojis
+from bot.exts.evergreen.avatar_modification._effects import PfpEffects
+from bot.utils.extensions import invoke_help_command
+from bot.utils.halloween import spookifications
+
+log = logging.getLogger(__name__)
+
+_EXECUTOR = ThreadPoolExecutor(10)
+
+FILENAME_STRING = "{effect}_{author}.png"
+
+MAX_SQUARES = 10_000
+
+T = t.TypeVar("T")
+
+with open("bot/resources/pride/gender_options.json") as f:
+ GENDER_OPTIONS = json.load(f)
+
+
+async def in_executor(func: t.Callable[..., T], *args) -> T:
+ """
+ Runs the given synchronus function `func` in an executor.
+
+ This is useful for running slow, blocking code within async
+ functions, so that they don't block the bot.
+ """
+ log.trace(f"Running {func.__name__} in an executor.")
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(_EXECUTOR, func, *args)
+
+
+def file_safe_name(effect: str, display_name: str) -> str:
+ """Returns a file safe filename based on the given effect and display name."""
+ valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}"
+
+ file_name = FILENAME_STRING.format(effect=effect, author=display_name)
+
+ # Replace spaces
+ file_name = file_name.replace(" ", "_")
+
+ # Normalize unicode characters
+ cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode()
+
+ # Remove invalid filename characters
+ cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars)
+ return cleaned_filename
+
+
+class AvatarModify(commands.Cog):
+ """Various commands for users to apply affects to their own avatars."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ async def _fetch_user(self, user_id: int) -> t.Optional[discord.User]:
+ """
+ Fetches a user and handles errors.
+
+ This helper function is required as the member cache doesn't always have the most up to date
+ profile picture. This can lead to errors if the image is delted from the Discord CDN.
+ fetch_member can't be used due to the avatar url being part of the user object, and
+ some weird caching that D.py does
+ """
+ try:
+ user = await self.bot.fetch_user(user_id)
+ except discord.errors.NotFound:
+ log.debug(f"User {user_id} could not be found.")
+ return None
+ except discord.HTTPException:
+ log.exception(f"Exception while trying to retrieve user {user_id} from Discord.")
+ return None
+
+ return user
+
+ @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod"))
+ async def avatar_modify(self, ctx: commands.Context) -> None:
+ """Groups all of the pfp modifying commands to allow a single concurrency limit."""
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @avatar_modify.command(name="8bitify", root_aliases=("8bitify",))
+ async def eightbit_command(self, ctx: commands.Context) -> None:
+ """Pixelates your avatar and changes the palette to an 8bit one."""
+ async with ctx.typing():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ image_bytes = await user.avatar_url_as(size=1024).read()
+ file_name = file_safe_name("eightbit_avatar", ctx.author.display_name)
+
+ file = await in_executor(
+ PfpEffects.apply_effect,
+ image_bytes,
+ PfpEffects.eight_bitify_effect,
+ file_name
+ )
+
+ embed = discord.Embed(
+ title="Your 8-bit avatar",
+ description="Here is your avatar. I think it looks all cool and 'retro'."
+ )
+
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url)
+
+ await ctx.send(embed=embed, file=file)
+
+ @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify"))
+ async def avatareasterify(self, ctx: commands.Context, *colours: t.Union[discord.Colour, str]) -> None:
+ """
+ This "Easterifies" the user's avatar.
+
+ Given colours will produce a personalised egg in the corner, similar to the egg_decorate command.
+ If colours are not given, a nice little chocolate bunny will sit in the corner.
+ Colours are split by spaces, unless you wrap the colour name in double quotes.
+ Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
+ """
+ async def send(*args, **kwargs) -> str:
+ """
+ This replaces the original ctx.send.
+
+ When invoking the egg decorating command, the egg itself doesn't print to to the channel.
+ Returns the message content so that if any errors occur, the error message can be output.
+ """
+ if args:
+ return args[0]
+
+ async with ctx.typing():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ egg = None
+ if colours:
+ send_message = ctx.send
+ ctx.send = send # Assigns ctx.send to a fake send
+ egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours)
+ if isinstance(egg, str): # When an error message occurs in eggdecorate.
+ await send_message(egg)
+ return
+ ctx.send = send_message # Reassigns ctx.send
+
+ image_bytes = await user.avatar_url_as(size=256).read()
+ file_name = file_safe_name("easterified_avatar", ctx.author.display_name)
+
+ file = await in_executor(
+ PfpEffects.apply_effect,
+ image_bytes,
+ PfpEffects.easterify_effect,
+ file_name,
+ egg
+ )
+
+ embed = discord.Embed(
+ name="Your Lovely Easterified Avatar!",
+ description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D"
+ )
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+ @staticmethod
+ async def send_pride_image(
+ ctx: commands.Context,
+ image_bytes: bytes,
+ pixels: int,
+ flag: str,
+ option: str
+ ) -> None:
+ """Gets and sends the image in an embed. Used by the pride commands."""
+ async with ctx.typing():
+ file_name = file_safe_name("pride_avatar", ctx.author.display_name)
+
+ file = await in_executor(
+ PfpEffects.apply_effect,
+ image_bytes,
+ PfpEffects.pridify_effect,
+ file_name,
+ pixels,
+ flag
+ )
+
+ embed = discord.Embed(
+ name="Your Lovely Pride Avatar!",
+ description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D"
+ )
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url)
+ await ctx.send(file=file, embed=embed)
+
+ @avatar_modify.group(
+ aliases=("avatarpride", "pridepfp", "prideprofile"),
+ root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"),
+ invoke_without_command=True
+ )
+ async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None:
+ """
+ This surrounds an avatar with a border of a specified LGBT flag.
+
+ This defaults to the LGBT rainbow flag if none is given.
+ The amount of pixels can be given which determines the thickness of the flag border.
+ This has a maximum of 512px and defaults to a 64px border.
+ The full image is 1024x1024.
+ """
+ option = option.lower()
+ pixels = max(0, min(512, pixels))
+ flag = GENDER_OPTIONS.get(option)
+ if flag is None:
+ await ctx.send("I don't have that flag!")
+ return
+
+ async with ctx.typing():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+ image_bytes = await user.avatar_url_as(size=1024).read()
+ await self.send_pride_image(ctx, image_bytes, pixels, flag, option)
+
+ @prideavatar.command()
+ async def image(self, ctx: commands.Context, url: str, option: str = "lgbt", pixels: int = 64) -> None:
+ """
+ This surrounds the image specified by the URL with a border of a specified LGBT flag.
+
+ This defaults to the LGBT rainbow flag if none is given.
+ The amount of pixels can be given which determines the thickness of the flag border.
+ This has a maximum of 512px and defaults to a 64px border.
+ The full image is 1024x1024.
+ """
+ option = option.lower()
+ pixels = max(0, min(512, pixels))
+ flag = GENDER_OPTIONS.get(option)
+ if flag is None:
+ await ctx.send("I don't have that flag!")
+ return
+
+ async with ctx.typing():
+ try:
+ async with self.bot.http_session.get(url) as response:
+ if response.status != 200:
+ await ctx.send("Bad response from provided URL!")
+ return
+ image_bytes = await response.read()
+ except client_exceptions.ClientConnectorError:
+ raise BadArgument("Cannot connect to provided URL!")
+ except client_exceptions.InvalidURL:
+ raise BadArgument("Invalid URL!")
+
+ await self.send_pride_image(ctx, image_bytes, pixels, flag, option)
+
+ @prideavatar.command()
+ async def flags(self, ctx: commands.Context) -> None:
+ """This lists the flags that can be used with the prideavatar command."""
+ choices = sorted(set(GENDER_OPTIONS.values()))
+ options = "• " + "\n• ".join(choices)
+ embed = discord.Embed(
+ title="I have the following flags:",
+ description=options,
+ colour=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+
+ @avatar_modify.command(
+ aliases=("savatar", "spookify"),
+ root_aliases=("spookyavatar", "spookify", "savatar"),
+ brief="Spookify an user's avatar."
+ )
+ async def spookyavatar(self, ctx: commands.Context, member: discord.Member = None) -> None:
+ """This "spookifies" the given user's avatar, with a random *spooky* effect."""
+ if member is None:
+ member = ctx.author
+
+ user = await self._fetch_user(member.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ async with ctx.typing():
+ image_bytes = await user.avatar_url_as(size=1024).read()
+
+ file_name = file_safe_name("spooky_avatar", member.display_name)
+
+ file = await in_executor(
+ PfpEffects.apply_effect,
+ image_bytes,
+ spookifications.get_random_effect,
+ file_name
+ )
+
+ embed = discord.Embed(
+ title="Is this you or am I just really paranoid?",
+ colour=Colours.soft_red
+ )
+ embed.set_author(name=member.name, icon_url=member.avatar_url)
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+ @avatar_modify.command(name="mosaic", root_aliases=("mosaic",))
+ async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None:
+ """Splits your avatar into x squares, randomizes them and stitches them back into a new image!"""
+ async with ctx.typing():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ if not 1 <= squares <= MAX_SQUARES:
+ raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.")
+
+ sqrt = math.sqrt(squares)
+
+ if not sqrt.is_integer():
+ squares = math.ceil(sqrt) ** 2 # Get the next perfect square
+
+ file_name = file_safe_name("mosaic_avatar", ctx.author.display_name)
+
+ img_bytes = await user.avatar_url_as(size=1024).read()
+
+ file = await in_executor(
+ PfpEffects.mosaic_effect,
+ img_bytes,
+ squares,
+ file_name
+ )
+
+ if squares == 1:
+ title = "Hooh... that was a lot of work"
+ description = "I present to you... Yourself!"
+ elif squares == MAX_SQUARES:
+ title = "Testing the limits I see..."
+ description = "What a masterpiece. :star:"
+ else:
+ title = "Your mosaic avatar"
+ description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares."
+
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ colour=Colours.blue
+ )
+
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the AvatarModify cog."""
+ bot.add_cog(AvatarModify(bot))
diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
index fa3fb35c..1681434f 100644
--- a/bot/exts/evergreen/battleship.py
+++ b/bot/exts/evergreen/battleship.py
@@ -227,7 +227,7 @@ class Game:
if message.content.lower() == "surrender":
self.surrender = True
return True
- self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
+ self.match = re.fullmatch("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
if not self.match:
self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI))
return bool(self.match)
diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py
new file mode 100644
index 00000000..a175602f
--- /dev/null
+++ b/bot/exts/evergreen/catify.py
@@ -0,0 +1,88 @@
+import random
+from contextlib import suppress
+from typing import Optional
+
+from discord import AllowedMentions, Embed, Forbidden
+from discord.ext import commands
+
+from bot.constants import Cats, Colours, NEGATIVE_REPLIES
+from bot.utils import helpers
+
+
+class Catify(commands.Cog):
+ """Cog for the catify command."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=["ᓚᘏᗢify", "ᓚᘏᗢ"])
+ @commands.cooldown(1, 5, commands.BucketType.user)
+ async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None:
+ """
+ Convert the provided text into a cat themed sentence by interspercing cats throughout text.
+
+ If no text is given then the users nickname is edited.
+ """
+ if not text:
+ display_name = ctx.author.display_name
+
+ if len(display_name) > 26:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "Your display name is too long to be catified! "
+ "Please change it to be under 26 characters."
+ ),
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ else:
+ display_name += f" | {random.choice(Cats.cats)}"
+
+ await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none())
+
+ with suppress(Forbidden):
+ await ctx.author.edit(nick=display_name)
+ else:
+ if len(text) >= 1500:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="Submitted text was too large! Please submit something under 1500 characters.",
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ string_list = text.split()
+ for index, name in enumerate(string_list):
+ name = name.lower()
+ if "cat" in name:
+ if random.randint(0, 5) == 5:
+ string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list[index] = name.replace("cat", random.choice(Cats.cats))
+ for element in Cats.cats:
+ if element in name:
+ string_list[index] = name.replace(element, "cat")
+
+ string_len = len(string_list) // 3 or len(string_list)
+
+ for _ in range(random.randint(1, string_len)):
+ # insert cat at random index
+ if random.randint(0, 5) == 5:
+ string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats))
+
+ text = helpers.suppress_links(" ".join(string_list))
+ await ctx.send(
+ f">>> {text}",
+ allowed_mentions=AllowedMentions.none()
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Loads the catify cog."""
+ bot.add_cog(Catify(bot))
diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
index 28902503..8db49748 100644
--- a/bot/exts/evergreen/error_handler.py
+++ b/bot/exts/evergreen/error_handler.py
@@ -46,6 +46,11 @@ class CommandErrorHandler(commands.Cog):
logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.")
return
+ parent_command = ""
+ if subctx := getattr(ctx, "subcontext", None):
+ parent_command = f"{ctx.command} "
+ ctx = subctx
+
error = getattr(error, 'original', error)
logging.debug(
f"Error Encountered: {type(error).__name__} - {str(error)}, "
@@ -63,8 +68,9 @@ class CommandErrorHandler(commands.Cog):
if isinstance(error, commands.UserInputError):
self.revert_cooldown_counter(ctx.command, ctx.message)
+ usage = f"```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
embed = self.error_embed(
- f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"Your input was invalid: {error}\n\nUsage:{usage}"
)
await ctx.send(embed=embed)
return
@@ -95,7 +101,7 @@ class CommandErrorHandler(commands.Cog):
self.revert_cooldown_counter(ctx.command, ctx.message)
embed = self.error_embed(
"The argument you provided was invalid: "
- f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"{error}\n\nUsage:\n```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
)
await ctx.send(embed=embed)
return
diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py
index 101725da..7152d0cb 100644
--- a/bot/exts/evergreen/fun.py
+++ b/bot/exts/evergreen/fun.py
@@ -11,6 +11,7 @@ from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverte
from bot import utils
from bot.constants import Client, Colours, Emojis
+from bot.utils import helpers
log = logging.getLogger(__name__)
@@ -83,6 +84,7 @@ class Fun(Cog):
if embed is not None:
embed = Fun._convert_embed(conversion_func, embed)
converted_text = conversion_func(text)
+ converted_text = helpers.suppress_links(converted_text)
# Don't put >>> if only embed present
if converted_text:
converted_text = f">>> {converted_text.lstrip('> ')}"
@@ -101,6 +103,7 @@ class Fun(Cog):
if embed is not None:
embed = Fun._convert_embed(conversion_func, embed)
converted_text = conversion_func(text)
+ converted_text = helpers.suppress_links(converted_text)
# Don't put >>> if only embed present
if converted_text:
converted_text = f">>> {converted_text.lstrip('> ')}"
diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py
index 2e38e3ab..c8a6b3f7 100644
--- a/bot/exts/evergreen/githubinfo.py
+++ b/bot/exts/evergreen/githubinfo.py
@@ -1,16 +1,19 @@
import logging
import random
from datetime import datetime
-from typing import Optional
+from urllib.parse import quote
import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import NEGATIVE_REPLIES
+from bot.constants import Colours, NEGATIVE_REPLIES
+from bot.exts.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
+GITHUB_API_URL = "https://api.github.com"
+
class GithubInfo(commands.Cog):
"""Fetches info from GitHub."""
@@ -23,27 +26,28 @@ class GithubInfo(commands.Cog):
async with self.bot.http_session.get(url) as r:
return await r.json()
- @commands.command(name='github', aliases=['gh'])
- @commands.cooldown(1, 60, BucketType.user)
- async def get_github_info(self, ctx: commands.Context, username: Optional[str]) -> None:
- """
- Fetches a user's GitHub information.
-
- Username is optional and sends the help command if not specified.
- """
- if username is None:
- await ctx.invoke(self.bot.get_command('help'), 'github')
- ctx.command.reset_cooldown(ctx)
- return
+ @commands.group(name='github', aliases=('gh', 'git'))
+ @commands.cooldown(1, 10, BucketType.user)
+ async def github_group(self, ctx: commands.Context) -> None:
+ """Commands for finding information related to GitHub."""
+ if ctx.invoked_subcommand is None:
+ await invoke_help_command(ctx)
+ @github_group.command(name='user', aliases=('userinfo',))
+ async def github_user_info(self, ctx: commands.Context, username: str) -> None:
+ """Fetches a user's GitHub information."""
async with ctx.typing():
- user_data = await self.fetch_data(f"https://api.github.com/users/{username}")
+ user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{username}")
# User_data will not have a message key if the user exists
- if user_data.get('message') is not None:
- await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES),
- description=f"The profile for `{username}` was not found.",
- colour=discord.Colour.red()))
+ if "message" in user_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=f"The profile for `{username}` was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
return
org_data = await self.fetch_data(user_data['organizations_url'])
@@ -63,7 +67,7 @@ class GithubInfo(commands.Cog):
embed = discord.Embed(
title=f"`{user_data['login']}`'s GitHub profile info",
description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "",
- colour=0x7289da,
+ colour=discord.Colour.blurple(),
url=user_data['html_url'],
timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ")
)
@@ -72,26 +76,99 @@ class GithubInfo(commands.Cog):
if user_data['type'] == "User":
- embed.add_field(name="Followers",
- value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)")
- embed.add_field(name="\u200b", value="\u200b")
- embed.add_field(name="Following",
- value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)")
-
- embed.add_field(name="Public repos",
- value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name="Followers",
+ value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)"
+ )
+ embed.add_field(
+ name="Following",
+ value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)"
+ )
+
+ embed.add_field(
+ name="Public repos",
+ value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)"
+ )
if user_data['type'] == "User":
- embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{username})")
+ embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{quote(username, safe='')})")
- embed.add_field(name=f"Organization{'s' if len(orgs)!=1 else ''}",
- value=orgs_to_add if orgs else "No organizations")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name=f"Organization{'s' if len(orgs)!=1 else ''}",
+ value=orgs_to_add if orgs else "No organizations"
+ )
embed.add_field(name="Website", value=blog)
await ctx.send(embed=embed)
+ @github_group.command(name='repository', aliases=('repo',))
+ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None:
+ """
+ Fetches a repositories' GitHub information.
+
+ The repository should look like `user/reponame` or `user reponame`.
+ """
+ repo = '/'.join(repo)
+ if repo.count('/') != 1:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The repository should look like `user/reponame` or `user reponame`.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ async with ctx.typing():
+ repo_data = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}")
+
+ # There won't be a message key if this repo exists
+ if "message" in repo_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The requested repository was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ embed = discord.Embed(
+ title=repo_data['name'],
+ description=repo_data["description"],
+ colour=discord.Colour.blurple(),
+ url=repo_data['html_url']
+ )
+
+ # If it's a fork, then it will have a parent key
+ try:
+ parent = repo_data["parent"]
+ embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})"
+ except KeyError:
+ log.debug("Repository is not a fork.")
+
+ repo_owner = repo_data['owner']
+
+ embed.set_author(
+ name=repo_owner["login"],
+ url=repo_owner["html_url"],
+ icon_url=repo_owner["avatar_url"]
+ )
+
+ repo_created_at = datetime.strptime(repo_data['created_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y")
+ last_pushed = datetime.strptime(repo_data['pushed_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M")
+
+ embed.set_footer(
+ text=(
+ f"{repo_data['forks_count']} ⑂ "
+ f"• {repo_data['stargazers_count']} ⭐ "
+ f"• Created At {repo_created_at} "
+ f"• Last Commit {last_pushed}"
+ )
+ )
+
+ await ctx.send(embed=embed)
+
def setup(bot: commands.Bot) -> None:
"""Adding the cog to the bot."""
diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py
index 91147243..f557e42e 100644
--- a/bot/exts/evergreen/help.py
+++ b/bot/exts/evergreen/help.py
@@ -289,7 +289,9 @@ class HelpSession:
parent = self.query.full_parent_name + ' ' if self.query.parent else ''
paginator.add_line(f'**```{prefix}{parent}{signature}```**')
- aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
+ aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in self.query.aliases]
+ aliases += [f"`{alias}`" for alias in getattr(self.query, "root_aliases", ())]
+ aliases = ", ".join(sorted(aliases))
if aliases:
paginator.add_line(f'**Can also use:** {aliases}\n')
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index bbcbf611..a0316080 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -2,12 +2,23 @@ import logging
import random
import re
import typing as t
-from enum import Enum
+from dataclasses import dataclass
import discord
-from discord.ext import commands, tasks
-
-from bot.constants import Categories, Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
+from discord.ext import commands
+
+from bot.constants import (
+ Categories,
+ Channels,
+ Colours,
+ ERROR_REPLIES,
+ Emojis,
+ NEGATIVE_REPLIES,
+ Tokens,
+ WHITELISTED_CHANNELS
+)
+from bot.utils.decorators import whitelist_override
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -15,20 +26,20 @@ BAD_RESPONSE = {
404: "Issue/pull request not located! Please enter a valid number!",
403: "Rate limit has been hit! Please try again later!"
}
+REQUEST_HEADERS = {
+ "Accept": "application/vnd.github.v3+json"
+}
-MAX_REQUESTS = 10
-REQUEST_HEADERS = dict()
+REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public"
+ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}"
+PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}"
-REPOS_API = "https://api.github.com/orgs/{org}/repos"
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
WHITELISTED_CATEGORIES = (
Categories.development, Categories.devprojects, Categories.media, Categories.staff
)
-WHITELISTED_CHANNELS_ON_MESSAGE = (
- Channels.organisation, Channels.mod_meta, Channels.mod_tools, Channels.staff_voice
-)
CODE_BLOCK_RE = re.compile(
r"^`([^`\n]+)`" # Inline codeblock
@@ -36,12 +47,45 @@ CODE_BLOCK_RE = re.compile(
re.DOTALL | re.MULTILINE
)
+# Maximum number of issues in one message
+MAXIMUM_ISSUES = 5
+
+# Regex used when looking for automatic linking in messages
+# regex101 of current regex https://regex101.com/r/V2ji8M/6
+AUTOMATIC_REGEX = re.compile(
+ r"((?P<org>[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P<repo>[\w\-\.]{1,100})#(?P<number>[0-9]+)"
+)
+
+
+@dataclass
+class FoundIssue:
+ """Dataclass representing an issue found by the regex."""
+
+ organisation: t.Optional[str]
+ repository: str
+ number: str
+
+ def __hash__(self) -> int:
+ return hash((self.organisation, self.repository, self.number))
+
+
+@dataclass
+class FetchError:
+ """Dataclass representing an error while fetching an issue."""
+
+ return_code: int
+ message: str
-class FetchIssueErrors(Enum):
- """Errors returned in fetch issues."""
- value_error = "Numbers not found."
- max_requests = "Max requests hit."
+@dataclass
+class IssueState:
+ """Dataclass representing the state of an issue."""
+
+ repository: str
+ number: int
+ url: str
+ title: str
+ emoji: str
class Issues(commands.Cog):
@@ -50,97 +94,96 @@ class Issues(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.repos = []
- self.get_pydis_repos.start()
-
- @tasks.loop(minutes=30)
- async def get_pydis_repos(self) -> None:
- """Get all python-discord repositories on github."""
- async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp:
- if resp.status == 200:
- data = await resp.json()
- for repo in data:
- self.repos.append(repo["full_name"].split("/")[1])
- self.repo_regex = "|".join(self.repos)
- else:
- log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}")
@staticmethod
- def check_in_block(message: discord.Message, repo_issue: str) -> bool:
- """Check whether the <repo>#<issue> is in codeblocks."""
- block = re.findall(CODE_BLOCK_RE, message.content)
-
- if not block:
- return False
- elif "#".join(repo_issue.split(" ")) in "".join([*block[0]]):
- return True
- return False
+ def remove_codeblocks(message: str) -> str:
+ """Remove any codeblock in a message."""
+ return re.sub(CODE_BLOCK_RE, "", message)
async def fetch_issues(
self,
- numbers: set,
+ number: int,
repository: str,
user: str
- ) -> t.Union[FetchIssueErrors, str, list]:
- """Retrieve issue(s) from a GitHub repository."""
- links = []
- if not numbers:
- return FetchIssueErrors.value_error
-
- if len(numbers) > MAX_REQUESTS:
- return FetchIssueErrors.max_requests
-
- for number in numbers:
- url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
- merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
- log.trace(f"Querying GH issues API: {url}")
- async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
- json_data = await r.json()
-
- if r.status in BAD_RESPONSE:
- log.warning(f"Received response {r.status} from: {url}")
- return f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}"
-
- # The initial API request is made to the issues API endpoint, which will return information
- # if the issue or PR is present. However, the scope of information returned for PRs differs
- # from issues: if the 'issues' key is present in the response then we can pull the data we
- # need from the initial API call.
- if "issues" in json_data.get("html_url"):
- if json_data.get("state") == "open":
- icon_url = Emojis.issue
- else:
- icon_url = Emojis.issue_closed
-
- # If the 'issues' key is not contained in the API response and there is no error code, then
- # we know that a PR has been requested and a call to the pulls API endpoint is necessary
- # to get the desired information for the PR.
+ ) -> t.Union[IssueState, FetchError]:
+ """
+ Retrieve an issue from a GitHub repository.
+
+ Returns IssueState on success, FetchError on failure.
+ """
+ url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number)
+ pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number)
+ log.trace(f"Querying GH issues API: {url}")
+
+ async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
+ json_data = await r.json()
+
+ if r.status == 403:
+ if r.headers.get("X-RateLimit-Remaining") == "0":
+ log.info(f"Ratelimit reached while fetching {url}")
+ return FetchError(403, "Ratelimit reached, please retry in a few minutes.")
+ return FetchError(403, "Cannot access issue.")
+ elif r.status in (404, 410):
+ return FetchError(r.status, "Issue not found.")
+ elif r.status != 200:
+ return FetchError(r.status, "Error while fetching issue.")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data["html_url"]:
+ if json_data.get("state") == "open":
+ emoji = Emojis.issue
else:
- log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
- async with self.bot.http_session.get(merge_url) as m:
- if json_data.get("state") == "open":
- icon_url = Emojis.pull_request
- # When the status is 204 this means that the state of the PR is merged
- elif m.status == 204:
- icon_url = Emojis.merge
- else:
- icon_url = Emojis.pull_request_closed
+ emoji = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {pulls_url}")
+ async with self.bot.http_session.get(pulls_url) as p:
+ pull_data = await p.json()
+ if pull_data["draft"]:
+ emoji = Emojis.pull_request_draft
+ elif pull_data["state"] == "open":
+ emoji = Emojis.pull_request
+ # When 'merged_at' is not None, this means that the state of the PR is merged
+ elif pull_data["merged_at"] is not None:
+ emoji = Emojis.merge
+ else:
+ emoji = Emojis.pull_request_closed
- issue_url = json_data.get("html_url")
- links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
+ issue_url = json_data.get("html_url")
- return links
+ return IssueState(repository, number, issue_url, json_data.get('title', ''), emoji)
@staticmethod
- def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed:
- """Get Response Embed."""
- description_list = ["{0} [{1}]({2})".format(*link) for link in result]
+ def format_embed(
+ results: t.List[t.Union[IssueState, FetchError]],
+ user: str,
+ repository: t.Optional[str] = None
+ ) -> discord.Embed:
+ """Take a list of IssueState or FetchError and format a Discord embed for them."""
+ description_list = []
+
+ for result in results:
+ if isinstance(result, IssueState):
+ description_list.append(f"{result.emoji} [{result.title}]({result.url})")
+ elif isinstance(result, FetchError):
+ description_list.append(f":x: [{result.return_code}] {result.message}")
+
resp = discord.Embed(
colour=Colours.bright_green,
description='\n'.join(description_list)
)
- resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
+ embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}"
+ resp.set_author(name="GitHub", url=embed_url)
return resp
+ @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(aliases=("pr",))
async def issue(
self,
@@ -150,56 +193,79 @@ class Issues(commands.Cog):
user: str = "python-discord"
) -> None:
"""Command to retrieve issue(s) from a GitHub repository."""
- if not(
- ctx.channel.category.id in WHITELISTED_CATEGORIES
- or ctx.channel.id in WHITELISTED_CHANNELS
- ):
- return
-
- result = await self.fetch_issues(set(numbers), repository, user)
+ # Remove duplicates
+ numbers = set(numbers)
- if result == FetchIssueErrors.value_error:
- await ctx.invoke(self.bot.get_command('help'), 'issue')
-
- elif result == FetchIssueErrors.max_requests:
+ if len(numbers) > MAXIMUM_ISSUES:
embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
- description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
)
await ctx.send(embed=embed)
+ await invoke_help_command(ctx)
- elif isinstance(result, list):
- # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
- resp = self.get_embed(result, user, repository)
- await ctx.send(embed=resp)
-
- elif isinstance(result, str):
- await ctx.send(result)
+ results = [await self.fetch_issues(number, repository, user) for number in numbers]
+ await ctx.send(embed=self.format_embed(results, user, repository))
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
- """Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>."""
- if not(
- message.channel.category.id in WHITELISTED_CATEGORIES
- or message.channel.id in WHITELISTED_CHANNELS_ON_MESSAGE
- ):
+ """
+ Automatic issue linking.
+
+ Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching <org>/<repo>#<issue>.
+ """
+ # Ignore bots
+ if message.author.bot:
return
- message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content)
+ issues = [
+ FoundIssue(*match.group("org", "repo", "number"))
+ for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content))
+ ]
links = []
- if message_repo_issue_map:
- for repo_issue in message_repo_issue_map:
- if not self.check_in_block(message, " ".join(repo_issue)):
- result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord")
- if isinstance(result, list):
- links.extend(result)
+ if issues:
+ # Block this from working in DMs
+ if not message.guild:
+ await message.channel.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "You can't retrieve issues from DMs. "
+ f"Try again in <#{Channels.community_bot_commands}>"
+ ),
+ colour=Colours.soft_red
+ )
+ )
+ return
+
+ log.trace(f"Found {issues = }")
+ # Remove duplicates
+ issues = set(issues)
+
+ if len(issues) > MAXIMUM_ISSUES:
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
+ )
+ await message.channel.send(embed=embed, delete_after=5)
+ return
+
+ for repo_issue in issues:
+ result = await self.fetch_issues(
+ int(repo_issue.number),
+ repo_issue.repository,
+ repo_issue.organisation or "python-discord"
+ )
+ if isinstance(result, IssueState):
+ links.append(result)
if not links:
return
- resp = self.get_embed(links, "python-discord")
+ resp = self.format_embed(links, "python-discord")
await message.channel.send(embed=resp)
diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py
new file mode 100644
index 00000000..c4a8597c
--- /dev/null
+++ b/bot/exts/evergreen/latex.py
@@ -0,0 +1,94 @@
+import asyncio
+import hashlib
+import pathlib
+import re
+from concurrent.futures import ThreadPoolExecutor
+from io import BytesIO
+
+import discord
+import matplotlib.pyplot as plt
+from discord.ext import commands
+
+# configure fonts and colors for matplotlib
+plt.rcParams.update(
+ {
+ "font.size": 16,
+ "mathtext.fontset": "cm", # Computer Modern font set
+ "mathtext.rm": "serif",
+ "figure.facecolor": "36393F", # matches Discord's dark mode background color
+ "text.color": "white",
+ }
+)
+
+FORMATTED_CODE_REGEX = re.compile(
+ r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
+ r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
+ r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
+ r"(?P<code>.*?)" # extract all code inside the markup
+ r"\s*" # any more whitespace before the end of the code markup
+ r"(?P=delim)", # match the exact same delimiter from the start again
+ re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive
+)
+
+CACHE_DIRECTORY = pathlib.Path("_latex_cache")
+CACHE_DIRECTORY.mkdir(exist_ok=True)
+
+
+class Latex(commands.Cog):
+ """Renders latex."""
+
+ @staticmethod
+ def _render(text: str, filepath: pathlib.Path) -> BytesIO:
+ """
+ Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception.
+
+ Saves rendered image to cache.
+ """
+ fig = plt.figure()
+ rendered_image = BytesIO()
+ fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top")
+
+ try:
+ plt.savefig(rendered_image, bbox_inches="tight", dpi=600)
+ except ValueError as e:
+ raise commands.BadArgument(str(e))
+
+ rendered_image.seek(0)
+
+ with open(filepath, "wb") as f:
+ f.write(rendered_image.getbuffer())
+
+ return rendered_image
+
+ @staticmethod
+ def _prepare_input(text: str) -> str:
+ text = text.replace(r"\\", "$\n$") # matplotlib uses \n for newlines, not \\
+
+ if match := FORMATTED_CODE_REGEX.match(text):
+ return match.group("code")
+ else:
+ return text
+
+ @commands.command()
+ @commands.max_concurrency(1, commands.BucketType.guild, wait=True)
+ async def latex(self, ctx: commands.Context, *, text: str) -> None:
+ """Renders the text in latex and sends the image."""
+ text = self._prepare_input(text)
+ query_hash = hashlib.md5(text.encode()).hexdigest()
+ image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png")
+ async with ctx.typing():
+ if image_path.exists():
+ await ctx.send(file=discord.File(image_path))
+ return
+
+ with ThreadPoolExecutor() as pool:
+ image = await asyncio.get_running_loop().run_in_executor(
+ pool, self._render, text, image_path
+ )
+
+ await ctx.send(file=discord.File(image, "latex.png"))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Latex Cog."""
+ bot.add_cog(Latex(bot))
diff --git a/bot/exts/evergreen/ping.py b/bot/exts/evergreen/ping.py
new file mode 100644
index 00000000..07c13524
--- /dev/null
+++ b/bot/exts/evergreen/ping.py
@@ -0,0 +1,44 @@
+import arrow
+from dateutil.relativedelta import relativedelta
+from discord import Embed
+from discord.ext import commands
+
+from bot import start_time
+from bot.constants import Colours
+
+
+class Ping(commands.Cog):
+ """Get info about the bot's ping and uptime."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="ping")
+ async def ping(self, ctx: commands.Context) -> None:
+ """Ping the bot to see its latency and state."""
+ embed = Embed(
+ title=":ping_pong: Pong!",
+ colour=Colours.bright_green,
+ description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms",
+ )
+
+ await ctx.send(embed=embed)
+
+ # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002
+ @commands.command(name="uptime")
+ async def uptime(self, ctx: commands.Context) -> None:
+ """Get the current uptime of the bot."""
+ difference = relativedelta(start_time - arrow.utcnow())
+ uptime_string = start_time.shift(
+ seconds=-difference.seconds,
+ minutes=-difference.minutes,
+ hours=-difference.hours,
+ days=-difference.days
+ ).humanize()
+
+ await ctx.send(f"I started up {uptime_string}.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Ping cog."""
+ bot.add_cog(Ping(bot))
diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
index 49127bea..e57fa2c0 100644
--- a/bot/exts/evergreen/reddit.py
+++ b/bot/exts/evergreen/reddit.py
@@ -1,128 +1,367 @@
+import asyncio
import logging
import random
+import textwrap
+from collections import namedtuple
+from datetime import datetime, timedelta
+from typing import List, Union
-import discord
-from discord.ext import commands
-from discord.ext.commands.cooldowns import BucketType
+from aiohttp import BasicAuth, ClientError
+from discord import Colour, Embed, TextChannel
+from discord.ext.commands import Cog, Context, group, has_any_role
+from discord.ext.tasks import loop
+from discord.utils import escape_markdown, sleep_until
-from bot.utils.pagination import ImagePaginator
+from bot.bot import Bot
+from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES
+from bot.utils.converters import Subreddit
+from bot.utils.extensions import invoke_help_command
+from bot.utils.messages import sub_clyde
+from bot.utils.pagination import ImagePaginator, LinePaginator
log = logging.getLogger(__name__)
+AccessToken = namedtuple("AccessToken", ["token", "expires_at"])
-class Reddit(commands.Cog):
- """Fetches reddit posts."""
- def __init__(self, bot: commands.Bot):
+class Reddit(Cog):
+ """Track subreddit posts and show detailed statistics about them."""
+
+ HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"}
+ URL = "https://www.reddit.com"
+ OAUTH_URL = "https://oauth.reddit.com"
+ MAX_RETRIES = 3
+
+ def __init__(self, bot: Bot):
self.bot = bot
- async def fetch(self, url: str) -> dict:
- """Send a get request to the reddit API and get json response."""
- session = self.bot.http_session
- params = {
- 'limit': 50
- }
- headers = {
- 'User-Agent': 'Iceman'
- }
-
- async with session.get(url=url, params=params, headers=headers) as response:
- return await response.json()
-
- @commands.command(name='reddit')
- @commands.cooldown(1, 10, BucketType.user)
- async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None:
- """
- Fetch reddit posts by using this command.
+ self.webhook = None
+ self.access_token = None
+ self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret)
- Gets a post from r/python by default.
- Usage:
- --> .reddit [subreddit_name] [hot/top/new]
- """
+ bot.loop.create_task(self.init_reddit_ready())
+ self.auto_poster_loop.start()
+
+ def cog_unload(self) -> None:
+ """Stop the loop task and revoke the access token when the cog is unloaded."""
+ self.auto_poster_loop.cancel()
+ if self.access_token and self.access_token.expires_at > datetime.utcnow():
+ asyncio.create_task(self.revoke_access_token())
+
+ async def init_reddit_ready(self) -> None:
+ """Sets the reddit webhook when the cog is loaded."""
+ await self.bot.wait_until_guild_available()
+ if not self.webhook:
+ self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook)
+
+ @property
+ def channel(self) -> TextChannel:
+ """Get the #reddit channel object from the bot's cache."""
+ return self.bot.get_channel(Channels.reddit)
+
+ def build_pagination_pages(self, posts: List[dict], paginate: bool) -> Union[List[tuple], str]:
+ """Build embed pages required for Paginator."""
pages = []
- sort_list = ["hot", "new", "top", "rising"]
- if sort.lower() not in sort_list:
- await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`")
- sort = "hot"
+ first_page = ""
+ for post in posts:
+ post_page = ""
+ image_url = ""
- data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json')
+ data = post["data"]
- try:
- posts = data["data"]["children"]
- except KeyError:
- return await ctx.send('Subreddit not found!')
- if not posts:
- return await ctx.send('No posts available!')
+ title = textwrap.shorten(data["title"], width=50, placeholder="...")
+
+ # Normal brackets interfere with Markdown.
+ title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌")
+ link = self.URL + data["permalink"]
+
+ first_page += f"**[{title.replace('*', '')}]({link})**\n"
+
+ text = data["selftext"]
+ if text:
+ first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n"
+
+ ups = data["ups"]
+ comments = data["num_comments"]
+ author = data["author"]
+
+ content_type = Emojis.reddit_post_text
+ if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))):
+ # This means the content type in the post is a video.
+ content_type = f"{Emojis.reddit_post_video}"
+
+ elif data["url"].endswith(("jpg", "png", "gif")):
+ # This means the content type in the post is an image.
+ content_type = f"{Emojis.reddit_post_photo}"
+ image_url = data["url"]
- if posts[1]["data"]["over_18"] is True:
- return await ctx.send(
- "You cannot access this Subreddit as it is ment for those who "
- "are 18 years or older."
+ first_page += (
+ f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}"
+ f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n"
)
- embed_titles = ""
+ if paginate:
+ post_page += f"**[{title}]({link})**\n\n"
+ if text:
+ post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n"
+ post_page += (
+ f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002"
+ f"{comments}\u2003{Emojis.reddit_users}{author}"
+ )
- # Chooses k unique random elements from a population sequence or set.
- random_posts = random.sample(posts, k=5)
+ pages.append((post_page, image_url))
- # -----------------------------------------------------------
- # This code below is bound of change when the emojis are added.
+ if not paginate:
+ # Return the first summery page if pagination is not required
+ return first_page
- upvote_emoji = self.bot.get_emoji(755845219890757644)
- comment_emoji = self.bot.get_emoji(755845255001014384)
- user_emoji = self.bot.get_emoji(755845303822974997)
- text_emoji = self.bot.get_emoji(676030265910493204)
- video_emoji = self.bot.get_emoji(676030265839190047)
- image_emoji = self.bot.get_emoji(676030265734201344)
- reddit_emoji = self.bot.get_emoji(676030265734332427)
+ pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string
+ return pages
- # ------------------------------------------------------------
+ async def get_access_token(self) -> None:
+ """
+ Get a Reddit API OAuth2 access token and assign it to self.access_token.
- for i, post in enumerate(random_posts, start=1):
- post_title = post["data"]["title"][0:50]
- post_url = post['data']['url']
- if post_title == "":
- post_title = "No Title."
- elif post_title == post_url:
- post_title = "Title is itself a link."
+ A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog
+ will be unloaded and a ClientError raised if retrieval was still unsuccessful.
+ """
+ for i in range(1, self.MAX_RETRIES + 1):
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/access_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "grant_type": "client_credentials",
+ "duration": "temporary"
+ }
+ )
- # ------------------------------------------------------------------
- # Embed building.
+ if response.status == 200 and response.content_type == "application/json":
+ content = await response.json()
+ expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway.
+ self.access_token = AccessToken(
+ token=content["access_token"],
+ expires_at=datetime.utcnow() + timedelta(seconds=expiration)
+ )
- embed_titles += f"**{i}.[{post_title}]({post_url})**\n"
- image_url = " "
- post_stats = f"{text_emoji}" # Set default content type to text.
+ log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}")
+ return
+ else:
+ log.debug(
+ f"Failed to get an access token: "
+ f"status {response.status} & content type {response.content_type}; "
+ f"retrying ({i}/{self.MAX_RETRIES})"
+ )
- if post["data"]["is_video"] is True or "youtube" in post_url.split("."):
- # This means the content type in the post is a video.
- post_stats = f"{video_emoji} "
+ await asyncio.sleep(3)
- elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"):
- # This means the content type in the post is an image.
- post_stats = f"{image_emoji} "
- image_url = post_url
-
- votes = f'{upvote_emoji}{post["data"]["ups"]}'
- comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}'
- post_stats += (
- f"\u2002{votes}\u2003"
- f"{comments}"
- f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n'
+ self.bot.remove_cog(self.qualified_name)
+ raise ClientError("Authentication with the Reddit API failed. Unloading the cog.")
+
+ async def revoke_access_token(self) -> None:
+ """
+ Revoke the OAuth2 access token for the Reddit API.
+
+ For security reasons, it's good practice to revoke the token when it's no longer being used.
+ """
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/revoke_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "token": self.access_token.token,
+ "token_type_hint": "access_token"
+ }
+ )
+
+ if response.status in [200, 204] and response.content_type == "application/json":
+ self.access_token = None
+ else:
+ log.warning(f"Unable to revoke access token: status {response.status}.")
+
+ async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]:
+ """A helper method to fetch a certain amount of Reddit posts at a given route."""
+ # Reddit's JSON responses only provide 25 posts at most.
+ if not 25 >= amount > 0:
+ raise ValueError("Invalid amount of subreddit posts requested.")
+
+ # Renew the token if necessary.
+ if not self.access_token or self.access_token.expires_at < datetime.utcnow():
+ await self.get_access_token()
+
+ url = f"{self.OAUTH_URL}/{route}"
+ for _ in range(self.MAX_RETRIES):
+ response = await self.bot.http_session.get(
+ url=url,
+ headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"},
+ params=params
+ )
+ if response.status == 200 and response.content_type == 'application/json':
+ # Got appropriate response - process and return.
+ content = await response.json()
+ posts = content["data"]["children"]
+
+ filtered_posts = [post for post in posts if not post["data"]["over_18"]]
+
+ return filtered_posts[:amount]
+
+ await asyncio.sleep(3)
+
+ log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}")
+ return list() # Failed to get appropriate response within allowed number of retries.
+
+ async def get_top_posts(
+ self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False
+ ) -> Union[Embed, List[tuple]]:
+ """
+ Get the top amount of posts for a given subreddit within a specified timeframe.
+
+ A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top
+ weekly posts.
+
+ The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most.
+ """
+ embed = Embed()
+
+ posts = await self.fetch_posts(
+ route=f"{subreddit}/top",
+ amount=amount,
+ params={"t": time}
+ )
+ if not posts:
+ embed.title = random.choice(ERROR_REPLIES)
+ embed.colour = Colour.red()
+ embed.description = (
+ "Sorry! We couldn't find any SFW posts from that subreddit. "
+ "If this problem persists, please let us know."
)
- embed_titles += f"{post_stats}\n"
- page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}"
- embed = discord.Embed()
- page_tuple = (page_text, image_url)
- pages.append(page_tuple)
+ return embed
+
+ if paginate:
+ return self.build_pagination_pages(posts, paginate=True)
+
+ # Use only starting summary page for #reddit channel posts.
+ embed.description = self.build_pagination_pages(posts, paginate=False)
+ embed.colour = Colour.blurple()
+ return embed
+
+ @loop()
+ async def auto_poster_loop(self) -> None:
+ """Post the top 5 posts daily, and the top 5 posts weekly."""
+ # once d.py get support for `time` parameter in loop decorator,
+ # this can be removed and the loop can use the `time=datetime.time.min` parameter
+ now = datetime.utcnow()
+ tomorrow = now + timedelta(days=1)
+ midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0)
+
+ await sleep_until(midnight_tomorrow)
+
+ await self.bot.wait_until_guild_available()
+ if not self.webhook:
+ await self.bot.fetch_webhook(RedditConfig.webhook)
+
+ if datetime.utcnow().weekday() == 0:
+ await self.top_weekly_posts()
+ # if it's a monday send the top weekly posts
+
+ for subreddit in RedditConfig.subreddits:
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
+ username = sub_clyde(f"{subreddit} Top Daily Posts")
+ message = await self.webhook.send(username=username, embed=top_posts, wait=True)
+
+ if message.channel.is_news():
+ await message.publish()
+
+ async def top_weekly_posts(self) -> None:
+ """Post a summary of the top posts."""
+ for subreddit in RedditConfig.subreddits:
+ # Send and pin the new weekly posts.
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="week")
+ username = sub_clyde(f"{subreddit} Top Weekly Posts")
+ message = await self.webhook.send(wait=True, username=username, embed=top_posts)
- # ------------------------------------------------------------------
+ if subreddit.lower() == "r/python":
+ if not self.channel:
+ log.warning("Failed to get #reddit channel to remove pins in the weekly loop.")
+ return
+
+ # Remove the oldest pins so that only 12 remain at most.
+ pins = await self.channel.pins()
+
+ while len(pins) >= 12:
+ await pins[-1].unpin()
+ del pins[-1]
+
+ await message.pin()
+
+ if message.channel.is_news():
+ await message.publish()
+
+ @group(name="reddit", invoke_without_command=True)
+ async def reddit_group(self, ctx: Context) -> None:
+ """View the top posts from various subreddits."""
+ await invoke_help_command(ctx)
+
+ @reddit_group.command(name="top")
+ async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of all time from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True)
+
+ await ctx.send(f"Here are the top {subreddit} posts of all time!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
- pages.insert(0, (embed_titles, " "))
- embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url)
await ImagePaginator.paginate(pages, ctx, embed)
+ @reddit_group.command(name="daily")
+ async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of today from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True)
+
+ await ctx.send(f"Here are today's top {subreddit} posts!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @reddit_group.command(name="weekly")
+ async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of this week from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True)
+
+ await ctx.send(f"Here are this week's top {subreddit} posts!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @has_any_role(*STAFF_ROLES)
+ @reddit_group.command(name="subreddits", aliases=("subs",))
+ async def subreddits_command(self, ctx: Context) -> None:
+ """Send a paginated embed of all the subreddits we're relaying."""
+ embed = Embed()
+ embed.title = "Relayed subreddits."
+ embed.colour = Colour.blurple()
+
+ await LinePaginator.paginate(
+ RedditConfig.subreddits,
+ ctx, embed,
+ footer_text="Use the reddit commands along with these to view their posts.",
+ empty=False,
+ max_lines=15
+ )
+
-def setup(bot: commands.Bot) -> None:
- """Load the Cog."""
+def setup(bot: Bot) -> None:
+ """Load the Reddit cog."""
+ if not RedditConfig.secret or not RedditConfig.client_id:
+ log.error("Credentials not provided, cog not loaded.")
+ return
bot.add_cog(Reddit(bot))
diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py
new file mode 100644
index 00000000..5f177fd6
--- /dev/null
+++ b/bot/exts/evergreen/timed.py
@@ -0,0 +1,46 @@
+from copy import copy
+from time import perf_counter
+
+from discord import Message
+from discord.ext import commands
+
+
+class TimedCommands(commands.Cog):
+ """Time the command execution of a command."""
+
+ @staticmethod
+ async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context:
+ """Get a new execution context for a command."""
+ msg: Message = copy(ctx.message)
+ msg.content = f"{ctx.prefix}{command}"
+
+ return await ctx.bot.get_context(msg)
+
+ @commands.command(name="timed", aliases=["time", "t"])
+ async def timed(self, ctx: commands.Context, *, command: str) -> None:
+ """Time the command execution of a command."""
+ new_ctx = await self.create_execution_context(ctx, command)
+
+ ctx.subcontext = new_ctx
+
+ if not ctx.subcontext.command:
+ help_command = f"{ctx.prefix}help"
+ error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands."
+
+ await ctx.send(error)
+ return
+
+ if new_ctx.command.qualified_name == "timed":
+ await ctx.send("You are not allowed to time the execution of the `timed` command.")
+ return
+
+ t_start = perf_counter()
+ await new_ctx.command.invoke(new_ctx)
+ t_end = perf_counter()
+
+ await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(TimedCommands(bot))
diff --git a/bot/exts/evergreen/uptime.py b/bot/exts/evergreen/uptime.py
deleted file mode 100644
index a9ad9dfb..00000000
--- a/bot/exts/evergreen/uptime.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import logging
-
-import arrow
-from dateutil.relativedelta import relativedelta
-from discord.ext import commands
-
-from bot import start_time
-
-log = logging.getLogger(__name__)
-
-
-class Uptime(commands.Cog):
- """A cog for posting the bot's uptime."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command(name="uptime")
- async def uptime(self, ctx: commands.Context) -> None:
- """Responds with the uptime of the bot."""
- difference = relativedelta(start_time - arrow.utcnow())
- uptime_string = start_time.shift(
- seconds=-difference.seconds,
- minutes=-difference.minutes,
- hours=-difference.hours,
- days=-difference.days
- ).humanize()
- await ctx.send(f"I started up {uptime_string}.")
-
-
-def setup(bot: commands.Bot) -> None:
- """Uptime Cog load."""
- bot.add_cog(Uptime(bot))
diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py
index 437d9e1a..14ec1041 100644
--- a/bot/exts/evergreen/wolfram.py
+++ b/bot/exts/evergreen/wolfram.py
@@ -62,7 +62,8 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
# if the invoked command is help we don't want to increase the ratelimits since it's not actually
# invoking the command/making a request, so instead just check if the user/guild are on cooldown.
guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
+ # check the message is in a guild, and check user bucket if user is not ignored
+ if ctx.guild and not any(r.id in ignore for r in ctx.author.roles):
return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
return guild_cooldown
diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
index 0cb37ecd..40e21f40 100644
--- a/bot/exts/halloween/candy_collection.py
+++ b/bot/exts/halloween/candy_collection.py
@@ -47,6 +47,9 @@ class CandyCollection(commands.Cog):
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
+ # Ignore messages in DMs
+ if not message.guild:
+ return
# make sure its a human message
if message.author.bot:
return
diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py
deleted file mode 100644
index 2d7df678..00000000
--- a/bot/exts/halloween/spookyavatar.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import logging
-import os
-from io import BytesIO
-
-import aiohttp
-import discord
-from PIL import Image
-from discord.ext import commands
-
-from bot.utils.halloween import spookifications
-
-log = logging.getLogger(__name__)
-
-
-class SpookyAvatar(commands.Cog):
- """A cog that spookifies an avatar."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- async def get(self, url: str) -> bytes:
- """Returns the contents of the supplied URL."""
- async with aiohttp.ClientSession() as session:
- async with session.get(url) as resp:
- return await resp.read()
-
- @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'),
- brief='Spookify an user\'s avatar.')
- async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None:
- """A command to print the user's spookified avatar."""
- if user is None:
- user = ctx.message.author
-
- async with ctx.typing():
- embed = discord.Embed(colour=0xFF0000)
- embed.title = "Is this you or am I just really paranoid?"
- embed.set_author(name=str(user.name), icon_url=user.avatar_url)
-
- image_bytes = await ctx.author.avatar_url.read()
- im = Image.open(BytesIO(image_bytes))
- modified_im = spookifications.get_random_effect(im)
- modified_im.save(str(ctx.message.id)+'.png')
- f = discord.File(str(ctx.message.id)+'.png')
- embed.set_image(url='attachment://'+str(ctx.message.id)+'.png')
-
- await ctx.send(file=f, embed=embed)
- os.remove(str(ctx.message.id)+'.png')
-
-
-def setup(bot: commands.Bot) -> None:
- """Spooky avatar Cog load."""
- bot.add_cog(SpookyAvatar(bot))
diff --git a/bot/exts/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py
new file mode 100644
index 00000000..695fa74d
--- /dev/null
+++ b/bot/exts/internal_eval/__init__.py
@@ -0,0 +1,10 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Set up the Internal Eval extension."""
+ # Import the Cog at runtime to prevent side effects like defining
+ # RedisCache instances too early.
+ from ._internal_eval import InternalEval
+
+ bot.add_cog(InternalEval(bot))
diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py
new file mode 100644
index 00000000..3a50b9f3
--- /dev/null
+++ b/bot/exts/internal_eval/_helpers.py
@@ -0,0 +1,249 @@
+import ast
+import collections
+import contextlib
+import functools
+import inspect
+import io
+import logging
+import sys
+import traceback
+import types
+import typing
+
+
+log = logging.getLogger(__name__)
+
+# A type alias to annotate the tuples returned from `sys.exc_info()`
+ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType]
+Namespace = typing.Dict[str, typing.Any]
+
+# This will be used as an coroutine function wrapper for the code
+# to be evaluated. The wrapper contains one `pass` statement which
+# will be replaced with `ast` with the code that we want to have
+# evaluated.
+# The function redirects output and captures exceptions that were
+# raised in the code we evaluate. The latter is used to provide a
+# meaningful traceback to the end user.
+EVAL_WRAPPER = """
+async def _eval_wrapper_function():
+ try:
+ with contextlib.redirect_stdout(_eval_context.stdout):
+ pass
+ if '_value_last_expression' in locals():
+ if inspect.isawaitable(_value_last_expression):
+ _value_last_expression = await _value_last_expression
+ _eval_context._value_last_expression = _value_last_expression
+ else:
+ _eval_context._value_last_expression = None
+ except Exception:
+ _eval_context.exc_info = sys.exc_info()
+ finally:
+ _eval_context.locals = locals()
+_eval_context.function = _eval_wrapper_function
+"""
+INTERNAL_EVAL_FRAMENAME = "<internal eval>"
+EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function"
+
+
+def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str:
+ """Format an exception caught while evaluation code by inserting lines."""
+ exc_type, exc_value, tb = exc_info
+ stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb))
+ code = code.split("\n")
+
+ output = ["Traceback (most recent call last):"]
+ for frame in stack_summary:
+ if frame.filename == INTERNAL_EVAL_FRAMENAME:
+ line = code[frame.lineno - 1].lstrip()
+
+ if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME:
+ name = INTERNAL_EVAL_FRAMENAME
+ else:
+ name = frame.name
+ else:
+ line = frame.line
+ name = frame.name
+
+ output.append(
+ f' File "{frame.filename}", line {frame.lineno}, in {name}\n'
+ f" {line}"
+ )
+
+ output.extend(traceback.format_exception_only(exc_type, exc_value))
+ return "\n".join(output)
+
+
+class EvalContext:
+ """
+ Represents the current `internal eval` context.
+
+ The context remembers names set during earlier runs of `internal eval`. To
+ clear the context, use the `.internal clear` command.
+ """
+
+ def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None:
+ self._locals = dict(local_vars)
+ self.context_vars = dict(context_vars)
+
+ self.stdout = io.StringIO()
+ self._value_last_expression = None
+ self.exc_info = None
+ self.code = ""
+ self.function = None
+ self.eval_tree = None
+
+ @property
+ def dependencies(self) -> typing.Dict[str, typing.Any]:
+ """
+ Return a mapping of the dependencies for the wrapper function.
+
+ By using a property descriptor, the mapping can't be accidentally
+ mutated during evaluation. This ensures the dependencies are always
+ available.
+ """
+ return {
+ "print": functools.partial(print, file=self.stdout),
+ "contextlib": contextlib,
+ "inspect": inspect,
+ "sys": sys,
+ "_eval_context": self,
+ "_": self._value_last_expression,
+ }
+
+ @property
+ def locals(self) -> typing.Dict[str, typing.Any]:
+ """Return a mapping of names->values needed for evaluation."""
+ return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)}
+
+ @locals.setter
+ def locals(self, locals_: typing.Dict[str, typing.Any]) -> None:
+ """Update the contextual mapping of names to values."""
+ log.trace(f"Updating {self._locals} with {locals_}")
+ self._locals.update(locals_)
+
+ def prepare_eval(self, code: str) -> typing.Optional[str]:
+ """Prepare an evaluation by processing the code and setting up the context."""
+ self.code = code
+
+ if not self.code:
+ log.debug("No code was attached to the evaluation command")
+ return "[No code detected]"
+
+ try:
+ code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME)
+ except SyntaxError:
+ log.debug("Got a SyntaxError while parsing the eval code")
+ return "".join(traceback.format_exception(*sys.exc_info(), limit=0))
+
+ log.trace("Parsing the AST to see if there's a trailing expression we need to capture")
+ code_tree = CaptureLastExpression(code_tree).capture()
+
+ log.trace("Wrapping the AST in the AST of the wrapper coroutine")
+ eval_tree = WrapEvalCodeTree(code_tree).wrap()
+
+ self.eval_tree = eval_tree
+ return None
+
+ async def run_eval(self) -> Namespace:
+ """Run the evaluation and return the updated locals."""
+ log.trace("Compiling the AST to bytecode using `exec` mode")
+ compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec")
+
+ log.trace("Executing the compiled code with the desired namespace environment")
+ exec(compiled_code, self.locals) # noqa: B102,S102
+
+ log.trace("Awaiting the created evaluation wrapper coroutine.")
+ await self.function()
+
+ log.trace("Returning the updated captured locals.")
+ return self._locals
+
+ def format_output(self) -> str:
+ """Format the output of the most recent evaluation."""
+ output = []
+
+ log.trace(f"Getting output from stdout `{id(self.stdout)}`")
+ stdout_text = self.stdout.getvalue()
+ if stdout_text:
+ log.trace("Appending output captured from stdout/print")
+ output.append(stdout_text)
+
+ if self._value_last_expression is not None:
+ log.trace("Appending the output of a captured trialing expression")
+ output.append(f"[Captured] {self._value_last_expression!r}")
+
+ if self.exc_info:
+ log.trace("Appending exception information")
+ output.append(format_internal_eval_exception(self.exc_info, self.code))
+
+ log.trace(f"Generated output: {output!r}")
+ return "\n".join(output) or "[No output]"
+
+
+class WrapEvalCodeTree(ast.NodeTransformer):
+ """Wraps the AST of eval code with the wrapper function."""
+
+ def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.eval_code_tree = eval_code_tree
+
+ # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping
+ self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME)
+
+ def wrap(self) -> ast.AST:
+ """Wrap the tree of the code by the tree of the wrapper function."""
+ new_tree = self.visit(self.wrapper)
+ return ast.fix_missing_locations(new_tree)
+
+ def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802
+ """
+ Replace the `_ast.Pass` node in the wrapper function by the eval AST.
+
+ This method works on the assumption that there's a single `pass`
+ statement in the wrapper function.
+ """
+ return list(ast.iter_child_nodes(self.eval_code_tree))
+
+
+class CaptureLastExpression(ast.NodeTransformer):
+ """Captures the return value from a loose expression."""
+
+ def __init__(self, tree: ast.AST, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.tree = tree
+ self.last_node = list(ast.iter_child_nodes(tree))[-1]
+
+ def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802
+ """
+ Replace the Expr node that is last child node of Module with an assignment.
+
+ We use an assignment to capture the value of the last node, if it's a loose
+ Expr node. Normally, the value of an Expr node is lost, meaning we don't get
+ the output of such a last "loose" expression. By assigning it a name, we can
+ retrieve it for our output.
+ """
+ if node is not self.last_node:
+ return node
+
+ log.trace("Found a trailing last expression in the evaluation code")
+
+ log.trace("Creating assignment statement with trailing expression as the right-hand side")
+ right_hand_side = list(ast.iter_child_nodes(node))[0]
+
+ assignment = ast.Assign(
+ targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())],
+ value=right_hand_side,
+ lineno=node.lineno,
+ col_offset=0,
+ )
+ ast.fix_missing_locations(assignment)
+ return assignment
+
+ def capture(self) -> ast.AST:
+ """Capture the value of the last expression with an assignment."""
+ if not isinstance(self.last_node, ast.Expr):
+ # We only have to replace a node if the very last node is an Expr node
+ return self.tree
+
+ new_tree = self.visit(self.tree)
+ return ast.fix_missing_locations(new_tree)
diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py
new file mode 100644
index 00000000..757a2a1e
--- /dev/null
+++ b/bot/exts/internal_eval/_internal_eval.py
@@ -0,0 +1,176 @@
+import logging
+import re
+import textwrap
+import typing
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Roles
+from bot.utils.decorators import with_role
+from bot.utils.extensions import invoke_help_command
+from ._helpers import EvalContext
+
+__all__ = ["InternalEval"]
+
+log = logging.getLogger(__name__)
+
+FORMATTED_CODE_REGEX = re.compile(
+ r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
+ r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
+ r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
+ r"(?P<code>.*?)" # extract all code inside the markup
+ r"\s*" # any more whitespace before the end of the code markup
+ r"(?P=delim)", # match the exact same delimiter from the start again
+ re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive
+)
+
+RAW_CODE_REGEX = re.compile(
+ r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
+ r"(?P<code>.*?)" # extract all the rest as code
+ r"\s*$", # any trailing whitespace until the end of the string
+ re.DOTALL # "." also matches newlines
+)
+
+
+class InternalEval(commands.Cog):
+ """Top secret code evaluation for admins and owners."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.locals = {}
+
+ @staticmethod
+ def shorten_output(
+ output: str,
+ max_length: int = 1900,
+ placeholder: str = "\n[output truncated]"
+ ) -> str:
+ """
+ Shorten the `output` so it's shorter than `max_length`.
+
+ There are three tactics for this, tried in the following order:
+ - Shorten the output on a line-by-line basis
+ - Shorten the output on any whitespace character
+ - Shorten the output solely on character count
+ """
+ max_length = max_length - len(placeholder)
+
+ shortened_output = []
+ char_count = 0
+ for line in output.split("\n"):
+ if char_count + len(line) > max_length:
+ break
+ shortened_output.append(line)
+ char_count += len(line) + 1 # account for (possible) line ending
+
+ if shortened_output:
+ shortened_output.append(placeholder)
+ return "\n".join(shortened_output)
+
+ shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder)
+
+ if shortened_output.strip() == placeholder.strip():
+ # `textwrap` was unable to find whitespace to shorten on, so it has
+ # reduced the output to just the placeholder. Let's shorten based on
+ # characters instead.
+ shortened_output = output[:max_length] + placeholder
+
+ return shortened_output
+
+ async def _upload_output(self, output: str) -> typing.Optional[str]:
+ """Upload `internal eval` output to our pastebin and return the url."""
+ try:
+ async with self.bot.http_session.post(
+ "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True
+ ) as resp:
+ data = await resp.json()
+
+ if "key" in data:
+ return f"https://paste.pythondiscord.com/{data['key']}"
+ except Exception:
+ # 400 (Bad Request) means there are too many characters
+ log.exception("Failed to upload `internal eval` output to paste service!")
+
+ async def _send_output(self, ctx: commands.Context, output: str) -> None:
+ """Send the `internal eval` output to the command invocation context."""
+ upload_message = ""
+ if len(output) >= 1980:
+ # The output is too long, let's truncate it for in-channel output and
+ # upload the complete output to the paste service.
+ url = await self._upload_output(output)
+
+ if url:
+ upload_message = f"\nFull output here: {url}"
+ else:
+ upload_message = "\n:warning: Failed to upload full output!"
+
+ output = self.shorten_output(output)
+
+ await ctx.send(f"```py\n{output}\n```{upload_message}")
+
+ async def _eval(self, ctx: commands.Context, code: str) -> None:
+ """Evaluate the `code` in the current evaluation context."""
+ context_vars = {
+ "message": ctx.message,
+ "author": ctx.message.author,
+ "channel": ctx.channel,
+ "guild": ctx.guild,
+ "ctx": ctx,
+ "self": self,
+ "bot": self.bot,
+ "discord": discord,
+ }
+
+ eval_context = EvalContext(context_vars, self.locals)
+
+ log.trace("Preparing the evaluation by parsing the AST of the code")
+ error = eval_context.prepare_eval(code)
+
+ if error:
+ log.trace("The code can't be evaluated due to an error")
+ await ctx.send(f"```py\n{error}\n```")
+ return
+
+ log.trace("Evaluate the AST we've generated for the evaluation")
+ new_locals = await eval_context.run_eval()
+
+ log.trace("Updating locals with those set during evaluation")
+ self.locals.update(new_locals)
+
+ log.trace("Sending the formatted output back to the context")
+ await self._send_output(ctx, eval_context.format_output())
+
+ @commands.group(name='internal', aliases=('int',))
+ @with_role(Roles.admin)
+ async def internal_group(self, ctx: commands.Context) -> None:
+ """Internal commands. Top secret!"""
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @internal_group.command(name='eval', aliases=('e',))
+ @with_role(Roles.admin)
+ async def eval(self, ctx: commands.Context, *, code: str) -> None:
+ """Run eval in a REPL-like format."""
+ if match := list(FORMATTED_CODE_REGEX.finditer(code)):
+ blocks = [block for block in match if block.group("block")]
+
+ if len(blocks) > 1:
+ code = '\n'.join(block.group("code") for block in blocks)
+ else:
+ match = match[0] if len(blocks) == 0 else blocks[0]
+ code, block, lang, delim = match.group("code", "block", "lang", "delim")
+
+ else:
+ code = RAW_CODE_REGEX.fullmatch(code).group("code")
+
+ code = textwrap.dedent(code)
+ await self._eval(ctx, code)
+
+ @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c"))
+ @with_role(Roles.admin)
+ async def reset(self, ctx: commands.Context) -> None:
+ """Reset the context and locals of the eval session."""
+ self.locals = {}
+ await ctx.send("The evaluation context was reset.")
diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py
deleted file mode 100644
index 2eade796..00000000
--- a/bot/exts/pride/pride_avatar.py
+++ /dev/null
@@ -1,177 +0,0 @@
-import logging
-from io import BytesIO
-from pathlib import Path
-from typing import Tuple
-
-import aiohttp
-import discord
-from PIL import Image, ImageDraw, UnidentifiedImageError
-from discord.ext.commands import Bot, Cog, Context, group
-
-from bot.constants import Colours
-
-log = logging.getLogger(__name__)
-
-OPTIONS = {
- "agender": "agender",
- "androgyne": "androgyne",
- "androgynous": "androgyne",
- "aromantic": "aromantic",
- "aro": "aromantic",
- "ace": "asexual",
- "asexual": "asexual",
- "bigender": "bigender",
- "bisexual": "bisexual",
- "bi": "bisexual",
- "demiboy": "demiboy",
- "demigirl": "demigirl",
- "demi": "demisexual",
- "demisexual": "demisexual",
- "gay": "gay",
- "lgbt": "gay",
- "queer": "gay",
- "homosexual": "gay",
- "fluid": "genderfluid",
- "genderfluid": "genderfluid",
- "genderqueer": "genderqueer",
- "intersex": "intersex",
- "lesbian": "lesbian",
- "non-binary": "nonbinary",
- "enby": "nonbinary",
- "nb": "nonbinary",
- "nonbinary": "nonbinary",
- "omnisexual": "omnisexual",
- "omni": "omnisexual",
- "pansexual": "pansexual",
- "pan": "pansexual",
- "pangender": "pangender",
- "poly": "polysexual",
- "polysexual": "polysexual",
- "polyamory": "polyamory",
- "polyamorous": "polyamory",
- "transgender": "transgender",
- "trans": "transgender",
- "trigender": "trigender"
-}
-
-
-class PrideAvatar(Cog):
- """Put an LGBT spin on your avatar!"""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @staticmethod
- def crop_avatar(avatar: Image) -> Image:
- """This crops the avatar into a circle."""
- mask = Image.new("L", avatar.size, 0)
- draw = ImageDraw.Draw(mask)
- draw.ellipse((0, 0) + avatar.size, fill=255)
- avatar.putalpha(mask)
- return avatar
-
- @staticmethod
- def crop_ring(ring: Image, px: int) -> Image:
- """This crops the ring into a circle."""
- mask = Image.new("L", ring.size, 0)
- draw = ImageDraw.Draw(mask)
- draw.ellipse((0, 0) + ring.size, fill=255)
- draw.ellipse((px, px, 1024-px, 1024-px), fill=0)
- ring.putalpha(mask)
- return ring
-
- @staticmethod
- def process_options(option: str, pixels: int) -> Tuple[str, int, str]:
- """Does some shared preprocessing for the prideavatar commands."""
- return option.lower(), max(0, min(512, pixels)), OPTIONS.get(option)
-
- async def process_image(self, ctx: Context, image_bytes: bytes, pixels: int, flag: str, option: str) -> None:
- """Constructs the final image, embeds it, and sends it."""
- try:
- avatar = Image.open(BytesIO(image_bytes))
- except UnidentifiedImageError:
- return await ctx.send("Cannot identify image from provided URL")
- avatar = avatar.convert("RGBA").resize((1024, 1024))
-
- avatar = self.crop_avatar(avatar)
-
- ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024))
- ring = ring.convert("RGBA")
- ring = self.crop_ring(ring, pixels)
-
- avatar.alpha_composite(ring, (0, 0))
- bufferedio = BytesIO()
- avatar.save(bufferedio, format="PNG")
- bufferedio.seek(0)
-
- file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed
- embed = discord.Embed(
- name="Your Lovely Pride Avatar",
- description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D"
- )
- embed.set_image(url="attachment://pride_avatar.png")
- embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
- await ctx.send(file=file, embed=embed)
-
- @group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True)
- async def prideavatar(self, ctx: Context, option: str = "lgbt", pixels: int = 64) -> None:
- """
- This surrounds an avatar with a border of a specified LGBT flag.
-
- This defaults to the LGBT rainbow flag if none is given.
- The amount of pixels can be given which determines the thickness of the flag border.
- This has a maximum of 512px and defaults to a 64px border.
- The full image is 1024x1024.
- """
- option, pixels, flag = self.process_options(option, pixels)
- if flag is None:
- return await ctx.send("I don't have that flag!")
-
- async with ctx.typing():
- image_bytes = await ctx.author.avatar_url.read()
- await self.process_image(ctx, image_bytes, pixels, flag, option)
-
- @prideavatar.command()
- async def image(self, ctx: Context, url: str, option: str = "lgbt", pixels: int = 64) -> None:
- """
- This surrounds the image specified by the URL with a border of a specified LGBT flag.
-
- This defaults to the LGBT rainbow flag if none is given.
- The amount of pixels can be given which determines the thickness of the flag border.
- This has a maximum of 512px and defaults to a 64px border.
- The full image is 1024x1024.
- """
- option, pixels, flag = self.process_options(option, pixels)
- if flag is None:
- return await ctx.send("I don't have that flag!")
-
- async with ctx.typing():
- async with aiohttp.ClientSession() as session:
- try:
- response = await session.get(url)
- except aiohttp.client_exceptions.ClientConnectorError:
- return await ctx.send("Cannot connect to provided URL!")
- except aiohttp.client_exceptions.InvalidURL:
- return await ctx.send("Invalid URL!")
- if response.status != 200:
- return await ctx.send("Bad response from provided URL!")
- image_bytes = await response.read()
- await self.process_image(ctx, image_bytes, pixels, flag, option)
-
- @prideavatar.command()
- async def flags(self, ctx: Context) -> None:
- """This lists the flags that can be used with the prideavatar command."""
- choices = sorted(set(OPTIONS.values()))
- options = "• " + "\n• ".join(choices)
- embed = discord.Embed(
- title="I have the following flags:",
- description=options,
- colour=Colours.soft_red
- )
-
- await ctx.send(embed=embed)
-
-
-def setup(bot: Bot) -> None:
- """Cog load."""
- bot.add_cog(PrideAvatar(bot))
diff --git a/bot/group.py b/bot/group.py
new file mode 100644
index 00000000..a7bc59b7
--- /dev/null
+++ b/bot/group.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Group(commands.Group):
+ """
+ A `discord.ext.commands.Group` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level groups rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a group must be a list or a tuple of strings.")
diff --git a/bot/resources/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json
index d8c1efa1..e1e8c70a 100644
--- a/bot/resources/easter/april_fools_vids.json
+++ b/bot/resources/easter/april_fools_vids.json
@@ -1,121 +1,130 @@
-{
- "google": [
- {
- "title": "Introducing Bad Joke Detector",
- "link": "https://youtu.be/OYcv406J_J4"
- },
- {
- "title": "Introducing Google Cloud Hummus API - Find your Hummus!",
- "link": "https://youtu.be/0_5X6N6DHyk"
- },
- {
- "title": "Introducing Google Play for Pets",
- "link": "https://youtu.be/UmJ2NBHXTqo"
- },
- {
- "title": "Haptic Helpers: bringing you to your senses",
- "link": "https://youtu.be/3MA6_21nka8"
- },
- {
- "title": "Introducing Google Wind",
- "link": "https://youtu.be/QAwL0O5nXe0"
- },
- {
- "title": "Experience YouTube in #SnoopaVision",
- "link": "https://youtu.be/DPEJB-FCItk"
- },
- {
- "title": "Introducing the self-driving bicycle in the Netherlands",
- "link": "https://youtu.be/LSZPNwZex9s"
- },
- {
- "title": "Android Developer Story: The Guardian goes galactic with Android and Google Play",
- "link": "https://youtu.be/dFrgNiweQDk"
- },
- {
- "title": "Introducing new delivery technology from Google Express",
- "link": "https://youtu.be/F0F6SnbqUcE"
- },
- {
- "title": "Google Cardboard Plastic",
- "link": "https://youtu.be/VkOuShXpoKc"
- },
- {
- "title": "Google Photos: Search your photos by emoji",
- "link": "https://youtu.be/HQtGFBbwKEk"
- },
- {
- "title": "Introducing Google Actual Cloud Platform",
- "link": "https://youtu.be/Cp10_PygJ4o"
- },
- {
- "title": "Introducing Dial-Up mode",
- "link": "https://youtu.be/XTTtkisylQw"
- },
- {
- "title": "Smartbox by Inbox: the mailbox of tomorrow, today",
- "link": "https://youtu.be/hydLZJXG3Tk"
- },
- {
- "title": "Introducing Coffee to the Home",
- "link": "https://youtu.be/U2JBFlW--UU"
- },
- {
- "title": "Chrome for Android and iOS: Emojify the Web",
- "link": "https://youtu.be/G3NXNnoGr3Y"
- },
- {
- "title": "Google Maps: Pokémon Challenge",
- "link": "https://youtu.be/4YMD6xELI_k"
- },
- {
- "title": "Introducing Google Fiber to the Pole",
- "link": "https://youtu.be/qcgWRpQP6ds"
- },
- {
- "title": "Introducing Gmail Blue",
- "link": "https://youtu.be/Zr4JwPb99qU"
- },
- {
- "title": "Introducing Google Nose",
- "link": "https://youtu.be/VFbYadm_mrw"
- },
- {
- "title": "Explore Treasure Mode with Google Maps",
- "link": "https://youtu.be/_qFFHC0eIUc"
- },
- {
- "title": "YouTube's ready to select a winner",
- "link": "https://youtu.be/H542nLTTbu0"
- },
- {
- "title": "A word about Gmail Tap",
- "link": "https://youtu.be/Je7Xq9tdCJc"
- },
- {
- "title": "Introducing the Google Fiber Bar",
- "link": "https://youtu.be/re0VRK6ouwI"
- },
- {
- "title": "Introducing Gmail Tap",
- "link": "https://youtu.be/1KhZKNZO8mQ"
- },
- {
- "title": "Chrome Multitask Mode",
- "link": "https://youtu.be/UiLSiqyDf4Y"
- },
- {
- "title": "Google Maps 8-bit for NES",
- "link": "https://youtu.be/rznYifPHxDg"
- },
- {
- "title": "Being a Google Autocompleter",
- "link": "https://youtu.be/blB_X38YSxQ"
- },
- {
- "title": "Introducing Gmail Motion",
- "link": "https://youtu.be/Bu927_ul_X0"
- }
- ]
-
-}
+[
+ {
+ "url": "https://youtu.be/OYcv406J_J4",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/0_5X6N6DHyk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/UmJ2NBHXTqo",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/3MA6_21nka8",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/QAwL0O5nXe0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/DPEJB-FCItk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/LSZPNwZex9s",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/dFrgNiweQDk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/F0F6SnbqUcE",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/VkOuShXpoKc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/HQtGFBbwKEk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Cp10_PygJ4o",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/XTTtkisylQw",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/hydLZJXG3Tk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/U2JBFlW--UU",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/G3NXNnoGr3Y",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/4YMD6xELI_k",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/qcgWRpQP6ds",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Zr4JwPb99qU",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/VFbYadm_mrw",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/_qFFHC0eIUc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/H542nLTTbu0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Je7Xq9tdCJc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/re0VRK6ouwI",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/1KhZKNZO8mQ",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/UiLSiqyDf4Y",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/rznYifPHxDg",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/blB_X38YSxQ",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Bu927_ul_X0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/smM-Wdk2RLQ",
+ "channel": "nvidia"
+ },
+ {
+ "url": "https://youtu.be/IlCx5gjAmqI",
+ "channel": "razer"
+ },
+ {
+ "url": "https://youtu.be/j8UJE7DoyJ8",
+ "channel": "razer"
+ }
+]
diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json
index e93f6dad..f7eb63d8 100644
--- a/bot/resources/easter/easter_riddle.json
+++ b/bot/resources/easter/easter_riddle.json
@@ -64,14 +64,6 @@
"correct_answer": "A chocolate one"
},
{
- "question": "Where does the Easter Bunny get his eggs?",
- "riddles": [
- "Not a bush or tree",
- "Emoji for a body part"
- ],
- "correct_answer": "Eggplants"
- },
- {
"question": "Why did the Easter Bunny have to fire the duck?",
"riddles": [
"Quack",
diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml
index f3b2eaa3..6b7e0206 100644
--- a/bot/resources/evergreen/py_topics.yaml
+++ b/bot/resources/evergreen/py_topics.yaml
@@ -69,7 +69,11 @@
# game-development
660625198390837248:
- -
+ - What is your favorite game mechanic?
+ - What is your favorite framework and why?
+ - What games do you know that were written in Python?
+ - What books or tutorials would you recommend for game-development beginners?
+ - What made you start developing games?
# microcontrollers
545603026732318730:
diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml
index 949220f9..6b0de0ef 100644
--- a/bot/resources/evergreen/starter.yaml
+++ b/bot/resources/evergreen/starter.yaml
@@ -6,7 +6,6 @@
- "What is better: Milk, Dark or White chocolate?"
- What is your favourite holiday?
- If you could have any superpower, what would it be?
-- Name one thing you like about a person to your right.
- If you could be anyone else for one day, who would it be?
- What Easter tradition do you enjoy most?
- What is the best gift you've been given?
@@ -31,3 +30,22 @@
- What is your favorite TV show?
- What is your favorite media genre?
- How many years have you spent coding?
+- What book do you highly recommend everyone to read?
+- What websites do you use daily to keep yourself up to date with the industry?
+- What made you want to join this Discord server?
+- How are you?
+- What is the best advice you have ever gotten in regards to programming/software?
+- What is the most satisfying thing you've done in your life?
+- Who is your favorite music composer/producer/singer?
+- What is your favorite song?
+- What is your favorite video game?
+- What are your hobbies other than programming?
+- Who is your favorite Writer?
+- What is your favorite movie?
+- What is your favorite sport?
+- What is your favorite fruit?
+- What is your favorite juice?
+- What is the best scenery you've ever seen?
+- What artistic talents do you have?
+- What is the tallest building you've entered?
+- What is the oldest computer you've ever used?
diff --git a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3
deleted file mode 100644
index 495f2bd1..00000000
--- a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3
deleted file mode 100644
index 538feabc..00000000
--- a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3
deleted file mode 100644
index 17f66698..00000000
--- a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3
deleted file mode 100644
index 5670657c..00000000
--- a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3
deleted file mode 100644
index 42f9e9fd..00000000
--- a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3
deleted file mode 100644
index 1cdb0f4d..00000000
--- a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3
deleted file mode 100644
index 89150d57..00000000
--- a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3
deleted file mode 100644
index b5f85f8d..00000000
--- a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3
deleted file mode 100644
index d141f68e..00000000
--- a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3
deleted file mode 100644
index a0614b53..00000000
--- a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3
deleted file mode 100644
index 38374316..00000000
--- a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3
deleted file mode 100644
index f769d9d8..00000000
--- a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3
deleted file mode 100644
index 8b04f0f5..00000000
--- a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3
deleted file mode 100644
index 964d685e..00000000
--- a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3
deleted file mode 100644
index 9e643773..00000000
--- a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3
deleted file mode 100644
index ad99cf76..00000000
--- a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt
deleted file mode 100644
index 7df03c2e..00000000
--- a/bot/resources/halloween/spookysounds/sources.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-Female_Monster_Growls_
-Male_Zombie_Roar_
-Monster_Alien_Growl_Calm_
-Monster_Alien_Grunt_Hiss_
-https://www.youtube.com/audiolibrary/soundeffects
-
-413315__inspectorj__something-evil-approaches-a
-https://freesound.org/people/InspectorJ/sounds/413315/
-
-133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08
-https://freesound.org/people/klankbeeld/sounds/133674/
-
-35716__analogchill__scream
-https://freesound.org/people/analogchill/sounds/35716/
-
-249686__cylon8472__cthulhu-growl
-https://freesound.org/people/cylon8472/sounds/249686/
-
-126113__klankbeeld__laugh
-https://freesound.org/people/klankbeeld/sounds/126113/
-
-14570__oscillator__ghost-fx
-https://freesound.org/people/oscillator/sounds/14570/
-
-60571__gabemiller74__breathofdeath
-https://freesound.org/people/gabemiller74/sounds/60571/
-
-168650__0xmusex0__doorcreak
-https://freesound.org/people/0XMUSEX0/sounds/168650/
-
-193812__geoneo0__four-voices-whispering-6
-https://freesound.org/people/geoneo0/sounds/193812/
-
-109710__tomlija__horror-gate
-https://freesound.org/people/Tomlija/sounds/109710/
-
-171078__klankbeeld__horror-scream-woman-long
-https://freesound.org/people/klankbeeld/sounds/171078/
-
-237282__devilfish101__frantic-violin-screech
-https://freesound.org/people/devilfish101/sounds/237282/
diff --git a/bot/resources/pride/gender_options.json b/bot/resources/pride/gender_options.json
new file mode 100644
index 00000000..062742fb
--- /dev/null
+++ b/bot/resources/pride/gender_options.json
@@ -0,0 +1,41 @@
+{
+ "agender": "agender",
+ "androgyne": "androgyne",
+ "androgynous": "androgyne",
+ "aromantic": "aromantic",
+ "aro": "aromantic",
+ "ace": "asexual",
+ "asexual": "asexual",
+ "bigender": "bigender",
+ "bisexual": "bisexual",
+ "bi": "bisexual",
+ "demiboy": "demiboy",
+ "demigirl": "demigirl",
+ "demi": "demisexual",
+ "demisexual": "demisexual",
+ "gay": "gay",
+ "lgbt": "gay",
+ "queer": "gay",
+ "homosexual": "gay",
+ "fluid": "genderfluid",
+ "genderfluid": "genderfluid",
+ "genderqueer": "genderqueer",
+ "intersex": "intersex",
+ "lesbian": "lesbian",
+ "non-binary": "nonbinary",
+ "enby": "nonbinary",
+ "nb": "nonbinary",
+ "nonbinary": "nonbinary",
+ "omnisexual": "omnisexual",
+ "omni": "omnisexual",
+ "pansexual": "pansexual",
+ "pan": "pansexual",
+ "pangender": "pangender",
+ "poly": "polysexual",
+ "polysexual": "polysexual",
+ "polyamory": "polyamory",
+ "polyamorous": "polyamory",
+ "transgender": "transgender",
+ "trans": "transgender",
+ "trigender": "trigender"
+}
diff --git a/bot/utils/converters.py b/bot/utils/converters.py
index 228714c9..27804170 100644
--- a/bot/utils/converters.py
+++ b/bot/utils/converters.py
@@ -1,5 +1,6 @@
import discord
-from discord.ext.commands.converter import MessageConverter
+from discord.ext.commands import BadArgument, Context
+from discord.ext.commands.converter import Converter, MessageConverter
class WrappedMessageConverter(MessageConverter):
@@ -14,3 +15,32 @@ class WrappedMessageConverter(MessageConverter):
argument = argument[1:-1]
return await super().convert(ctx, argument)
+
+
+class Subreddit(Converter):
+ """Forces a string to begin with "r/" and checks if it's a valid subreddit."""
+
+ @staticmethod
+ async def convert(ctx: Context, sub: str) -> str:
+ """
+ Force sub to begin with "r/" and check if it's a valid subreddit.
+
+ If sub is a valid subreddit, return it prepended with "r/"
+ """
+ sub = sub.lower()
+
+ if not sub.startswith("r/"):
+ sub = f"r/{sub}"
+
+ resp = await ctx.bot.http_session.get(
+ "https://www.reddit.com/subreddits/search.json",
+ params={"q": sub}
+ )
+
+ json = await resp.json()
+ if not json["data"]["children"]:
+ raise BadArgument(
+ f"The subreddit `{sub}` either doesn't exist, or it has no posts."
+ )
+
+ return sub
diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py
index 2b1c1b31..9e080759 100644
--- a/bot/utils/exceptions.py
+++ b/bot/utils/exceptions.py
@@ -1,4 +1,4 @@
class UserNotPlayingError(Exception):
- """Will raised when user try to use game commands when not playing."""
+ """Raised when users try to use game commands when they are not playing."""
pass
diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py
new file mode 100644
index 00000000..74c2ccd0
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,8 @@
+import re
+
+
+def suppress_links(message: str) -> str:
+ """Accepts a message that may contain links, suppresses them, and returns them."""
+ for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)):
+ message = message.replace(link, f"<{link}>")
+ return message
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
new file mode 100644
index 00000000..a6c035f9
--- /dev/null
+++ b/bot/utils/messages.py
@@ -0,0 +1,19 @@
+import re
+from typing import Optional
+
+
+def sub_clyde(username: Optional[str]) -> Optional[str]:
+ """
+ Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string.
+
+ Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400.
+ Return None only if `username` is None.
+ """
+ def replace_e(match: re.Match) -> str:
+ char = "е" if match[2] == "e" else "Е"
+ return match[1] + char
+
+ if username:
+ return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)
+ else:
+ return username # Empty string or None
diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py
index a4d0cc56..917275c0 100644
--- a/bot/utils/pagination.py
+++ b/bot/utils/pagination.py
@@ -4,6 +4,7 @@ from typing import Iterable, List, Optional, Tuple
from discord import Embed, Member, Reaction
from discord.abc import User
+from discord.embeds import EmptyEmbed
from discord.ext.commands import Context, Paginator
from bot.constants import Emojis
@@ -417,9 +418,8 @@ class ImagePaginator(Paginator):
await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
- image = paginator.images[current_page]
- if image:
- embed.set_image(url=image)
+ image = paginator.images[current_page] or EmptyEmbed
+ embed.set_image(url=image)
embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")