aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/FUNDING.yml2
-rw-r--r--.pre-commit-config.yaml12
-rw-r--r--CONTRIBUTING.md120
-rw-r--r--Pipfile4
-rw-r--r--Pipfile.lock347
-rw-r--r--bot/__init__.py1
-rw-r--r--bot/__main__.py10
-rw-r--r--bot/cogs/alias.py10
-rw-r--r--bot/cogs/antispam.py58
-rw-r--r--bot/cogs/bot.py10
-rw-r--r--bot/cogs/clean.py14
-rw-r--r--bot/cogs/cogs.py12
-rw-r--r--bot/cogs/defcon.py40
-rw-r--r--bot/cogs/deployment.py4
-rw-r--r--bot/cogs/doc.py6
-rw-r--r--bot/cogs/filtering.py169
-rw-r--r--bot/cogs/free.py106
-rw-r--r--bot/cogs/help.py15
-rw-r--r--bot/cogs/information.py44
-rw-r--r--bot/cogs/jams.py110
-rw-r--r--bot/cogs/moderation.py561
-rw-r--r--bot/cogs/modlog.py77
-rw-r--r--bot/cogs/off_topic_names.py28
-rw-r--r--bot/cogs/reddit.py13
-rw-r--r--bot/cogs/reminders.py10
-rw-r--r--bot/cogs/site.py74
-rw-r--r--bot/cogs/snakes.py1216
-rw-r--r--bot/cogs/snekbox.py5
-rw-r--r--bot/cogs/superstarify/__init__.py6
-rw-r--r--bot/cogs/tags.py6
-rw-r--r--bot/cogs/token_remover.py7
-rw-r--r--bot/cogs/utils.py6
-rw-r--r--bot/cogs/wolfram.py13
-rw-r--r--bot/constants.py54
-rw-r--r--bot/converters.py105
-rw-r--r--bot/decorators.py86
-rw-r--r--bot/pagination.py36
-rw-r--r--bot/resources/snake_cards/backs/card_back1.jpgbin165788 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/backs/card_back2.jpgbin140868 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_bottom.pngbin18165 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_frame.pngbin1460 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_top.pngbin12581 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/expressway.ttfbin156244 -> 0 bytes
-rw-r--r--bot/resources/snakes_and_ladders/banner.jpgbin17928 -> 0 bytes
-rw-r--r--bot/resources/snakes_and_ladders/board.jpgbin80264 -> 0 bytes
-rw-r--r--bot/resources/stars.json82
-rw-r--r--bot/rules/newlines.py23
-rw-r--r--bot/utils/__init__.py81
-rw-r--r--bot/utils/checks.py56
-rw-r--r--bot/utils/snakes/__init__.py0
-rw-r--r--bot/utils/snakes/hatching.py44
-rw-r--r--bot/utils/snakes/perlin.py158
-rw-r--r--bot/utils/snakes/perlinsneks.py111
-rw-r--r--bot/utils/snakes/sal.py365
-rw-r--r--bot/utils/snakes/sal_board.py33
-rw-r--r--config-default.yml51
56 files changed, 1635 insertions, 2766 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..6d9919ef2
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+patreon: python_discord
+custom: https://www.redbubble.com/people/pythondiscord
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..4776bc63b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,12 @@
+repos:
+- repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v2.2.3
+ hooks:
+ - id: flake8
+ additional_dependencies: [
+ "flake8-bugbear",
+ "flake8-import-order",
+ "flake8-tidy-imports",
+ "flake8-todo",
+ "flake8-string-format"
+ ] \ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 36152fc5d..6648ce1f0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,40 +1,104 @@
# Contributing to one of our projects
-Our projects are open-source, and are deployed as commits are pushed to the `master` branch on each repository.
-We've created a set of guidelines here in order to keep everything clean and in working order. Please note that
-contributions may be rejected on the basis of a contributor failing to follow the guidelines.
+Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` 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. **No force-pushes** or modifying the Git history in any way.
-1. If you have direct access to the repository, **create a branch for your changes** and create a merge request for that branch.
- If not, fork it and work on a separate branch there.
- * Some repositories require this and will reject any direct pushes to `master`. Make this a habit!
-1. If someone is working on a merge request, **do not open your own merge request for the same task**. Instead, leave some comments
- on the existing merge request. 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 merge request. If you do this, we suggest following these guidelines when interacting with their repository
- as well.
-1. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html).
- * Additionally, run `flake8` against your code before you push it. Your commit will be rejected by the build server
- if it fails to lint.
-1. **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!
-1. **Work as a team** and cooperate where possible. Keep things friendly, and help each other out - these are shared
- projects, and nobody likes to have their feet trodden on.
-1. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server
- does not. With this trust comes responsibility - do not release any information you have learned as a result of
- your contributor position. We are very strict about announcing things at specific times, and many staff members
- will not appreciate a disruption of the announcement schedule.
-
-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, especially in relation to Rule 7.
+2. 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 `master`, so make branching a habit!
+3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html).
+ * Run `flake8` against your code **before** you push it. 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 tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) 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 breaking the build for linting errors.
+4. **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.
+ * Try to avoid making minor commits for fixing typos or linting errors. Since you've already set up a pre-commit hook to run `flake8` 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/)
+5. **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 master into your branch. Try to leave merging from master 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 master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing.
+6. **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!
+7. 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.
+8. **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.
+9. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server does not. With this trust comes responsibility - do not release any information you have learned as a result of your contributor position. We are very strict about announcing things at specific times, and many staff members will not appreciate a disruption of the announcement schedule.
+10. 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, especially in relation to Rule 7.
## Changes to this arrangement
-All projects evolve over time, and this contribution guide is no different. This document may also be subject to pull
-requests or changes by contributors, where you believe you have something valuable to add or change.
+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
+A working environment for the [PyDis site](https://github.com/python-discord/site) is required to develop the bot. Instructions for setting up environments for both the site and the bot can be found on the PyDis Wiki:
+ * [Site](https://wiki.pythondiscord.com/wiki/contributing/project/site)
+ * [Bot](https://wiki.pythondiscord.com/wiki/contributing/project/bot)
+
+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
+def foo(input_1: int, input_2: dict) -> bool:
+```
+
+Tells us that `foo` accepts an `int` and a `dict` 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
+def foo(bar: int, baz: dict=None) -> bool:
+ """
+ Does some things with some stuff.
+
+ :param bar: Some input
+ :param baz: Optional, some other input
+
+ :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
+def foo(bar: int, baz: dict=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:
+* **TRACE:** Use this for tracing every step of a complex process. That way we can see which step of the process failed. Err on the side of verbose. **Note:** This is a PyDis-implemented logging level.
+* **DEBUG:** Someone is interacting with the application, and the application is behaving as expected.
+* **INFO:** Something completely ordinary happened. Like a cog loading during startup.
+* **WARNING:** Someone is interacting with the application in an unexpected way or the application is responding in an unexpected way, but without causing an error.
+* **ERROR:** An error that affects the specific part that is being interacted with
+* **CRITICAL:** An error that affects the whole application.
+
+### Work in Progress (WIP) PRs
+Github [has introduced a new 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.
## Footnotes
diff --git a/Pipfile b/Pipfile
index 0524f10bf..e2ad73ef3 100644
--- a/Pipfile
+++ b/Pipfile
@@ -13,12 +13,12 @@ markdownify = "*"
lxml = "*"
pyyaml = "*"
fuzzywuzzy = "*"
-pillow = "*"
aio-pika = "*"
python-dateutil = "*"
deepdiff = "*"
requests = "*"
dateparser = "*"
+urllib3 = ">=1.24.2,<1.25"
[dev-packages]
"flake8" = ">=3.6"
@@ -29,6 +29,7 @@ dateparser = "*"
"flake8-string-format" = "*"
safety = "*"
dodgy = "*"
+pre-commit = "*"
pytest = "*"
[requires]
@@ -37,6 +38,7 @@ python_version = "3.7"
[scripts]
start = "python -m bot"
lint = "python -m flake8"
+precommit = "pre-commit install"
build = "docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile ."
push = "docker push pythondiscord/bot:latest"
buildbase = "docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile ."
diff --git a/Pipfile.lock b/Pipfile.lock
index a3d489e31..e2585756f 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "927aabf1a7c4b9e097c3521012e20e030c4f493298fbc489713b30eaff48e732"
+ "sha256": "ad3b645e777f7b21a2bfb472e182361f904ae5f1f41df59300c4c68c89bd2fd1"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a",
- "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b"
+ "sha256:47b12535897117b9876db2e2c0506b6c89bd4dbd90617cd8b20163d4196137ed",
+ "sha256:821ee9f652ba472919ebffdc37c661fc740c24309a2291b37ac8a160b4003ce6"
],
"index": "pypi",
- "version": "==5.5.2"
+ "version": "==5.5.3"
},
"aiodns": {
"hashes": [
@@ -62,10 +62,10 @@
},
"aiormq": {
"hashes": [
- "sha256:2e18576a90dfdaa91f705bd226506d9589353350f09b7121179c0bf5350a79a8",
- "sha256:be3e74b6f4a490ea1f3d393c186e98e8214cdde26f7073812b23fc002fff7383"
+ "sha256:038bd43d68f8e77bf79c7cc362da9df5ca6497c23c3bf20ee43ce1622448ef8a",
+ "sha256:f36be480de4009ddb621a8795c52f0c146813799f56d79e126dfa60e13e41dd9"
],
- "version": "==2.5.1"
+ "version": "==2.5.5"
},
"alabaster": {
"hashes": [
@@ -90,10 +90,10 @@
},
"babel": {
"hashes": [
- "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
- "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
+ "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
+ "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
],
- "version": "==2.6.0"
+ "version": "==2.7.0"
},
"beautifulsoup4": {
"hashes": [
@@ -105,10 +105,10 @@
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
+ "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
],
- "version": "==2019.3.9"
+ "version": "==2019.6.16"
},
"cffi": {
"hashes": [
@@ -150,14 +150,6 @@
],
"version": "==3.0.4"
},
- "colorama": {
- "hashes": [
- "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
- "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
- ],
- "markers": "sys_platform == 'win32'",
- "version": "==0.4.1"
- },
"dateparser": {
"hashes": [
"sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e",
@@ -205,13 +197,6 @@
],
"version": "==2.8"
},
- "idna-ssl": {
- "hashes": [
- "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"
- ],
- "markers": "python_version < '3.7'",
- "version": "==1.1.0"
- },
"imagesize": {
"hashes": [
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
@@ -228,10 +213,10 @@
},
"jsonpickle": {
"hashes": [
- "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0",
- "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1"
+ "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2",
+ "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b"
],
- "version": "==1.1"
+ "version": "==1.2"
},
"logmatic-python": {
"hashes": [
@@ -242,35 +227,33 @@
},
"lxml": {
"hashes": [
- "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5",
- "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f",
- "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b",
- "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616",
- "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b",
- "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294",
- "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3",
- "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90",
- "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e",
- "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314",
- "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f",
- "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d",
- "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8",
- "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3",
- "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33",
- "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317",
- "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63",
- "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd",
- "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f",
- "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a",
- "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f",
- "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22",
- "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d",
- "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b",
- "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f",
- "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86"
+ "sha256:06c7616601430aa140a69f97e3116308fffe0848f543b639a5ec2e8920ae72fd",
+ "sha256:177202792f9842374a8077735c69c41a4282183f7851443d2beb8ee310720819",
+ "sha256:19317ad721ceb9e39847d11131903931e2794e447d4751ebb0d9236f1b349ff2",
+ "sha256:36d206e62f3e5dbaafd4ec692b67157e271f5da7fd925fda8515da675eace50d",
+ "sha256:387115b066c797c85f9861a9613abf50046a15aac16759bc92d04f94acfad082",
+ "sha256:3ce1c49d4b4a7bc75fb12acb3a6247bb7a91fe420542e6d671ba9187d12a12c2",
+ "sha256:4d2a5a7d6b0dbb8c37dab66a8ce09a8761409c044017721c21718659fa3365a1",
+ "sha256:58d0a1b33364d1253a88d18df6c0b2676a1746d27c969dc9e32d143a3701dda5",
+ "sha256:62a651c618b846b88fdcae0533ec23f185bb322d6c1845733f3123e8980c1d1b",
+ "sha256:69ff21064e7debc9b1b1e2eee8c2d686d042d4257186d70b338206a80c5bc5ea",
+ "sha256:7060453eba9ba59d821625c6af6a266bd68277dce6577f754d1eb9116c094266",
+ "sha256:7d26b36a9c4bce53b9cfe42e67849ae3c5c23558bc08363e53ffd6d94f4ff4d2",
+ "sha256:83b427ad2bfa0b9705e02a83d8d607d2c2f01889eb138168e462a3a052c42368",
+ "sha256:923d03c84534078386cf50193057aae98fa94cace8ea7580b74754493fda73ad",
+ "sha256:b773715609649a1a180025213f67ffdeb5a4878c784293ada300ee95a1f3257b",
+ "sha256:baff149c174e9108d4a2fee192c496711be85534eab63adb122f93e70aa35431",
+ "sha256:bca9d118b1014b4c2d19319b10a3ebed508ff649396ce1855e1c96528d9b2fa9",
+ "sha256:ce580c28845581535dc6000fc7c35fdadf8bea7ccb57d6321b044508e9ba0685",
+ "sha256:d34923a569e70224d88e6682490e24c842907ba2c948c5fd26185413cbe0cd96",
+ "sha256:dd9f0e531a049d8b35ec5e6c68a37f1ba6ec3a591415e6804cbdf652793d15d7",
+ "sha256:ecb805cbfe9102f3fd3d2ef16dfe5ae9e2d7a7dfbba92f4ff1e16ac9784dbfb0",
+ "sha256:ede9aad2197a0202caff35d417b671f5f91a3631477441076082a17c94edd846",
+ "sha256:ef2d1fc370400e0aa755aab0b20cf4f1d0e934e7fd5244f3dd4869078e4942b9",
+ "sha256:f2fec194a49bfaef42a548ee657362af5c7a640da757f6f452a35da7dd9f923c"
],
"index": "pypi",
- "version": "==4.3.3"
+ "version": "==4.3.4"
},
"markdownify": {
"hashes": [
@@ -366,38 +349,6 @@
],
"version": "==2.3.0"
},
- "pillow": {
- "hashes": [
- "sha256:15c056bfa284c30a7f265a41ac4cbbc93bdbfc0dfe0613b9cb8a8581b51a9e55",
- "sha256:1a4e06ba4f74494ea0c58c24de2bb752818e9d504474ec95b0aa94f6b0a7e479",
- "sha256:1c3c707c76be43c9e99cb7e3d5f1bee1c8e5be8b8a2a5eeee665efbf8ddde91a",
- "sha256:1fd0b290203e3b0882d9605d807b03c0f47e3440f97824586c173eca0aadd99d",
- "sha256:24114e4a6e1870c5a24b1da8f60d0ba77a0b4027907860188ea82bd3508c80eb",
- "sha256:258d886a49b6b058cd7abb0ab4b2b85ce78669a857398e83e8b8e28b317b5abb",
- "sha256:33c79b6dd6bc7f65079ab9ca5bebffb5f5d1141c689c9c6a7855776d1b09b7e8",
- "sha256:367385fc797b2c31564c427430c7a8630db1a00bd040555dfc1d5c52e39fcd72",
- "sha256:3c1884ff078fb8bf5f63d7d86921838b82ed4a7d0c027add773c2f38b3168754",
- "sha256:44e5240e8f4f8861d748f2a58b3f04daadab5e22bfec896bf5434745f788f33f",
- "sha256:46aa988e15f3ea72dddd81afe3839437b755fffddb5e173886f11460be909dce",
- "sha256:74d90d499c9c736d52dd6d9b7221af5665b9c04f1767e35f5dd8694324bd4601",
- "sha256:809c0a2ce9032cbcd7b5313f71af4bdc5c8c771cb86eb7559afd954cab82ebb5",
- "sha256:85d1ef2cdafd5507c4221d201aaf62fc9276f8b0f71bd3933363e62a33abc734",
- "sha256:8c3889c7681af77ecfa4431cd42a2885d093ecb811e81fbe5e203abc07e0995b",
- "sha256:9218d81b9fca98d2c47d35d688a0cea0c42fd473159dfd5612dcb0483c63e40b",
- "sha256:9aa4f3827992288edd37c9df345783a69ef58bd20cc02e64b36e44bcd157bbf1",
- "sha256:9d80f44137a70b6f84c750d11019a3419f409c944526a95219bea0ac31f4dd91",
- "sha256:b7ebd36128a2fe93991293f997e44be9286503c7530ace6a55b938b20be288d8",
- "sha256:c4c78e2c71c257c136cdd43869fd3d5e34fc2162dc22e4a5406b0ebe86958239",
- "sha256:c6a842537f887be1fe115d8abb5daa9bc8cc124e455ff995830cc785624a97af",
- "sha256:cf0a2e040fdf5a6d95f4c286c6ef1df6b36c218b528c8a9158ec2452a804b9b8",
- "sha256:cfd28aad6fc61f7a5d4ee556a997dc6e5555d9381d1390c00ecaf984d57e4232",
- "sha256:dca5660e25932771460d4688ccbb515677caaf8595f3f3240ec16c117deff89a",
- "sha256:de7aedc85918c2f887886442e50f52c1b93545606317956d65f342bd81cb4fc3",
- "sha256:e6c0bbf8e277b74196e3140c35f9a1ae3eafd818f7f2d3a15819c49135d6c062"
- ],
- "index": "pypi",
- "version": "==6.0.0"
- },
"pycares": {
"hashes": [
"sha256:2ca080db265ea238dc45f997f94effb62b979a617569889e265c26a839ed6305",
@@ -418,16 +369,17 @@
},
"pycparser": {
"hashes": [
- "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
+ "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3",
+ "sha256:b360ff0cd21cdecd07372020a2d7f3234e1acc8c31ab4b4d3a6fa6e5bc6259cd"
],
"version": "==2.19"
},
"pygments": {
"hashes": [
- "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
- "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
+ "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
+ "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
],
- "version": "==2.3.1"
+ "version": "==2.4.2"
},
"pynacl": {
"hashes": [
@@ -495,42 +447,44 @@
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3",
+ "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043",
+ "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7",
+ "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265",
+ "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391",
+ "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778",
+ "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225",
+ "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955",
+ "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e",
+ "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190",
+ "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd"
],
"index": "pypi",
- "version": "==5.1"
+ "version": "==5.1.1"
},
"regex": {
"hashes": [
- "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4",
- "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf",
- "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175",
- "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19",
- "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c",
- "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9",
- "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8",
- "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680",
- "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585"
+ "sha256:1c70ccb8bf4ded0cbe53092e9f56dcc9d6b0efcf6e80b6ef9b0ece8a557d6635",
+ "sha256:2948310c01535ccb29bb600dd033b07b91f36e471953889b7f3a1e66b39d0c19",
+ "sha256:2ab13db0411cb308aa590d33c909ea4efeced40188d8a4a7d3d5970657fe73bc",
+ "sha256:38e6486c7e14683cd1b17a4218760f0ea4c015633cf1b06f7c190fb882a51ba7",
+ "sha256:80dde4ff10b73b823da451687363cac93dd3549e059d2dc19b72a02d048ba5aa",
+ "sha256:84daedefaa56320765e9c4d43912226d324ef3cc929f4d75fa95f8c579a08211",
+ "sha256:b98e5876ca1e63b41c4aa38d7d5cc04a736415d4e240e9ae7ebc4f780083c7d5",
+ "sha256:ca4f47131af28ef168ff7c80d4b4cad019cb4cabb5fa26143f43aa3dbd60389c",
+ "sha256:cf7838110d3052d359da527372666429b9485ab739286aa1a11ed482f037a88c",
+ "sha256:dd4e8924915fa748e128864352875d3d0be5f4597ab1b1d475988b8e3da10dd7",
+ "sha256:f2c65530255e4010a5029eb11138f5ecd5aa70363f57a3444d83b3253b0891be"
],
- "version": "==2019.4.14"
+ "version": "==2019.6.8"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"six": {
"hashes": [
@@ -555,11 +509,11 @@
},
"sphinx": {
"hashes": [
- "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b",
- "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce"
+ "sha256:15143166e786c7faa76fa990d3b6b38ebffe081ef81cffd1d656b07f3b28a1fa",
+ "sha256:5fd62ba64235d77a81554d47ff6b17578171b6dbbc992221e9ebc684898fff59"
],
"index": "pypi",
- "version": "==2.0.1"
+ "version": "==2.1.1"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -603,15 +557,6 @@
],
"version": "==1.1.3"
},
- "typing": {
- "hashes": [
- "sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d",
- "sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4",
- "sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a"
- ],
- "markers": "python_version < '3.7'",
- "version": "==3.6.6"
- },
"tzlocal": {
"hashes": [
"sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"
@@ -620,10 +565,11 @@
},
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
],
- "version": "==1.24.2"
+ "index": "pypi",
+ "version": "==1.24.3"
},
"websockets": {
"hashes": [
@@ -669,6 +615,13 @@
}
},
"develop": {
+ "aspy.yaml": {
+ "hashes": [
+ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc",
+ "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"
+ ],
+ "version": "==1.3.0"
+ },
"atomicwrites": {
"hashes": [
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
@@ -685,10 +638,17 @@
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
+ "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
+ ],
+ "version": "==2019.6.16"
+ },
+ "cfgv": {
+ "hashes": [
+ "sha256:32edbe09de6f4521224b87822103a8c16a614d31a894735f7a5b3bcf0eb3c37e",
+ "sha256:3bd31385cd2bebddbba8012200aaf15aa208539f1b33973759b4d02fc2148da5"
],
- "version": "==2019.3.9"
+ "version": "==2.0.0"
},
"chardet": {
"hashes": [
@@ -704,14 +664,6 @@
],
"version": "==7.0"
},
- "colorama": {
- "hashes": [
- "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
- "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
- ],
- "markers": "sys_platform == 'win32'",
- "version": "==0.4.1"
- },
"dodgy": {
"hashes": [
"sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c"
@@ -780,6 +732,13 @@
"index": "pypi",
"version": "==0.7"
},
+ "identify": {
+ "hashes": [
+ "sha256:0a11379b46d06529795442742a043dc2fa14cd8c995ae81d1febbc5f1c014c87",
+ "sha256:43a5d24ffdb07bc7e21faf68b08e9f526a1f41f0056073f480291539ef961dfd"
+ ],
+ "version": "==1.4.5"
+ },
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
@@ -787,6 +746,13 @@
],
"version": "==2.8"
},
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:6dfd58dfe281e8d240937776065dd3624ad5469c835248219bd16cf2e12dbeb7",
+ "sha256:cb6ee23b46173539939964df59d3d72c3e0c1b5d54b84f1d8a7e912fe43612db"
+ ],
+ "version": "==0.18"
+ },
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@@ -802,6 +768,12 @@
"markers": "python_version > '2.7'",
"version": "==7.0.0"
},
+ "nodeenv": {
+ "hashes": [
+ "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
+ ],
+ "version": "==1.3.3"
+ },
"packaging": {
"hashes": [
"sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
@@ -811,10 +783,18 @@
},
"pluggy": {
"hashes": [
- "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
- "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
+ "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc",
+ "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"
],
- "version": "==0.9.0"
+ "version": "==0.12.0"
+ },
+ "pre-commit": {
+ "hashes": [
+ "sha256:92e406d556190503630fd801958379861c94884693a032ba66629d0351fdccd4",
+ "sha256:cccc39051bc2457b0c0f7152a411f8e05e3ba2fe1a5613e4ee0833c1c1985ce3"
+ ],
+ "index": "pypi",
+ "version": "==1.17.0"
},
"py": {
"hashes": [
@@ -846,36 +826,36 @@
},
"pytest": {
"hashes": [
- "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d",
- "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5"
+ "sha256:4a784f1d4f2ef198fe9b7aef793e9fa1a3b2f84e822d9b3a64a181293a572d45",
+ "sha256:926855726d8ae8371803f7b2e6ec0a69953d9c6311fa7c3b6c1b929ff92d27da"
],
"index": "pypi",
- "version": "==4.4.1"
+ "version": "==4.6.3"
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3",
+ "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043",
+ "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7",
+ "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265",
+ "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391",
+ "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778",
+ "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225",
+ "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955",
+ "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e",
+ "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190",
+ "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd"
],
"index": "pypi",
- "version": "==5.1"
+ "version": "==5.1.1"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"safety": {
"hashes": [
@@ -892,12 +872,41 @@
],
"version": "==1.12.0"
},
+ "toml": {
+ "hashes": [
+ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
+ "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+ ],
+ "version": "==0.10.0"
+ },
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
+ ],
+ "index": "pypi",
+ "version": "==1.24.3"
+ },
+ "virtualenv": {
+ "hashes": [
+ "sha256:b7335cddd9260a3dd214b73a2521ffc09647bde3e9457fcca31dc3be3999d04a",
+ "sha256:d28ca64c0f3f125f59cabf13e0a150e1c68e5eea60983cc4395d88c584495783"
+ ],
+ "version": "==16.6.1"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
+ "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
+ ],
+ "version": "==0.1.7"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d",
+ "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3"
],
- "version": "==1.24.2"
+ "version": "==0.5.1"
}
}
}
diff --git a/bot/__init__.py b/bot/__init__.py
index b6919a489..8efa5e53c 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -91,5 +91,4 @@ for key, value in logging.Logger.manager.loggerDict.items():
# Silence irrelevant loggers
logging.getLogger("aio_pika").setLevel(logging.ERROR)
logging.getLogger("discord").setLevel(logging.ERROR)
-logging.getLogger("PIL").setLevel(logging.ERROR)
logging.getLogger("websockets").setLevel(logging.ERROR)
diff --git a/bot/__main__.py b/bot/__main__.py
index 44d4d9c02..b3f80ef55 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -31,10 +31,10 @@ bot.http_session = ClientSession(
bot.api_client = APIClient(loop=asyncio.get_event_loop())
# Internal/debug
-bot.load_extension("bot.cogs.logging")
-bot.load_extension("bot.cogs.security")
bot.load_extension("bot.cogs.filtering")
+bot.load_extension("bot.cogs.logging")
bot.load_extension("bot.cogs.modlog")
+bot.load_extension("bot.cogs.security")
# Commands, etc
bot.load_extension("bot.cogs.antispam")
@@ -50,20 +50,20 @@ if not DEBUG_MODE:
# Feature cogs
bot.load_extension("bot.cogs.alias")
-bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.defcon")
bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.eval")
+bot.load_extension("bot.cogs.free")
bot.load_extension("bot.cogs.fun")
-bot.load_extension("bot.cogs.superstarify")
bot.load_extension("bot.cogs.information")
+bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
-bot.load_extension("bot.cogs.snakes")
bot.load_extension("bot.cogs.snekbox")
+bot.load_extension("bot.cogs.superstarify")
bot.load_extension("bot.cogs.sync")
bot.load_extension("bot.cogs.tags")
bot.load_extension("bot.cogs.token_remover")
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index f71d5d81f..85d101448 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -114,7 +114,7 @@ class Alias:
@command(name="reload", hidden=True)
async def cogs_reload_alias(self, ctx, *, cog_name: str):
"""
- Alias for invoking <prefix>cogs reload cog_name.
+ Alias for invoking <prefix>cogs reload [cog_name].
cog_name: str - name of the cog to be reloaded.
"""
@@ -137,6 +137,14 @@ class Alias:
await self.invoke(ctx, "defcon disable")
+ @command(name="exception", hidden=True)
+ async def tags_get_traceback_alias(self, ctx):
+ """
+ Alias for invoking <prefix>tags get traceback.
+ """
+
+ await self.invoke(ctx, "tags get traceback")
+
@group(name="get",
aliases=("show", "g"),
hidden=True,
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 1c69d33ae..0c6a02bf9 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -1,21 +1,19 @@
-import asyncio
import logging
-import textwrap
from datetime import datetime, timedelta
from typing import List
-from dateutil.relativedelta import relativedelta
from discord import Colour, Member, Message, Object, TextChannel
from discord.ext.commands import Bot
from bot import rules
+from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
- Colours, DEBUG_MODE, Event,
- Guild as GuildConfig, Icons, Roles,
+ Colours, DEBUG_MODE, Event, Filter,
+ Guild as GuildConfig, Icons,
+ Roles, STAFF_ROLES,
)
-from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -32,18 +30,12 @@ RULE_FUNCTION_MAPPING = {
'newlines': rules.apply_newlines,
'role_mentions': rules.apply_role_mentions
}
-WHITELISTED_CHANNELS = (
- Channels.admins, Channels.announcements, Channels.big_brother_logs,
- Channels.devlog, Channels.devtest, Channels.helpers, Channels.message_log,
- Channels.mod_alerts, Channels.modlog, Channels.staff_lounge
-)
-WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
class AntiSpam:
def __init__(self, bot: Bot):
self.bot = bot
- self.muted_role = None
+ self._muted_role = Object(Roles.muted)
@property
def mod_log(self) -> ModLog:
@@ -58,8 +50,8 @@ class AntiSpam:
not message.guild
or message.guild.id != GuildConfig.id
or message.author.bot
- or (message.channel.id in WHITELISTED_CHANNELS and not DEBUG_MODE)
- or (message.author.top_role.id in WHITELISTED_ROLES and not DEBUG_MODE)
+ or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)
+ or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE)
):
return
@@ -99,18 +91,16 @@ class AntiSpam:
# Fire it off as a background task to ensure
# that the sleep doesn't block further tasks
self.bot.loop.create_task(
- self.punish(message, member, full_reason, relevant_messages)
+ self.punish(message, member, full_reason, relevant_messages, rule_name)
)
await self.maybe_delete_messages(message.channel, relevant_messages)
break
- async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message]):
+ async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str):
# Sanity check to ensure we're not lagging behind
if self.muted_role not in member.roles:
remove_role_after = AntiSpamConfig.punishment['remove_after']
- duration_delta = relativedelta(seconds=remove_role_after)
- human_duration = humanize_delta(duration_delta)
mod_alert_message = (
f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n"
@@ -118,8 +108,8 @@ class AntiSpam:
f"**Reason:** {reason}\n"
)
- # For multiple messages, use the logs API
- if len(messages) > 1:
+ # For multiple messages or those with excessive newlines, use the logs API
+ if len(messages) > 1 or rule_name == 'newlines':
url = await self.mod_log.upload_log(messages, msg.guild.me.id)
mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
else:
@@ -132,7 +122,8 @@ class AntiSpam:
mod_alert_message += f"{content}"
- await self.mod_log.send_log_message(
+ # Return the mod log message Context that we can use to post the infraction
+ mod_log_ctx = await self.mod_log.send_log_message(
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
title=f"Spam detected!",
@@ -142,27 +133,8 @@ class AntiSpam:
ping_everyone=AntiSpamConfig.ping_everyone
)
- await member.add_roles(self.muted_role, reason=reason)
- description = textwrap.dedent(f"""
- **Channel**: {msg.channel.mention}
- **User**: {msg.author.mention} (`{msg.author.id}`)
- **Reason**: {reason}
- Role will be removed after {human_duration}.
- """)
-
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=Colour(Colours.soft_red),
- title="User muted", text=description
- )
-
- await asyncio.sleep(remove_role_after)
- await member.remove_roles(self.muted_role, reason="AntiSpam mute expired")
-
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=Colour(Colours.soft_green),
- title="User unmuted",
- text=f"Was muted by `AntiSpam` cog for {human_duration}."
- )
+ # Run a tempmute
+ await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason)
async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]):
# Is deletion of offending messages actually enabled?
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 84ddb85f4..828e2514c 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -7,7 +7,8 @@ from discord import Embed, Message, RawMessageUpdateEvent
from discord.ext.commands import Bot, Context, command, group
from bot.constants import (
- Channels, Guild, Roles, URLs
+ Channels, Guild, MODERATION_ROLES,
+ Roles, URLs,
)
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
@@ -75,7 +76,7 @@ class Bot:
await ctx.send(embed=embed)
@command(name='echo', aliases=('print',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def echo_command(self, ctx: Context, *, text: str):
"""
Send the input verbatim to the current channel
@@ -84,7 +85,7 @@ class Bot:
await ctx.send(text)
@command(name='embed')
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def embed_command(self, ctx: Context, *, text: str):
"""
Send the input within an embed to the current channel
@@ -366,7 +367,7 @@ class Bot:
return
# Retrieve channel and message objects for use later
- channel = self.bot.get_channel(payload.data.get("channel_id"))
+ channel = self.bot.get_channel(int(payload.data.get("channel_id")))
user_message = await channel.get_message(payload.message_id)
# Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None
@@ -377,6 +378,7 @@ class Bot:
bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id])
await bot_message.delete()
del self.codeblock_message_ids[payload.message_id]
+ log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
def setup(bot):
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 7621c4ef7..e7b6bac85 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Bot, Context, group
from bot.cogs.modlog import ModLog
from bot.constants import (
Channels, CleanMessages, Colours, Event,
- Icons, NEGATIVE_REPLIES, Roles
+ Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
from bot.decorators import with_role
@@ -190,7 +190,7 @@ class Clean:
)
@group(invoke_without_command=True, name="clean", hidden=True)
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context):
"""
Commands for cleaning messages in channels
@@ -199,7 +199,7 @@ class Clean:
await ctx.invoke(self.bot.get_command("help"), "clean")
@clean_group.command(name="user", aliases=["users"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_user(self, ctx: Context, user: User, amount: int = 10):
"""
Delete messages posted by the provided user,
@@ -209,7 +209,7 @@ class Clean:
await self._clean_messages(amount, ctx, user=user)
@clean_group.command(name="all", aliases=["everything"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_all(self, ctx: Context, amount: int = 10):
"""
Delete all messages, regardless of poster,
@@ -219,7 +219,7 @@ class Clean:
await self._clean_messages(amount, ctx)
@clean_group.command(name="bots", aliases=["bot"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_bots(self, ctx: Context, amount: int = 10):
"""
Delete all messages posted by a bot,
@@ -229,7 +229,7 @@ class Clean:
await self._clean_messages(amount, ctx, bots_only=True)
@clean_group.command(name="regex", aliases=["word", "expression"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_regex(self, ctx: Context, regex, amount: int = 10):
"""
Delete all messages that match a certain regex,
@@ -239,7 +239,7 @@ class Clean:
await self._clean_messages(amount, ctx, regex=regex)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
+ @with_role(*MODERATION_ROLES)
async def clean_cancel(self, ctx: Context):
"""
If there is an ongoing cleaning process,
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
index cefe6b530..5bef52c0a 100644
--- a/bot/cogs/cogs.py
+++ b/bot/cogs/cogs.py
@@ -5,7 +5,7 @@ from discord import Colour, Embed
from discord.ext.commands import Bot, Context, group
from bot.constants import (
- Emojis, Roles, URLs,
+ Emojis, MODERATION_ROLES, Roles, URLs
)
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -37,14 +37,14 @@ class Cogs:
self.cogs.update({v: k for k, v in self.cogs.items()})
@group(name='cogs', aliases=('c',), invoke_without_command=True)
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.devops)
async def cogs_group(self, ctx: Context):
"""Load, unload, reload, and list active cogs."""
await ctx.invoke(self.bot.get_command("help"), "cogs")
@cogs_group.command(name='load', aliases=('l',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.devops)
async def load_command(self, ctx: Context, cog: str):
"""
Load up an unloaded cog, given the module containing it
@@ -97,7 +97,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='unload', aliases=('ul',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.devops)
async def unload_command(self, ctx: Context, cog: str):
"""
Unload an already-loaded cog, given the module containing it
@@ -149,7 +149,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='reload', aliases=('r',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.devops)
async def reload_command(self, ctx: Context, cog: str):
"""
Reload an unloaded cog, given the module containing it
@@ -254,7 +254,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='list', aliases=('all',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.devops)
async def list_command(self, ctx: Context):
"""
Get a list of all cogs, including their loaded status.
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 29979de83..c67fa2807 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -5,14 +5,11 @@ from discord import Colour, Embed, Member
from discord.ext.commands import Bot, Context, group
from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Emojis, Icons, Keys, Roles
+from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
-COLOUR_RED = Colour(0xcd6d6d)
-COLOUR_GREEN = Colour(0x68c290)
-
REJECTION_MESSAGE = """
Hi, {user} - Thanks for your interest in our server!
@@ -24,6 +21,8 @@ will be resolved soon. In the meantime, please feel free to peruse the resources
<https://pythondiscord.com/>, and have a nice day!
"""
+BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism"
+
class Defcon:
"""Time-sensitive server defense mechanisms"""
@@ -61,6 +60,8 @@ class Defcon:
self.days = timedelta(days=0)
log.warning(f"DEFCON disabled")
+ await self.update_channel_topic()
+
async def on_member_join(self, member: Member):
if self.enabled and self.days.days > 0:
now = datetime.utcnow()
@@ -88,7 +89,7 @@ class Defcon:
message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled."
await self.mod_log.send_log_message(
- Icons.defcon_denied, COLOUR_RED, "Entry denied",
+ Icons.defcon_denied, Colours.soft_red, "Entry denied",
message, member.avatar_url_as(static_format="png")
)
@@ -134,7 +135,7 @@ class Defcon:
)
await self.mod_log.send_log_message(
- Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
+ Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}\n\n"
"**There was a problem updating the site** - This setting may be reverted when the bot is "
@@ -146,11 +147,13 @@ class Defcon:
await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.")
await self.mod_log.send_log_message(
- Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
+ Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
f"**Days:** {self.days.days}\n\n"
)
+ await self.update_channel_topic()
+
@defcon_group.command(name='disable', aliases=('off', 'd'))
@with_role(Roles.admin, Roles.owner)
async def disable_command(self, ctx: Context):
@@ -181,7 +184,7 @@ class Defcon:
)
await self.mod_log.send_log_message(
- Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
+ Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
"**There was a problem updating the site** - This setting may be reverted when the bot is "
"restarted.\n\n"
@@ -191,10 +194,12 @@ class Defcon:
await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.")
await self.mod_log.send_log_message(
- Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
+ Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled",
f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)"
)
+ await self.update_channel_topic()
+
@defcon_group.command(name='status', aliases=('s',))
@with_role(Roles.admin, Roles.owner)
async def status_command(self, ctx: Context):
@@ -259,6 +264,23 @@ class Defcon:
f"**Days:** {self.days.days}"
)
+ await self.update_channel_topic()
+
+ async def update_channel_topic(self):
+ """
+ Update the #defcon channel topic with the current DEFCON status
+ """
+
+ if self.enabled:
+ day_str = "days" if self.days.days > 1 else "day"
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})"
+ else:
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)"
+
+ self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
+ defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon)
+ await defcon_channel.edit(topic=new_topic)
+
def setup(bot: Bot):
bot.add_cog(Defcon(bot))
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
index bc9dbf5ab..e71e07c2f 100644
--- a/bot/cogs/deployment.py
+++ b/bot/cogs/deployment.py
@@ -3,7 +3,7 @@ import logging
from discord import Colour, Embed
from discord.ext.commands import Bot, Context, command, group
-from bot.constants import Keys, Roles, URLs
+from bot.constants import Keys, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ class Deployment:
self.bot = bot
@group(name='redeploy', invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def redeploy_group(self, ctx: Context):
"""Redeploy the bot or the site."""
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index d427acc3a..aa49b0c25 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -13,7 +13,7 @@ from markdownify import MarkdownConverter
from requests import ConnectionError
from sphinx.ext import intersphinx
-from bot.constants import Roles
+from bot.constants import MODERATION_ROLES
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -319,7 +319,7 @@ class Doc:
await ctx.send(embed=doc_embed)
@docs_group.command(name='set', aliases=('s',))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def set_command(
self, ctx, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
@@ -363,7 +363,7 @@ class Doc:
await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
@docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def delete_command(self, ctx, package_name: ValidPythonIdentifier):
"""
Removes the specified package from the database.
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index d1a0de89e..418297fc4 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -1,7 +1,10 @@
import logging
import re
+from typing import Optional, Union
-from discord import Colour, Member, Message
+import discord.errors
+from dateutil.relativedelta import relativedelta
+from discord import Colour, DMChannel, Member, Message, TextChannel
from discord.ext.commands import Bot
from bot.cogs.modlog import ModLog
@@ -35,31 +38,57 @@ class Filtering:
def __init__(self, bot: Bot):
self.bot = bot
+ _staff_mistake_str = "If you believe this was a mistake, please let staff know!"
self.filters = {
"filter_zalgo": {
"enabled": Filter.filter_zalgo,
"function": self._has_zalgo,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_zalgo,
+ "notification_msg": (
+ "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "
+ f"{_staff_mistake_str}"
+ )
},
"filter_invites": {
"enabled": Filter.filter_invites,
"function": self._has_invites,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_invites,
+ "notification_msg": (
+ f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
+ r"Our server rules can be found here: <https://pythondiscord.com/about/rules>"
+ )
},
"filter_domains": {
"enabled": Filter.filter_domains,
"function": self._has_urls,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_domains,
+ "notification_msg": (
+ f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}"
+ )
+ },
+ "watch_rich_embeds": {
+ "enabled": Filter.watch_rich_embeds,
+ "function": self._has_rich_embed,
+ "type": "watchlist",
+ "content_only": False,
},
"watch_words": {
"enabled": Filter.watch_words,
"function": self._has_watchlist_words,
- "type": "watchlist"
+ "type": "watchlist",
+ "content_only": True,
},
"watch_tokens": {
"enabled": Filter.watch_tokens,
"function": self._has_watchlist_tokens,
- "type": "watchlist"
+ "type": "watchlist",
+ "content_only": True,
},
}
@@ -70,10 +99,14 @@ class Filtering:
async def on_message(self, msg: Message):
await self._filter_message(msg)
- async def on_message_edit(self, _: Message, after: Message):
- await self._filter_message(after)
+ async def on_message_edit(self, before: Message, after: Message):
+ if not before.edited_at:
+ delta = relativedelta(after.edited_at, before.created_at).microseconds
+ else:
+ delta = None
+ await self._filter_message(after, delta)
- async def _filter_message(self, msg: Message):
+ async def _filter_message(self, msg: Message, delta: Optional[int] = None):
"""
Whenever a message is sent or edited,
run it through our filters to see if it
@@ -102,22 +135,77 @@ class Filtering:
# If none of the above, we can start filtering.
if filter_message:
for filter_name, _filter in self.filters.items():
-
# Is this specific filter enabled in the config?
if _filter["enabled"]:
- triggered = await _filter["function"](msg.content)
+ # Double trigger check for the embeds filter
+ if filter_name == "watch_rich_embeds":
+ # If the edit delta is less than 0.001 seconds, then we're probably dealing
+ # with a double filter trigger.
+ if delta is not None and delta < 100:
+ return
+
+ # Does the filter only need the message content or the full message?
+ if _filter["content_only"]:
+ triggered = await _filter["function"](msg.content)
+ else:
+ triggered = await _filter["function"](msg)
if triggered:
+ # If this is a filter (not a watchlist), we should delete the message.
+ if _filter["type"] == "filter":
+ try:
+ # Embeds (can?) trigger both the `on_message` and `on_message_edit`
+ # event handlers, triggering filtering twice for the same message.
+ #
+ # If `on_message`-triggered filtering already deleted the message
+ # then `on_message_edit`-triggered filtering will raise exception
+ # since the message no longer exists.
+ #
+ # In addition, to avoid sending two notifications to the user, the
+ # logs, and mod_alert, we return if the message no longer exists.
+ await msg.delete()
+ except discord.errors.NotFound:
+ return
+
+ # Notify the user if the filter specifies
+ if _filter["user_notification"]:
+ await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
+
+ if isinstance(msg.channel, DMChannel):
+ channel_str = "via DM"
+ else:
+ channel_str = f"in {msg.channel.mention}"
+
message = (
f"The {filter_name} {_filter['type']} was triggered "
f"by **{msg.author.name}#{msg.author.discriminator}** "
- f"(`{msg.author.id}`) in <#{msg.channel.id}> with [the "
+ f"(`{msg.author.id}`) {channel_str} with [the "
f"following message]({msg.jump_url}):\n\n"
f"{msg.content}"
)
log.debug(message)
+ additional_embeds = None
+ additional_embeds_msg = None
+
+ if filter_name == "filter_invites":
+ additional_embeds = []
+ for invite, data in triggered.items():
+ embed = discord.Embed(description=(
+ f"**Members:**\n{data['members']}\n"
+ f"**Active:**\n{data['active']}"
+ ))
+ embed.set_author(name=data["name"])
+ embed.set_thumbnail(url=data["icon"])
+ embed.set_footer(text=f"Guild Invite Code: {invite}")
+ additional_embeds.append(embed)
+ additional_embeds_msg = "For the following guild(s):"
+
+ elif filter_name == "watch_rich_embeds":
+ additional_embeds = msg.embeds
+ additional_embeds_msg = "With the following embed(s):"
+
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=Icons.filtering,
@@ -127,12 +215,10 @@ class Filtering:
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
ping_everyone=Filter.ping_everyone,
+ additional_embeds=additional_embeds,
+ additional_embeds_msg=additional_embeds_msg
)
- # If this is a filter (not a watchlist), we should delete the message.
- if _filter["type"] == "filter":
- await msg.delete()
-
break # We don't want multiple filters to trigger
@staticmethod
@@ -200,10 +286,12 @@ class Filtering:
return bool(re.search(ZALGO_RE, text))
- async def _has_invites(self, text: str) -> bool:
+ async def _has_invites(self, text: str) -> Union[dict, bool]:
"""
- Returns True if the text contains an invite which is not on the guild_invite_whitelist in
- config.yml
+ Checks if there's any invites in the text content that aren't in the guild whitelist.
+
+ If any are detected, a dictionary of invite data is returned, with a key per invite.
+ If none are detected, False is returned.
Attempts to catch some of common ways to try to cheat the system.
"""
@@ -213,10 +301,13 @@ class Filtering:
text = text.replace("\\", "")
invites = re.findall(INVITE_RE, text, re.IGNORECASE)
+ invite_data = dict()
for invite in invites:
+ if invite in invite_data:
+ continue
response = await self.bot.http_session.get(
- f"{URLs.discord_invite_api}/{invite}"
+ f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"}
)
response = await response.json()
guild = response.get("guild")
@@ -229,9 +320,45 @@ class Filtering:
guild_id = int(guild.get("id"))
if guild_id not in Filter.guild_invite_whitelist:
- return True
+ guild_icon_hash = guild["icon"]
+ guild_icon = (
+ "https://cdn.discordapp.com/icons/"
+ f"{guild_id}/{guild_icon_hash}.png?size=512"
+ )
+
+ invite_data[invite] = {
+ "name": guild["name"],
+ "icon": guild_icon,
+ "members": response["approximate_member_count"],
+ "active": response["approximate_presence_count"]
+ }
+
+ return invite_data if invite_data else False
+
+ @staticmethod
+ async def _has_rich_embed(msg: Message):
+ """
+ Returns True if any of the embeds in the message are of type 'rich', but are not twitter
+ embeds. Returns False otherwise.
+ """
+ if msg.embeds:
+ for embed in msg.embeds:
+ if embed.type == "rich" and (not embed.url or "twitter.com" not in embed.url):
+ return True
return False
+ async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel):
+ """
+ Notify filtered_member about a moderation action with the reason str
+
+ First attempts to DM the user, fall back to in-channel notification if user has DMs disabled
+ """
+
+ try:
+ await filtered_member.send(reason)
+ except discord.errors.Forbidden:
+ await channel.send(f"{filtered_member.mention} {reason}")
+
def setup(bot: Bot):
bot.add_cog(Filtering(bot))
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
new file mode 100644
index 000000000..fd6009bb8
--- /dev/null
+++ b/bot/cogs/free.py
@@ -0,0 +1,106 @@
+import logging
+from datetime import datetime
+
+from discord import Colour, Embed, Member, utils
+from discord.ext.commands import Context, command
+
+from bot.constants import Categories, Channels, Free, STAFF_ROLES
+from bot.decorators import redirect_output
+
+
+log = logging.getLogger(__name__)
+
+TIMEOUT = Free.activity_timeout
+RATE = Free.cooldown_rate
+PER = Free.cooldown_per
+
+
+class Free:
+ """Tries to figure out which help channels are free."""
+
+ PYTHON_HELP_ID = Categories.python_help
+
+ @command(name="free", aliases=('f',))
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def free(self, ctx: Context, user: Member = None, seek: int = 2):
+ """
+ Lists free help channels by likeliness of availability.
+ :param user: accepts user mention, ID, etc.
+ :param seek: How far back to check the last active message.
+
+ seek is used only when this command is invoked in a help channel.
+ You cannot override seek without mentioning a user first.
+
+ When seek is 2, we are avoiding considering the last active message
+ in a channel to be the one that invoked this command.
+
+ When seek is 3 or more, a user has been mentioned on the assumption
+ that they asked if the channel is free or they asked their question
+ in an active channel, and we want the message before that happened.
+ """
+ free_channels = []
+ python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID)
+
+ if user is not None and seek == 2:
+ seek = 3
+ elif not 0 < seek < 10:
+ seek = 3
+
+ # Iterate through all the help channels
+ # to check latest activity
+ for channel in python_help.channels:
+ # Seek further back in the help channel
+ # the command was invoked in
+ if channel.id == ctx.channel.id:
+ messages = await channel.history(limit=seek).flatten()
+ msg = messages[seek-1]
+ # Otherwise get last message
+ else:
+ msg = await channel.history(limit=1).next() # noqa (False positive)
+
+ inactive = (datetime.utcnow() - msg.created_at).seconds
+ if inactive > TIMEOUT:
+ free_channels.append((inactive, channel))
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.title = "**Looking for a free help channel?**"
+
+ if user is not None:
+ embed.description = f"**Hey {user.mention}!**\n\n"
+ else:
+ embed.description = ""
+
+ # Display all potentially inactive channels
+ # in descending order of inactivity
+ if free_channels:
+ embed.description += "**The following channel{0} look{1} free:**\n\n**".format(
+ 's' if len(free_channels) > 1 else '',
+ '' if len(free_channels) > 1 else 's'
+ )
+
+ # Sort channels in descending order by seconds
+ # Get position in list, inactivity, and channel object
+ # For each channel, add to embed.description
+ for i, (inactive, channel) in enumerate(sorted(free_channels, reverse=True), 1):
+ minutes, seconds = divmod(inactive, 60)
+ if minutes > 59:
+ hours, minutes = divmod(minutes, 60)
+ embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n"
+ else:
+ embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n"
+
+ embed.description += ("**\nThese channels aren't guaranteed to be free, "
+ "so use your best judgement and check for yourself.")
+ else:
+ embed.description = ("**Doesn't look like any channels are available right now. "
+ "You're welcome to check for yourself to be sure. "
+ "If all channels are truly busy, please be patient "
+ "as one will likely be available soon.**")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(Free())
+ log.info("Cog loaded: Free")
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index d30ff0dfb..20ed08f07 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -6,14 +6,18 @@ from contextlib import suppress
from discord import Colour, Embed, HTTPException
from discord.ext import commands
+from discord.ext.commands import CheckFailure
from fuzzywuzzy import fuzz, process
from bot import constants
+from bot.constants import Channels, STAFF_ROLES
+from bot.decorators import redirect_output
from bot.pagination import (
DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI,
LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
)
+
REACTIONS = {
FIRST_EMOJI: 'first',
LEFT_EMOJI: 'back',
@@ -427,7 +431,15 @@ class HelpSession:
# see if the user can run the command
strikeout = ''
- can_run = await command.can_run(self._ctx)
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
if not can_run:
# skip if we don't show commands they can't run
if self._only_can_run:
@@ -642,6 +654,7 @@ class Help:
Custom Embed Pagination Help feature
"""
@commands.command('help')
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
async def new_help(self, ctx, *commands):
"""
Shows Command Help.
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 92b2444a3..a2585f395 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,17 +1,21 @@
import logging
+import random
import textwrap
from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import (
+ BadArgument, Bot, CommandError, Context, MissingPermissions, command
+)
-from bot.constants import Emojis, Keys, Roles
+from bot.constants import (
+ Channels, Emojis, Keys, MODERATION_ROLES, NEGATIVE_REPLIES, STAFF_ROLES
+)
from bot.decorators import with_role
+from bot.utils.checks import with_role_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
-MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
-
class Information:
"""
@@ -121,13 +125,26 @@ class Information:
await ctx.send(embed=embed)
- @with_role(*MODERATION_ROLES)
@command(name="user", aliases=["user_info", "member", "member_info"])
async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False):
"""
Returns info about a user.
"""
+ # Do a role check if this is being executed on
+ # someone other than the caller
+ if user and user != ctx.author:
+ if not with_role_check(ctx, *MODERATION_ROLES):
+ raise BadArgument("You do not have permission to use this command on users other than yourself.")
+
+ # Non-moderators may only do this in #bot-commands and can't see
+ # hidden infractions.
+ if not with_role_check(ctx, *STAFF_ROLES):
+ if not ctx.channel.id == Channels.bot:
+ raise MissingPermissions("You can't do that here!")
+ # Hide hidden infractions for users without a moderation role
+ hidden = False
+
# Validates hidden input
hidden = str(hidden)
@@ -192,6 +209,23 @@ class Information:
await ctx.send(embed=embed)
+ @user_info.error
+ async def user_info_command_error(self, ctx: Context, error: CommandError):
+ embed = Embed(colour=Colour.red())
+
+ if isinstance(error, BadArgument):
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = str(error)
+ await ctx.send(embed=embed)
+
+ elif isinstance(error, MissingPermissions):
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>."
+ await ctx.send(embed=embed)
+
+ else:
+ log.exception(f"Unhandled error: {error}")
+
def setup(bot):
bot.add_cog(Information(bot))
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
new file mode 100644
index 000000000..96b98e559
--- /dev/null
+++ b/bot/cogs/jams.py
@@ -0,0 +1,110 @@
+import logging
+
+from discord import Member, PermissionOverwrite, utils
+from discord.ext import commands
+
+from bot.constants import Roles
+from bot.decorators import with_role
+
+log = logging.getLogger(__name__)
+
+
+class CodeJams:
+ """
+ Manages the code-jam related parts of our server
+ """
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ @with_role(Roles.admin)
+ async def createteam(
+ self, ctx: commands.Context,
+ team_name: str, members: commands.Greedy[Member]
+ ):
+ """
+ Create a team channel (both voice and text) in the Code Jams category, assign roles
+ and then add overwrites for the team.
+
+ The first user passed will always be the team leader.
+ """
+
+ # We had a little issue during Code Jam 4 here, the greedy converter did it's job
+ # and ignored anything which wasn't a valid argument which left us with teams of
+ # two members or at some times even 1 member. This fixes that by checking that there
+ # are always 3 members in the members list.
+ if len(members) < 3:
+ await ctx.send(":no_entry_sign: One of your arguments was invalid - there must be a "
+ f"minimum of 3 valid members in your team. Found: {len(members)} "
+ "members")
+ return
+
+ code_jam_category = utils.get(ctx.guild.categories, name="Code Jam")
+
+ if code_jam_category is None:
+ log.info("Code Jam category not found, creating it.")
+
+ category_overwrites = {
+ ctx.guild.default_role: PermissionOverwrite(read_messages=False),
+ ctx.guild.me: PermissionOverwrite(read_messages=True)
+ }
+
+ code_jam_category = await ctx.guild.create_category_channel(
+ "Code Jam",
+ overwrites=category_overwrites,
+ reason="It's code jam time!"
+ )
+
+ # First member is always the team leader
+ team_channel_overwrites = {
+ members[0]: PermissionOverwrite(
+ manage_messages=True,
+ read_messages=True,
+ manage_webhooks=True,
+ connect=True
+ ),
+ ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
+ ctx.guild.get_role(Roles.developer): PermissionOverwrite(
+ read_messages=False,
+ connect=False
+ )
+ }
+
+ # Rest of members should just have read_messages
+ for member in members[1:]:
+ team_channel_overwrites[member] = PermissionOverwrite(
+ read_messages=True,
+ connect=True
+ )
+
+ # Create a text channel for the team
+ team_channel = await ctx.guild.create_text_channel(
+ team_name,
+ overwrites=team_channel_overwrites,
+ category=code_jam_category
+ )
+
+ # Create a voice channel for the team
+ team_voice_name = " ".join(team_name.split("-")).title()
+
+ await ctx.guild.create_voice_channel(
+ team_voice_name,
+ overwrites=team_channel_overwrites,
+ category=code_jam_category
+ )
+
+ # Assign team leader role
+ await members[0].add_roles(ctx.guild.get_role(Roles.team_leader))
+
+ # Assign rest of roles
+ jammer_role = ctx.guild.get_role(Roles.jammer)
+ for member in members:
+ await member.add_roles(jammer_role)
+
+ await ctx.send(f":ok_hand: Team created: {team_channel.mention}")
+
+
+def setup(bot):
+ bot.add_cog(CodeJams(bot))
+ log.info("Cog loaded: CodeJams")
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index 256d38866..1dc2c70d6 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -13,7 +13,7 @@ from discord.ext.commands import (
from bot import constants
from bot.cogs.modlog import ModLog
-from bot.constants import Colours, Event, Icons, Roles
+from bot.constants import Colours, Event, Icons, MODERATION_ROLES
from bot.converters import ExpirationDate, InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -23,7 +23,6 @@ from bot.utils.time import wait_until
log = logging.getLogger(__name__)
-MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
INFRACTION_ICONS = {
"Mute": Icons.user_mute,
"Kick": Icons.sign_out,
@@ -44,9 +43,12 @@ def proxy_user(user_id: str) -> Object:
return user
+UserTypes = Union[Member, User, proxy_user]
+
+
class Moderation(Scheduler):
"""
- Rowboat replacement moderation tools.
+ Server moderation tools.
"""
def __init__(self, bot: Bot):
@@ -63,34 +65,32 @@ class Moderation(Scheduler):
infractions = await self.bot.api_client.get(
'bot/infractions', params={'active': 'true'}
)
- loop = asyncio.get_event_loop()
for infraction in infractions:
if infraction["expires_at"] is not None:
- self.schedule_task(loop, infraction["id"], infraction)
+ self.schedule_task(self.bot.loop, infraction["id"], infraction)
# region: Permanent infractions
@with_role(*MODERATION_ROLES)
- @command(name="warn")
- async def warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ @command()
+ async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None):
"""
Create a warning infraction in the database for a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the warning.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the warning.
"""
+ response_object = await post_infraction(ctx, user, type="warning", reason=reason)
+ if response_object is None:
+ return
+
notified = await self.notify_infraction(
user=user,
infr_type="Warning",
reason=reason
)
- response_object = await post_infraction(
- ctx, user, type="warning", reason=reason
- )
- if response_object is None:
- return
-
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: warned {user.mention}"
@@ -99,10 +99,13 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "warning")
+ if notified:
+ dm_status = "Sent"
+ log_content = None
+ else:
+ dm_status = "**Failed**"
+ log_content = ctx.author.mention
- # Send a message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_warn,
colour=Colour(Colours.soft_red),
@@ -110,32 +113,46 @@ class Moderation(Scheduler):
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
+ Actor: {ctx.author}
+ DM: {dm_status}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="kick")
+ @command()
async def kick(self, ctx: Context, user: Member, *, reason: str = None):
"""
Kicks a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the kick.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the kick.
"""
+ if not await self.respect_role_hierarchy(ctx, user, 'kick'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
+ response_object = await post_infraction(ctx, user, type="kick", reason=reason)
+ if response_object is None:
+ return
+
notified = await self.notify_infraction(
user=user,
infr_type="Kick",
reason=reason
)
- response_object = await post_infraction(ctx, user, type="kick", reason=reason)
- if response_object is None:
- return
-
self.mod_log.ignore(Event.member_remove, user.id)
- await user.kick(reason=reason)
+
+ try:
+ await user.kick(reason=reason)
+ action_result = True
+ except Forbidden:
+ action_result = False
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: kicked {user.mention}"
@@ -145,31 +162,40 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "kick")
+ dm_status = "Sent" if notified else "**Failed**"
+ title = "Member kicked" if action_result else "Member kicked (Failed)"
+ log_content = None if all((notified, action_result)) else ctx.author.mention
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.sign_out,
colour=Colour(Colours.soft_red),
- title="Member kicked",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
+ DM: {dm_status}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="ban")
- async def ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ @command()
+ async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None):
"""
Create a permanent ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the ban.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the ban.
"""
+ if not await self.respect_role_hierarchy(ctx, user, 'ban'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
active_bans = await self.bot.api_client.get(
'bot/infractions',
params={
@@ -184,19 +210,24 @@ class Moderation(Scheduler):
f"See infraction **#{active_bans[0]['id']}**."
)
+ response_object = await post_infraction(ctx, user, type="ban", reason=reason)
+ if response_object is None:
+ return
+
notified = await self.notify_infraction(
user=user,
infr_type="Ban",
reason=reason
)
- response_object = await post_infraction(ctx, user, type="ban", reason=reason)
- if response_object is None:
- return
-
self.mod_log.ignore(Event.member_ban, user.id)
self.mod_log.ignore(Event.member_remove, user.id)
- await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+
+ try:
+ await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ action_result = True
+ except Forbidden:
+ action_result = False
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: permanently banned {user.mention}"
@@ -206,29 +237,35 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "ban")
+ dm_status = "Sent" if notified else "**Failed**"
+ log_content = None if all((notified, action_result)) else ctx.author.mention
+ title = "Member permanently banned"
+ if not action_result:
+ title += " (Failed)"
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_ban,
colour=Colour(Colours.soft_red),
- title="Member permanently banned",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
+ DM: {dm_status}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="mute")
+ @command()
async def mute(self, ctx: Context, user: Member, *, reason: str = None):
"""
Create a permanent mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the mute.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the mute.
"""
active_mutes = await self.bot.api_client.get(
@@ -245,19 +282,20 @@ class Moderation(Scheduler):
f"See infraction **#{active_mutes[0]['id']}**."
)
- notified = await self.notify_infraction(
- user=user, infr_type="Mute",
- expires_at="Permanent", reason=reason
- )
-
response_object = await post_infraction(ctx, user, type="mute", reason=reason)
if response_object is None:
return
- # add the mute role
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Mute",
+ expires_at="Permanent",
+ reason=reason
+ )
+
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: permanently muted {user.mention}"
@@ -266,10 +304,13 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "mute")
+ if notified:
+ dm_status = "Sent"
+ log_content = None
+ else:
+ dm_status = "**Failed**"
+ log_content = ctx.author.mention
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_mute,
colour=Colour(Colours.soft_red),
@@ -278,24 +319,28 @@ class Moderation(Scheduler):
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
+ DM: {dm_status}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
# endregion
# region: Temporary infractions
@with_role(*MODERATION_ROLES)
- @command(name="tempmute")
+ @command()
async def tempmute(
self, ctx: Context, user: Member, expiration: ExpirationDate,
*, reason: str = None
):
"""
Create a temporary mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary mute infraction
- :param reason: The reason for the temporary mute.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`duration`:** The duration for the temporary mute infraction
+ **`reason`:** The reason for the temporary mute.
"""
active_mutes = await self.bot.api_client.get(
@@ -312,11 +357,6 @@ class Moderation(Scheduler):
f"See infraction **#{active_mutes[0]['id']}**."
)
- notified = await self.notify_infraction(
- user=user, infr_type="Mute",
- expires_at=expiration, reason=reason
- )
-
infraction = await post_infraction(
ctx, user,
type="mute", reason=reason,
@@ -326,14 +366,20 @@ class Moderation(Scheduler):
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Mute",
+ expires_at=expiration,
+ reason=reason
+ )
+
infraction_expiration = (
datetime
.fromisoformat(infraction["expires_at"][:-1])
.strftime('%c')
)
- loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction["id"], infraction)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}"
@@ -343,10 +389,13 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "mute")
+ if notified:
+ dm_status = "Sent"
+ log_content = None
+ else:
+ dm_status = "**Failed**"
+ log_content = ctx.author.mention
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_mute,
colour=Colour(Colours.soft_red),
@@ -355,24 +404,32 @@ class Moderation(Scheduler):
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
+ DM: {dm_status}
Reason: {reason}
Expires: {infraction_expiration}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="tempban")
+ @command()
async def tempban(
- self, ctx: Context, user: Union[User, proxy_user], expiry: ExpirationDate,
- *, reason: str = None
+ self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None
):
"""
Create a temporary ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param expiry: The duration for the temporary ban infraction
- :param reason: The reason for the temporary ban.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`expiry`:** The duration for the temporary ban infraction
+ **`reason`:** The reason for the temporary ban.
"""
+ if not await self.respect_role_hierarchy(ctx, user, 'tempban'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
active_bans = await self.bot.api_client.get(
'bot/infractions',
params={
@@ -387,11 +444,6 @@ class Moderation(Scheduler):
f"See infraction **#{active_bans[0]['id']}**."
)
- notified = await self.notify_infraction(
- user=user, infr_type="Ban",
- expires_at=expiry, reason=reason
- )
-
infraction = await post_infraction(
ctx, user, type="ban",
reason=reason, expires_at=expiry
@@ -399,10 +451,21 @@ class Moderation(Scheduler):
if infraction is None:
return
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Ban",
+ expires_at=expiry,
+ reason=reason
+ )
+
self.mod_log.ignore(Event.member_ban, user.id)
self.mod_log.ignore(Event.member_remove, user.id)
- guild: Guild = ctx.guild
- await guild.ban(user, reason=reason, delete_message_days=0)
+
+ try:
+ await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ action_result = True
+ except Forbidden:
+ action_result = False
infraction_expiration = (
datetime
@@ -410,8 +473,7 @@ class Moderation(Scheduler):
.strftime('%c')
)
- loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction["id"], infraction)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}"
@@ -421,33 +483,39 @@ class Moderation(Scheduler):
else:
await ctx.send(f"{action} ({reason}).")
- if not notified:
- await self.log_notify_failure(user, ctx.author, "ban")
+ dm_status = "Sent" if notified else "**Failed**"
+ log_content = None if all((notified, action_result)) else ctx.author.mention
+ title = "Member temporarily banned"
+ if not action_result:
+ title += " (Failed)"
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_ban,
colour=Colour(Colours.soft_red),
thumbnail=user.avatar_url_as(static_format="png"),
- title="Member temporarily banned",
+ title=title,
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
+ DM: {dm_status}
Reason: {reason}
Expires: {infraction_expiration}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {infraction['id']}"
)
# endregion
# region: Permanent shadow infractions
@with_role(*MODERATION_ROLES)
- @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note'])
- async def shadow_warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ @command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn'])
+ async def note(self, ctx: Context, user: UserTypes, *, reason: str = None):
"""
- Create a warning infraction in the database for a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the warning.
+ Create a private infraction note in the database for a user.
+
+ **`user`:** accepts user mention, ID, etc.
+ **`reason`:** The reason for the warning.
"""
response_object = await post_infraction(
@@ -458,123 +526,152 @@ class Moderation(Scheduler):
return
if reason is None:
- result_message = f":ok_hand: note added for {user.mention}."
+ await ctx.send(f":ok_hand: note added for {user.mention}.")
else:
- result_message = f":ok_hand: note added for {user.mention} ({reason})."
-
- await ctx.send(result_message)
+ await ctx.send(f":ok_hand: note added for {user.mention} ({reason}).")
- # Send a message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_warn,
colour=Colour(Colours.soft_red),
- title="Member shadow warned",
+ title="Member note added",
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- """)
+ """),
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick'])
+ @command(hidden=True, aliases=['shadowkick', 'skick'])
async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None):
"""
Kicks a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the kick.
+
+ **`user`:** accepts user mention, ID, etc.
+ **`reason`:** The reason for the kick.
"""
+ if not await self.respect_role_hierarchy(ctx, user, 'shadowkick'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
if response_object is None:
return
self.mod_log.ignore(Event.member_remove, user.id)
- await user.kick(reason=reason)
+
+ try:
+ await user.kick(reason=reason)
+ action_result = True
+ except Forbidden:
+ action_result = False
if reason is None:
- result_message = f":ok_hand: kicked {user.mention}."
+ await ctx.send(f":ok_hand: kicked {user.mention}.")
else:
- result_message = f":ok_hand: kicked {user.mention} ({reason})."
+ await ctx.send(f":ok_hand: kicked {user.mention} ({reason}).")
- await ctx.send(result_message)
+ title = "Member shadow kicked"
+ if action_result:
+ log_content = None
+ else:
+ log_content = ctx.author.mention
+ title += " (Failed)"
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.sign_out,
colour=Colour(Colours.soft_red),
- title="Member shadow kicked",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ @command(hidden=True, aliases=['shadowban', 'sban'])
+ async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None):
"""
Create a permanent ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the ban.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the ban.
"""
+ if not await self.respect_role_hierarchy(ctx, user, 'shadowban'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
if response_object is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
self.mod_log.ignore(Event.member_remove, user.id)
- await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+
+ try:
+ await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ action_result = True
+ except Forbidden:
+ action_result = False
if reason is None:
- result_message = f":ok_hand: permanently banned {user.mention}."
+ await ctx.send(f":ok_hand: permanently banned {user.mention}.")
else:
- result_message = f":ok_hand: permanently banned {user.mention} ({reason})."
+ await ctx.send(f":ok_hand: permanently banned {user.mention} ({reason}).")
- await ctx.send(result_message)
+ title = "Member permanently banned"
+ if action_result:
+ log_content = None
+ else:
+ log_content = ctx.author.mention
+ title += " (Failed)"
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_ban,
colour=Colour(Colours.soft_red),
- title="Member permanently banned",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="shadow_mute", hidden=True, aliases=['shadowmute', 'smute'])
+ @command(hidden=True, aliases=['shadowmute', 'smute'])
async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None):
"""
Create a permanent mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the mute.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`reason`:** The reason for the mute.
"""
response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
if response_object is None:
return
- # add the mute role
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
if reason is None:
- result_message = f":ok_hand: permanently muted {user.mention}."
+ await ctx.send(f":ok_hand: permanently muted {user.mention}.")
else:
- result_message = f":ok_hand: permanently muted {user.mention} ({reason})."
+ await ctx.send(f":ok_hand: permanently muted {user.mention} ({reason}).")
- await ctx.send(result_message)
-
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_mute,
colour=Colour(Colours.soft_red),
@@ -584,23 +681,29 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- """)
+ """),
+ footer=f"ID {response_object['infraction']['id']}"
)
# endregion
# region: Temporary shadow infractions
@with_role(*MODERATION_ROLES)
- @command(name="shadow_tempmute", hidden=True, aliases=["shadowtempmute, stempmute"])
- async def shadow_tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None):
+ @command(hidden=True, aliases=["shadowtempmute, stempmute"])
+ async def shadow_tempmute(
+ self, ctx: Context, user: Member, duration: str, *, reason: str = None
+ ):
"""
Create a temporary mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary mute infraction
- :param reason: The reason for the temporary mute.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`duration`:** The duration for the temporary mute infraction
+ **`reason`:** The reason for the temporary mute.
"""
- response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration, hidden=True)
+ response_object = await post_infraction(
+ ctx, user, type="mute", reason=reason, duration=duration, hidden=True
+ )
if response_object is None:
return
@@ -610,17 +713,15 @@ class Moderation(Scheduler):
infraction_object = response_object["infraction"]
infraction_expiration = infraction_object["expires_at"]
- loop = asyncio.get_event_loop()
- self.schedule_expiration(loop, infraction_object)
+ self.schedule_expiration(ctx.bot.loop, infraction_object)
if reason is None:
- result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}."
+ await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.")
else:
- result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})."
-
- await ctx.send(result_message)
+ await ctx.send(
+ f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})."
+ )
- # Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_mute,
colour=Colour(Colours.soft_red),
@@ -632,67 +733,89 @@ class Moderation(Scheduler):
Reason: {reason}
Duration: {duration}
Expires: {infraction_expiration}
- """)
+ """),
+ footer=f"ID {response_object['infraction']['id']}"
)
@with_role(*MODERATION_ROLES)
- @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"])
+ @command(hidden=True, aliases=["shadowtempban, stempban"])
async def shadow_tempban(
- self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None
+ self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None
):
"""
Create a temporary ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary ban infraction
- :param reason: The reason for the temporary ban.
+
+ **`user`:** Accepts user mention, ID, etc.
+ **`duration`:** The duration for the temporary ban infraction
+ **`reason`:** The reason for the temporary ban.
"""
- response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration, hidden=True)
+ if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'):
+ # Ensure ctx author has a higher top role than the target user
+ # Warning is sent to ctx by the helper method
+ return
+
+ response_object = await post_infraction(
+ ctx, user, type="ban", reason=reason, duration=duration, hidden=True
+ )
if response_object is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
self.mod_log.ignore(Event.member_remove, user.id)
- guild: Guild = ctx.guild
- await guild.ban(user, reason=reason, delete_message_days=0)
+
+ try:
+ await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ action_result = True
+ except Forbidden:
+ action_result = False
infraction_object = response_object["infraction"]
infraction_expiration = infraction_object["expires_at"]
- loop = asyncio.get_event_loop()
- self.schedule_expiration(loop, infraction_object)
+ self.schedule_expiration(ctx.bot.loop, infraction_object)
if reason is None:
- result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}."
+ await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.")
else:
- result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})."
+ await ctx.send(
+ f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})."
+ )
- await ctx.send(result_message)
+ title = "Member temporarily banned"
+ if action_result:
+ log_content = None
+ else:
+ log_content = ctx.author.mention
+ title += " (Failed)"
# Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_ban,
colour=Colour(Colours.soft_red),
thumbnail=user.avatar_url_as(static_format="png"),
- title="Member temporarily banned",
+ title=title,
text=textwrap.dedent(f"""
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
Duration: {duration}
Expires: {infraction_expiration}
- """)
+ """),
+ content=log_content,
+ footer=f"ID {response_object['infraction']['id']}"
)
# endregion
# region: Remove infractions (un- commands)
@with_role(*MODERATION_ROLES)
- @command(name="unmute")
+ @command()
async def unmute(self, ctx: Context, user: Member):
"""
Deactivates the active mute infraction for a user.
- :param user: Accepts user mention, ID, etc.
+
+ **`user`:** Accepts user mention, ID, etc.
"""
try:
@@ -710,8 +833,9 @@ class Moderation(Scheduler):
if not response:
# no active infraction
- await ctx.send(f":x: There is no active mute infraction for user {user.mention}.")
- return
+ return await ctx.send(
+ f":x: There is no active mute infraction for user {user.mention}."
+ )
infraction = response[0]
await self._deactivate_infraction(infraction)
@@ -725,11 +849,16 @@ class Moderation(Scheduler):
icon_url=Icons.user_unmute
)
- dm_result = ":incoming_envelope: " if notified else ""
- await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.")
+ if notified:
+ dm_status = "Sent"
+ dm_emoji = ":incoming_envelope: "
+ log_content = None
+ else:
+ dm_status = "**Failed**"
+ dm_emoji = ""
+ log_content = ctx.author.mention
- if not notified:
- await self.log_notify_failure(user, ctx.author, "unmute")
+ await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.")
# Send a log message to the mod log
await self.mod_log.send_log_message(
@@ -741,19 +870,22 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Intended expiry: {infraction['expires_at']}
- """)
+ DM: {dm_status}
+ """),
+ footer=infraction["id"],
+ content=log_content
)
except Exception:
log.exception("There was an error removing an infraction.")
await ctx.send(":x: There was an error removing the infraction.")
- return
@with_role(*MODERATION_ROLES)
- @command(name="unban")
- async def unban(self, ctx: Context, user: Union[User, proxy_user]):
+ @command()
+ async def unban(self, ctx: Context, user: UserTypes):
"""
Deactivates the active ban infraction for a user.
- :param user: Accepts user mention, ID, etc.
+
+ **`user`:** Accepts user mention, ID, etc.
"""
try:
@@ -774,8 +906,9 @@ class Moderation(Scheduler):
if not response:
# no active infraction
- await ctx.send(f":x: There is no active ban infraction for user {user.mention}.")
- return
+ return await ctx.send(
+ f":x: There is no active ban infraction for user {user.mention}."
+ )
infraction = response[0]
await self._deactivate_infraction(infraction)
@@ -799,7 +932,6 @@ class Moderation(Scheduler):
except Exception:
log.exception("There was an error removing an infraction.")
await ctx.send(":x: There was an error removing the infraction.")
- return
# endregion
# region: Edit infraction commands
@@ -826,8 +958,9 @@ class Moderation(Scheduler):
):
"""
Sets the duration of the given infraction, relative to the time of updating.
- :param infraction_id: the id of the infraction
- :param expires_at: the new expiration date of the infraction.
+
+ **`infraction_id`:** the id of the infraction
+ **`expires_at`:** the new expiration date of the infraction.
Use "permanent" to mark the infraction as permanent.
"""
@@ -869,7 +1002,10 @@ class Moderation(Scheduler):
.fromisoformat(infraction['expires_at'][:-1])
.strftime('%c')
)
- await ctx.send(f":ok_hand: Updated infraction: set to expire on {human_expiry}.")
+ await ctx.send(
+ ":ok_hand: Updated infraction: set to expire on "
+ f"{human_expiry}."
+ )
except Exception:
log.exception("There was an error updating an infraction.")
@@ -910,8 +1046,8 @@ class Moderation(Scheduler):
async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str):
"""
Sets the reason of the given infraction.
- :param infraction_id: the id of the infraction
- :param reason: The new reason of the infraction
+ **`infraction_id`:** the id of the infraction
+ **`reason`:** The new reason of the infraction
"""
try:
@@ -927,8 +1063,7 @@ class Moderation(Scheduler):
except Exception:
log.exception("There was an error updating an infraction.")
- await ctx.send(":x: There was an error updating the infraction.")
- return
+ return await ctx.send(":x: There was an error updating the infraction.")
# Get information about the infraction's user
user_id = updated_infraction['user']
@@ -1037,6 +1172,7 @@ class Moderation(Scheduler):
def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict):
"""
Schedules a task to expire a temporary infraction.
+
:param loop: the asyncio event loop
:param infraction_object: the infraction object to expire at the end of the task
"""
@@ -1065,9 +1201,10 @@ class Moderation(Scheduler):
async def _scheduled_task(self, infraction_object: dict):
"""
- A co-routine which marks an infraction as expired after the delay from the time of scheduling
- to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website,
- and the expiration task is cancelled.
+ A co-routine which marks an infraction as expired after the delay from the time of
+ scheduling to the time of expiration. At the time of expiration, the infraction is
+ marked as inactive on the website, and the expiration task is cancelled.
+
:param infraction_object: the infraction in question
"""
@@ -1096,6 +1233,7 @@ class Moderation(Scheduler):
"""
A co-routine which marks an infraction as inactive on the website.
This co-routine does not cancel or un-schedule an expiration task.
+
:param infraction_object: the infraction in question
"""
@@ -1180,7 +1318,8 @@ class Moderation(Scheduler):
return await self.send_private_embed(user, embed)
async def notify_pardon(
- self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified
+ self, user: Union[User, Member], title: str, content: str,
+ icon_url: str = Icons.user_verified
):
"""
Notify a user that an infraction has been lifted.
@@ -1227,7 +1366,10 @@ class Moderation(Scheduler):
content=actor.mention,
colour=Colour(Colours.soft_red),
title="Notification Failed",
- text=f"Direct message was unable to be sent.\nUser: {target.mention}\nType: {infraction_type}"
+ text=(
+ f"Direct message was unable to be sent.\nUser: {target.mention}\n"
+ f"Type: {infraction_type}"
+ )
)
# endregion
@@ -1237,6 +1379,35 @@ class Moderation(Scheduler):
if User in error.converters:
await ctx.send(str(error.errors[0]))
+ async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool:
+ """
+ Check if the highest role of the invoking member is greater than that of the target member.
+ If this check fails, a warning is sent to the invoking ctx.
+
+ Returns True always if target is not a discord.Member instance.
+
+ :param ctx: The command context when invoked.
+ :param target: The target of the infraction.
+ :param infr_type: The type of infraction.
+ """
+
+ if not isinstance(target, Member):
+ return True
+
+ actor = ctx.author
+ target_is_lower = target.top_role < actor.top_role
+ if not target_is_lower:
+ log.info(
+ f"{actor} ({actor.id}) attempted to {infr_type} "
+ f"{target} ({target.id}), who has an equal or higher top role."
+ )
+ await ctx.send(
+ f":x: {actor.mention}, you may not {infr_type} "
+ "someone with an equal or higher top role."
+ )
+
+ return target_is_lower
+
def setup(bot):
bot.add_cog(Moderation(bot))
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index a3876bab7..9f0c88424 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -76,9 +76,20 @@ class ModLog:
self._ignored[event].append(item)
async def send_log_message(
- self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str,
- thumbnail: str = None, channel_id: int = Channels.modlog, ping_everyone: bool = False,
- files: List[File] = None, content: str = None
+ self,
+ icon_url: Optional[str],
+ colour: Colour,
+ title: Optional[str],
+ text: str,
+ thumbnail: Optional[str] = None,
+ channel_id: int = Channels.modlog,
+ ping_everyone: bool = False,
+ files: Optional[List[File]] = None,
+ content: Optional[str] = None,
+ additional_embeds: Optional[List[Embed]] = None,
+ additional_embeds_msg: Optional[str] = None,
+ timestamp_override: Optional[datetime] = None,
+ footer: Optional[str] = None,
):
embed = Embed(description=text)
@@ -86,9 +97,12 @@ class ModLog:
embed.set_author(name=title, icon_url=icon_url)
embed.colour = colour
- embed.timestamp = datetime.utcnow()
+ embed.timestamp = timestamp_override or datetime.utcnow()
- if thumbnail is not None:
+ if footer:
+ embed.set_footer(text=footer)
+
+ if thumbnail:
embed.set_thumbnail(url=thumbnail)
if ping_everyone:
@@ -97,7 +111,16 @@ class ModLog:
else:
content = "@everyone"
- await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files)
+ channel = self.bot.get_channel(channel_id)
+ log_message = await channel.send(content=content, embed=embed, files=files)
+
+ if additional_embeds:
+ if additional_embeds_msg:
+ await channel.send(additional_embeds_msg)
+ for additional_embed in additional_embeds:
+ await channel.send(embed=additional_embed)
+
+ return await self.bot.get_context(log_message) # Optionally return for use with antispam
async def on_guild_channel_create(self, channel: GUILD_CHANNEL):
if channel.guild.id != GuildConstant.id:
@@ -148,6 +171,10 @@ class ModLog:
if before.guild.id != GuildConstant.id:
return
+ if before.id in self._ignored[Event.guild_channel_update]:
+ self._ignored[Event.guild_channel_update].remove(before.id)
+ return
+
diff = DeepDiff(before, after)
changes = []
done = []
@@ -327,7 +354,8 @@ class ModLog:
await self.send_log_message(
Icons.user_ban, Colour(Colours.soft_red),
"User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.modlog
)
async def on_member_join(self, member: Member):
@@ -335,7 +363,7 @@ class ModLog:
return
message = f"{member.name}#{member.discriminator} (`{member.id}`)"
- now = datetime.datetime.utcnow()
+ now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
message += "\n\n**Account age:** " + humanize_delta(difference)
@@ -346,7 +374,8 @@ class ModLog:
await self.send_log_message(
Icons.sign_in, Colour(Colours.soft_green),
"User joined", message,
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
async def on_member_remove(self, member: Member):
@@ -360,7 +389,8 @@ class ModLog:
await self.send_log_message(
Icons.sign_out, Colour(Colours.soft_red),
"User left", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
async def on_member_unban(self, guild: Guild, member: User):
@@ -374,7 +404,8 @@ class ModLog:
await self.send_log_message(
Icons.user_unban, Colour.blurple(),
"User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.modlog
)
async def on_member_update(self, before: Member, after: Member):
@@ -462,7 +493,8 @@ class ModLog:
await self.send_log_message(
Icons.user_update, Colour.blurple(),
"Member updated", message,
- thumbnail=after.avatar_url_as(static_format="png")
+ thumbnail=after.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
async def on_message_delete(self, message: Message):
@@ -605,14 +637,27 @@ class ModLog:
f"{after.clean_content}"
)
+ if before.edited_at:
+ # Message was previously edited, to assist with self-bot detection, use the edited_at
+ # datetime as the baseline and create a human-readable delta between this edit event
+ # and the last time the message was edited
+ timestamp = before.edited_at
+ delta = humanize_delta(relativedelta(after.edited_at, before.edited_at))
+ footer = f"Last edited {delta} ago"
+ else:
+ # Message was not previously edited, use the created_at datetime as the baseline, no
+ # delta calculation needed
+ timestamp = before.created_at
+ footer = None
+
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (Before)",
- before_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response,
+ channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer
)
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (After)",
- after_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response,
+ channel_id=Channels.message_log, timestamp_override=after.edited_at
)
async def on_raw_message_edit(self, event: RawMessageUpdateEvent):
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index b22926664..c0d2e5dc5 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from discord import Colour, Embed
from discord.ext.commands import BadArgument, Bot, Context, Converter, group
-from bot.constants import Channels, Keys, Roles
+from bot.constants import Channels, Keys, MODERATION_ROLES
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -19,7 +19,7 @@ class OffTopicName(Converter):
@staticmethod
async def convert(ctx: Context, argument: str):
- allowed_characters = ("-", "’", "'", "`")
+ allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
if not (2 <= len(argument) <= 96):
raise BadArgument("Channel name must be between 2 and 96 chars long")
@@ -30,11 +30,11 @@ class OffTopicName(Converter):
"alphanumeric characters, minus signs or apostrophes."
)
- elif not argument.islower():
- raise BadArgument("Channel name must be lowercase")
-
- # Replace some unusable apostrophe-like characters with "’".
- return argument.replace("'", "’").replace("`", "’")
+ # Replace invalid characters with unicode alternatives.
+ table = str.maketrans(
+ allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-'
+ )
+ return argument.translate(table)
async def update_names(bot: Bot, headers: dict):
@@ -48,9 +48,11 @@ async def update_names(bot: Bot, headers: dict):
"""
while True:
+ # Since we truncate the compute timedelta to seconds, we add one second to ensure
+ # we go past midnight in the `seconds_to_sleep` set below.
today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
next_midnight = today_at_midnight + timedelta(days=1)
- seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds
+ seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1
await asyncio.sleep(seconds_to_sleep)
channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
@@ -82,17 +84,17 @@ class OffTopicNames:
async def on_ready(self):
if self.updater_task is None:
coro = update_names(self.bot, self.headers)
- self.updater_task = await self.bot.loop.create_task(coro)
+ self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def otname_group(self, ctx):
"""Add or list items from the off-topic channel name rotation."""
await ctx.invoke(self.bot.get_command("help"), "otname")
@otname_group.command(name='add', aliases=('a',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def add_command(self, ctx, name: OffTopicName):
"""Adds a new off-topic name to the rotation."""
@@ -104,7 +106,7 @@ class OffTopicNames:
await ctx.send(":ok_hand:")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def delete_command(self, ctx, name: OffTopicName):
"""Removes a off-topic name from the rotation."""
@@ -116,7 +118,7 @@ class OffTopicNames:
await ctx.send(":ok_hand:")
@otname_group.command(name='list', aliases=('l',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def list_command(self, ctx):
"""
Lists all currently known off-topic channel names in a paginator.
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 952fa4682..b5bd26e3d 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from discord import Colour, Embed, TextChannel
from discord.ext.commands import Bot, Context, group
-from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, Roles
+from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES
from bot.converters import Subreddit
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -31,6 +31,9 @@ class Reddit:
self.prev_lengths = {}
self.last_ids = {}
+ self.new_posts_task = None
+ self.top_weekly_posts_task = None
+
async def fetch_posts(self, route: str, *, amount: int = 25, params=None):
"""
A helper method to fetch a certain amount of Reddit posts at a given route.
@@ -257,7 +260,7 @@ class Reddit:
time="week"
)
- @with_role(Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+ @with_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
async def subreddits_command(self, ctx: Context):
"""
@@ -280,8 +283,10 @@ class Reddit:
self.reddit_channel = self.bot.get_channel(Channels.reddit)
if self.reddit_channel is not None:
- self.bot.loop.create_task(self.poll_new_posts())
- self.bot.loop.create_task(self.poll_top_weekly_posts())
+ if self.new_posts_task is None:
+ self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts())
+ if self.top_weekly_posts_task is None:
+ self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts())
else:
log.warning("Couldn't locate a channel for subreddit relaying.")
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index fa1be307c..03ea00de8 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -9,17 +9,15 @@ from dateutil.relativedelta import relativedelta
from discord import Colour, Embed
from discord.ext.commands import Bot, Context, group
-from bot.constants import (
- Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles
-)
+from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
from bot.converters import ExpirationDate
from bot.pagination import LinePaginator
+from bot.utils.checks import without_role_check
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta, wait_until
log = logging.getLogger(__name__)
-STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
WHITELISTED_CHANNELS = (Channels.bot,)
MAXIMUM_REMINDERS = 5
@@ -157,8 +155,8 @@ class Reminders(Scheduler):
embed = Embed()
- # Make sure the reminder should actually be made.
- if ctx.author.top_role.id not in STAFF_ROLES:
+ # If the user is not staff, we need to verify whether or not to make a reminder at all.
+ if without_role_check(ctx, *STAFF_ROLES):
# If they don't have permission to set a reminder in this channel
if ctx.channel.id not in WHITELISTED_CHANNELS:
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index dee58ea76..37bf4f4ea 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -1,10 +1,11 @@
-import gettext
import logging
from discord import Colour, Embed
from discord.ext.commands import Bot, Context, group
-from bot.constants import URLs
+from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.decorators import redirect_output
+from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -93,46 +94,45 @@ class Site:
await ctx.send(embed=embed)
- @site_group.command(name="rules")
- async def site_rules(self, ctx: Context, *selection: int):
- """Info about the server's rules."""
-
- url = f"{URLs.site_schema}{URLs.site}/about/rules"
- full_rules = await self.bot.api_client.get(
- 'rules', params={'link_format': 'md'}
- )
- if selection:
- invalid_indices = tuple(
- pick
- for pick in selection
- if pick < 0 or pick >= len(full_rules)
+ @site_group.command(aliases=['r', 'rule'], name='rules')
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def site_rules(self, ctx: Context, *rules: int):
+ """
+ Provides a link to the `rules` endpoint of the website, or displays
+ specific rules, if they are requested.
+
+ **`ctx`:** The Discord message context
+ **`rules`:** The rules a user wants to get.
+ """
+ rules_embed = Embed(title='Rules', color=Colour.blurple())
+ rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules"
+
+ if not rules:
+ # Rules were not submitted. Return the default description.
+ rules_embed.description = (
+ "The rules and guidelines that apply to this community can be found on"
+ " our [rules page](https://pythondiscord.com/about/rules). We expect"
+ " all members of the community to have read and understood these."
)
- if invalid_indices:
- return await ctx.send(
- embed=Embed(
- title='Invalid rule indices',
- description=', '.join(map(str, invalid_indices)),
- colour=Colour.red()
- )
- )
- title = (
- gettext.ngettext("Rule", 'Rules', len(selection))
- + " " + ", ".join(map(str, selection))
- )
- else:
- title = "Full rules"
- selection = range(len(full_rules))
+ await ctx.send(embed=rules_embed)
+ return
- embed = Embed(title=title)
- embed.set_footer(text=url)
- embed.colour = Colour.blurple()
- embed.description = '\n'.join(
- f"**{pick}**: {full_rules[pick]}"
- for pick in selection
+ full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'})
+ invalid_indices = tuple(
+ pick
+ for pick in rules
+ if pick < 0 or pick >= len(full_rules)
)
- await ctx.send(embed=embed)
+ if invalid_indices:
+ indices = ', '.join(map(str, invalid_indices))
+ await ctx.send(f":x: Invalid rule indices {indices}")
+ return
+
+ final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules)
+
+ await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
def setup(bot):
diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py
deleted file mode 100644
index 8dee13dca..000000000
--- a/bot/cogs/snakes.py
+++ /dev/null
@@ -1,1216 +0,0 @@
-import asyncio
-import colorsys
-import logging
-import os
-import random
-import re
-import string
-import textwrap
-import urllib
-from functools import partial
-from io import BytesIO
-from typing import Any, Dict
-
-import aiohttp
-import async_timeout
-from PIL import Image, ImageDraw, ImageFont
-from discord import Colour, Embed, File, Member, Message, Reaction
-from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, group
-
-from bot.constants import ERROR_REPLIES, Keys, URLs
-from bot.converters import Snake
-from bot.decorators import locked
-from bot.utils.snakes import hatching, perlin, perlinsneks, sal
-
-
-log = logging.getLogger(__name__)
-
-
-# region: Constants
-# Color
-SNAKE_COLOR = 0x399600
-
-# Antidote constants
-SYRINGE_EMOJI = "\U0001F489" # :syringe:
-PILL_EMOJI = "\U0001F48A" # :pill:
-HOURGLASS_EMOJI = "\u231B" # :hourglass:
-CROSSBONES_EMOJI = "\u2620" # :skull_crossbones:
-ALEMBIC_EMOJI = "\u2697" # :alembic:
-TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole
-CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole
-BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole
-HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses
-EMPTY_UNICODE = "\u200b" # literally just an empty space
-
-ANTIDOTE_EMOJI = (
- SYRINGE_EMOJI,
- PILL_EMOJI,
- HOURGLASS_EMOJI,
- CROSSBONES_EMOJI,
- ALEMBIC_EMOJI,
-)
-
-# Quiz constants
-ANSWERS_EMOJI = {
- "a": "\U0001F1E6", # :regional_indicator_a: 🇦
- "b": "\U0001F1E7", # :regional_indicator_b: 🇧
- "c": "\U0001F1E8", # :regional_indicator_c: 🇨
- "d": "\U0001F1E9", # :regional_indicator_d: 🇩
-}
-
-ANSWERS_EMOJI_REVERSE = {
- "\U0001F1E6": "A", # :regional_indicator_a: 🇦
- "\U0001F1E7": "B", # :regional_indicator_b: 🇧
- "\U0001F1E8": "C", # :regional_indicator_c: 🇨
- "\U0001F1E9": "D", # :regional_indicator_d: 🇩
-}
-
-# Zzzen of pythhhon constant
-ZEN = """
-Beautiful is better than ugly.
-Explicit is better than implicit.
-Simple is better than complex.
-Complex is better than complicated.
-Flat is better than nested.
-Sparse is better than dense.
-Readability counts.
-Special cases aren't special enough to break the rules.
-Although practicality beats purity.
-Errors should never pass silently.
-Unless explicitly silenced.
-In the face of ambiguity, refuse the temptation to guess.
-There should be one-- and preferably only one --obvious way to do it.
-Now is better than never.
-Although never is often better than *right* now.
-If the implementation is hard to explain, it's a bad idea.
-If the implementation is easy to explain, it may be a good idea.
-"""
-
-# Max messages to train snake_chat on
-MSG_MAX = 100
-
-# get_snek constants
-URL = "https://en.wikipedia.org/w/api.php?"
-
-# snake guess responses
-INCORRECT_GUESS = (
- "Nope, that's not what it is.",
- "Not quite.",
- "Not even close.",
- "Terrible guess.",
- "Nnnno.",
- "Dude. No.",
- "I thought everyone knew this one.",
- "Guess you suck at snakes.",
- "Bet you feel stupid now.",
- "Hahahaha, no.",
- "Did you hit the wrong key?"
-)
-
-CORRECT_GUESS = (
- "**WRONG**. Wait, no, actually you're right.",
- "Yeah, you got it!",
- "Yep, that's exactly what it is.",
- "Uh-huh. Yep yep yep.",
- "Yeah that's right.",
- "Yup. How did you know that?",
- "Are you a herpetologist?",
- "Sure, okay, but I bet you can't pronounce it.",
- "Are you cheating?"
-)
-
-# snake card consts
-CARD = {
- "top": Image.open("bot/resources/snake_cards/card_top.png"),
- "frame": Image.open("bot/resources/snake_cards/card_frame.png"),
- "bottom": Image.open("bot/resources/snake_cards/card_bottom.png"),
- "backs": [
- Image.open(f"bot/resources/snake_cards/backs/{file}")
- for file in os.listdir("bot/resources/snake_cards/backs")
- ],
- "font": ImageFont.truetype("bot/resources/snake_cards/expressway.ttf", 20)
-}
-# endregion
-
-
-class Snakes:
- """
- Commands related to snakes. These were created by our
- community during the first code jam.
-
- More information can be found in the code-jam-1 repo.
-
- https://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1
- """
-
- wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL)
- valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp')
-
- def __init__(self, bot: Bot):
- self.active_sal = {}
- self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
-
- # region: Helper methods
- @staticmethod
- def _beautiful_pastel(hue):
- """
- Returns random bright pastels.
- """
-
- light = random.uniform(0.7, 0.85)
- saturation = 1
-
- rgb = colorsys.hls_to_rgb(hue, light, saturation)
- hex_rgb = ""
-
- for part in rgb:
- value = int(part * 0xFF)
- hex_rgb += f"{value:02x}"
-
- return int(hex_rgb, 16)
-
- @staticmethod
- def _generate_card(buffer: BytesIO, content: dict) -> BytesIO:
- """
- Generate a card from snake information.
-
- Written by juan and Someone during the first code jam.
- """
-
- snake = Image.open(buffer)
-
- # Get the size of the snake icon, configure the height of the image box (yes, it changes)
- icon_width = 347 # Hardcoded, not much i can do about that
- icon_height = int((icon_width / snake.width) * snake.height)
- frame_copies = icon_height // CARD['frame'].height + 1
- snake.thumbnail((icon_width, icon_height))
-
- # Get the dimensions of the final image
- main_height = icon_height + CARD['top'].height + CARD['bottom'].height
- main_width = CARD['frame'].width
-
- # Start creating the foreground
- foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0))
- foreground.paste(CARD['top'], (0, 0))
-
- # Generate the frame borders to the correct height
- for offset in range(frame_copies):
- position = (0, CARD['top'].height + offset * CARD['frame'].height)
- foreground.paste(CARD['frame'], position)
-
- # Add the image and bottom part of the image
- foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :(
- foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height))
-
- # Setup the background
- back = random.choice(CARD['backs'])
- back_copies = main_height // back.height + 1
- full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0))
-
- # Generate the tiled background
- for offset in range(back_copies):
- full_image.paste(back, (16, 16 + offset * back.height))
-
- # Place the foreground onto the final image
- full_image.paste(foreground, (0, 0), foreground)
-
- # Get the first two sentences of the info
- description = '.'.join(content['info'].split(".")[:2]) + '.'
-
- # Setup positioning variables
- margin = 36
- offset = CARD['top'].height + icon_height + margin
-
- # Create blank rectangle image which will be behind the text
- rectangle = Image.new(
- "RGBA",
- (main_width, main_height),
- (0, 0, 0, 0)
- )
-
- # Draw a semi-transparent rectangle on it
- rect = ImageDraw.Draw(rectangle)
- rect.rectangle(
- (margin, offset, main_width - margin, main_height - margin),
- fill=(63, 63, 63, 128)
- )
-
- # Paste it onto the final image
- full_image.paste(rectangle, (0, 0), mask=rectangle)
-
- # Draw the text onto the final image
- draw = ImageDraw.Draw(full_image)
- for line in textwrap.wrap(description, 36):
- draw.text([margin + 4, offset], line, font=CARD['font'])
- offset += CARD['font'].getsize(line)[1]
-
- # Get the image contents as a BufferIO object
- buffer = BytesIO()
- full_image.save(buffer, 'PNG')
- buffer.seek(0)
-
- return buffer
-
- @staticmethod
- def _snakify(message):
- """
- Sssnakifffiesss a sstring.
- """
-
- # Replace fricatives with exaggerated snake fricatives.
- simple_fricatives = [
- "f", "s", "z", "h",
- "F", "S", "Z", "H",
- ]
- complex_fricatives = [
- "th", "sh", "Th", "Sh"
- ]
-
- for letter in simple_fricatives:
- if letter.islower():
- message = message.replace(letter, letter * random.randint(2, 4))
- else:
- message = message.replace(letter, (letter * random.randint(2, 4)).title())
-
- for fricative in complex_fricatives:
- message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4))
-
- return message
-
- async def _fetch(self, session, url, params=None):
- """
- Asyncronous web request helper method.
- """
-
- if params is None:
- params = {}
-
- async with async_timeout.timeout(10):
- async with session.get(url, params=params) as response:
- return await response.json()
-
- def _get_random_long_message(self, messages, retries=10):
- """
- Fetch a message that's at least 3 words long,
- but only if it is possible to do so in retries
- attempts. Else, just return whatever the last
- message is.
- """
-
- long_message = random.choice(messages)
- if len(long_message.split()) < 3 and retries > 0:
- return self._get_random_long_message(
- messages,
- retries=retries - 1
- )
-
- return long_message
-
- async def _get_snek(self, name: str) -> Dict[str, Any]:
- """
- Goes online and fetches all the data from a wikipedia article
- about a snake. Builds a dict that the .get() method can use.
-
- Created by Ava and eivl.
-
- :param name: The name of the snake to get information for - omit for a random snake
- :return: A dict containing information on a snake
- """
-
- snake_info = {}
-
- async with aiohttp.ClientSession() as session:
- params = {
- 'format': 'json',
- 'action': 'query',
- 'list': 'search',
- 'srsearch': name,
- 'utf8': '',
- 'srlimit': '1',
- }
-
- json = await self._fetch(session, URL, params=params)
-
- # wikipedia does have a error page
- try:
- pageid = json["query"]["search"][0]["pageid"]
- except KeyError:
- # Wikipedia error page ID(?)
- pageid = 41118
- except IndexError:
- return None
-
- params = {
- 'format': 'json',
- 'action': 'query',
- 'prop': 'extracts|images|info',
- 'exlimit': 'max',
- 'explaintext': '',
- 'inprop': 'url',
- 'pageids': pageid
- }
-
- json = await self._fetch(session, URL, params=params)
-
- # constructing dict - handle exceptions later
- try:
- snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"]
- snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"]
- snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"]
- snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"]
- snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"]
- except KeyError:
- snake_info["error"] = True
-
- if snake_info["images"]:
- i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/'
- image_list = []
- map_list = []
- thumb_list = []
-
- # Wikipedia has arbitrary images that are not snakes
- banned = [
- 'Commons-logo.svg',
- 'Red%20Pencil%20Icon.png',
- 'distribution',
- 'The%20Death%20of%20Cleopatra%20arthur.jpg',
- 'Head%20of%20holotype',
- 'locator',
- 'Woma.png',
- '-map.',
- '.svg',
- 'ange.',
- 'Adder%20(PSF).png'
- ]
-
- for image in snake_info["images"]:
- # images come in the format of `File:filename.extension`
- file, sep, filename = image["title"].partition(':')
- filename = filename.replace(" ", "%20") # Wikipedia returns good data!
-
- if not filename.startswith('Map'):
- if any(ban in filename for ban in banned):
- pass
- else:
- image_list.append(f"{i_url}{filename}")
- thumb_list.append(f"{i_url}{filename}?width=100")
- else:
- map_list.append(f"{i_url}{filename}")
-
- snake_info["image_list"] = image_list
- snake_info["map_list"] = map_list
- snake_info["thumb_list"] = thumb_list
- snake_info["name"] = name
-
- match = self.wiki_brief.match(snake_info['extract'])
- info = match.group(1) if match else None
-
- if info:
- info = info.replace("\n", "\n\n") # Give us some proper paragraphs.
-
- snake_info["info"] = info
-
- return snake_info
-
- async def _get_snake_name(self) -> Dict[str, str]:
- """
- Gets a random snake name.
- :return: A random snake name, as a string.
- """
-
- response = await self.bot.http_session.get(URLs.site_names_api, headers=self.headers)
- name_data = await response.json()
-
- return name_data
-
- async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list):
- """
- Validate the answer using a reaction event loop
- :return:
- """
-
- def predicate(reaction, user):
- """
- Test if the the answer is valid and can be evaluated.
- """
- return (
- reaction.message.id == message.id # The reaction is attached to the question we asked.
- and user == ctx.author # It's the user who triggered the quiz.
- and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options.
- )
-
- for emoji in ANSWERS_EMOJI.values():
- await message.add_reaction(emoji)
-
- # Validate the answer
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate)
- except asyncio.TimeoutError:
- await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.")
- await message.clear_reactions()
- return
-
- if str(reaction.emoji) == ANSWERS_EMOJI[answer]:
- await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.")
- else:
- await ctx.send(
- f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**."
- )
-
- await message.clear_reactions()
- # endregion
-
- # region: Commands
- @group(name='snakes', aliases=('snake',), invoke_without_command=True)
- async def snakes_group(self, ctx: Context):
- """Commands from our first code jam."""
-
- await ctx.invoke(self.bot.get_command("help"), "snake")
-
- @bot_has_permissions(manage_messages=True)
- @snakes_group.command(name='antidote')
- @locked()
- async def antidote_command(self, ctx: Context):
- """
- Antidote - Can you create the antivenom before the patient dies?
-
- Rules: You have 4 ingredients for each antidote, you only have 10 attempts
- Once you synthesize the antidote, you will be presented with 4 markers
- Tick: This means you have a CORRECT ingredient in the CORRECT position
- Circle: This means you have a CORRECT ingredient in the WRONG position
- Cross: This means you have a WRONG ingredient in the WRONG position
-
- Info: The game automatically ends after 5 minutes inactivity.
- You should only use each ingredient once.
-
- This game was created by Lord Bisk and Runew0lf.
- """
-
- def predicate(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
-
- return (
- all((
- reaction_.message.id == board_id.id, # Reaction is on this message
- reaction_.emoji in ANTIDOTE_EMOJI, # Reaction is one of the pagination emotes
- user_.id != self.bot.user.id, # Reaction was not made by the Bot
- user_.id == ctx.author.id # Reaction was made by author
- ))
- )
-
- # Initialize variables
- antidote_tries = 0
- antidote_guess_count = 0
- antidote_guess_list = []
- guess_result = []
- board = []
- page_guess_list = []
- page_result_list = []
- win = False
-
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
-
- # Generate answer
- antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it
- random.shuffle(antidote_answer)
- antidote_answer.pop()
-
- # Begin initial board building
- for i in range(0, 10):
- page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}")
- page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}")
- board.append(f"`{i+1:02d}` "
- f"{page_guess_list[i]} - "
- f"{page_result_list[i]}")
- board.append(EMPTY_UNICODE)
- antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board))
- board_id = await ctx.send(embed=antidote_embed) # Display board
-
- # Add our player reactions
- for emoji in ANTIDOTE_EMOJI:
- await board_id.add_reaction(emoji)
-
- # Begin main game loop
- while not win and antidote_tries < 10:
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=300, check=predicate)
- except asyncio.TimeoutError:
- log.debug("Antidote timed out waiting for a reaction")
- break # We're done, no reactions for the last 5 minutes
-
- if antidote_tries < 10:
- if antidote_guess_count < 4:
- if reaction.emoji in ANTIDOTE_EMOJI:
- antidote_guess_list.append(reaction.emoji)
- antidote_guess_count += 1
-
- if antidote_guess_count == 4: # Guesses complete
- antidote_guess_count = 0
- page_guess_list[antidote_tries] = " ".join(antidote_guess_list)
-
- # Now check guess
- for i in range(0, len(antidote_answer)):
- if antidote_guess_list[i] == antidote_answer[i]:
- guess_result.append(TICK_EMOJI)
- elif antidote_guess_list[i] in antidote_answer:
- guess_result.append(BLANK_EMOJI)
- else:
- guess_result.append(CROSS_EMOJI)
- guess_result.sort()
- page_result_list[antidote_tries] = " ".join(guess_result)
-
- # Rebuild the board
- board = []
- for i in range(0, 10):
- board.append(f"`{i+1:02d}` "
- f"{page_guess_list[i]} - "
- f"{page_result_list[i]}")
- board.append(EMPTY_UNICODE)
-
- # Remove Reactions
- for emoji in antidote_guess_list:
- await board_id.remove_reaction(emoji, user)
-
- if antidote_guess_list == antidote_answer:
- win = True
-
- antidote_tries += 1
- guess_result = []
- antidote_guess_list = []
-
- antidote_embed.clear_fields()
- antidote_embed.add_field(name=f"{10 - antidote_tries} "
- f"guesses remaining",
- value="\n".join(board))
- # Redisplay the board
- await board_id.edit(embed=antidote_embed)
-
- # Winning / Ending Screen
- if win is True:
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
- antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif")
- antidote_embed.add_field(name=f"You have created the snake antidote!",
- value=f"The solution was: {' '.join(antidote_answer)}\n"
- f"You had {10 - antidote_tries} tries remaining.")
- await board_id.edit(embed=antidote_embed)
- else:
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
- antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif")
- antidote_embed.add_field(name=EMPTY_UNICODE,
- value=f"Sorry you didnt make the antidote in time.\n"
- f"The formula was {' '.join(antidote_answer)}")
- await board_id.edit(embed=antidote_embed)
-
- log.debug("Ending pagination and removing all reactions...")
- await board_id.clear_reactions()
-
- @snakes_group.command(name='draw')
- async def draw_command(self, ctx: Context):
- """
- Draws a random snek using Perlin noise
-
- Written by Momo and kel.
- Modified by juan and lemon.
- """
-
- with ctx.typing():
-
- # Generate random snake attributes
- width = random.randint(6, 10)
- length = random.randint(15, 22)
- random_hue = random.random()
- snek_color = self._beautiful_pastel(random_hue)
- text_color = self._beautiful_pastel((random_hue + 0.5) % 1)
- bg_color = (
- random.randint(32, 50),
- random.randint(32, 50),
- random.randint(50, 70),
- )
-
- # Get a snake idiom from the API
- response = await self.bot.http_session.get(URLs.site_idioms_api, headers=self.headers)
- text = await response.json()
-
- # Build and send the snek
- factory = perlin.PerlinNoiseFactory(dimension=1, octaves=2)
- image_frame = perlinsneks.create_snek_frame(
- factory,
- snake_width=width,
- snake_length=length,
- snake_color=snek_color,
- text=text,
- text_color=text_color,
- bg_color=bg_color
- )
- png_bytes = perlinsneks.frame_to_png_bytes(image_frame)
-
- file = File(png_bytes, filename='snek.png')
-
- await ctx.send(file=file)
-
- @snakes_group.command(name='get')
- @bot_has_permissions(manage_messages=True)
- @locked()
- async def get_command(self, ctx: Context, *, name: Snake = None):
- """
- Fetches information about a snake from Wikipedia.
- :param ctx: Context object passed from discord.py
- :param name: Optional, the name of the snake to get information for - omit for a random snake
-
- Created by Ava and eivl.
- """
-
- with ctx.typing():
- if name is None:
- name = await Snake.random()
-
- if isinstance(name, dict):
- data = name
- else:
- data = await self._get_snek(name)
-
- if data.get('error'):
- return await ctx.send('Could not fetch data from Wikipedia.')
-
- description = data["info"]
-
- # Shorten the description if needed
- if len(description) > 1000:
- description = description[:1000]
- last_newline = description.rfind("\n")
- if last_newline > 0:
- description = description[:last_newline]
-
- # Strip and add the Wiki link.
- if "fullurl" in data:
- description = description.strip("\n")
- description += f"\n\nRead more on [Wikipedia]({data['fullurl']})"
-
- # Build and send the embed.
- embed = Embed(
- title=data.get("title", data.get('name')),
- description=description,
- colour=0x59982F,
- )
-
- emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png'
- image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), emoji)
- embed.set_image(url=image)
-
- await ctx.send(embed=embed)
-
- @snakes_group.command(name='guess', aliases=('identify',))
- @locked()
- async def guess_command(self, ctx):
- """
- Snake identifying game!
-
- Made by Ava and eivl.
- Modified by lemon.
- """
-
- with ctx.typing():
-
- image = None
-
- while image is None:
- snakes = [await Snake.random() for _ in range(4)]
- snake = random.choice(snakes)
- answer = "abcd"[snakes.index(snake)]
-
- data = await self._get_snek(snake)
-
- image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), None)
-
- embed = Embed(
- title='Which of the following is the snake in the image?',
- description="\n".join(f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes),
- colour=SNAKE_COLOR
- )
- embed.set_image(url=image)
-
- guess = await ctx.send(embed=embed)
- options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes}
- await self._validate_answer(ctx, guess, answer, options)
-
- @snakes_group.command(name='hatch')
- async def hatch_command(self, ctx: Context):
- """
- Hatches your personal snake
-
- Written by Momo and kel.
- """
-
- # Pick a random snake to hatch.
- snake_name = random.choice(list(hatching.snakes.keys()))
- snake_image = hatching.snakes[snake_name]
-
- # Hatch the snake
- message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:..."))
- await asyncio.sleep(1)
-
- for stage in hatching.stages:
- hatch_embed = Embed(description=stage)
- await message.edit(embed=hatch_embed)
- await asyncio.sleep(1)
- await asyncio.sleep(1)
- await message.delete()
-
- # Build and send the embed.
- my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name))
- my_snake_embed.set_thumbnail(url=snake_image)
- my_snake_embed.set_footer(
- text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator)
- )
-
- await ctx.channel.send(embed=my_snake_embed)
-
- @snakes_group.command(name='movie')
- async def movie_command(self, ctx: Context):
- """
- Gets a random snake-related movie from OMDB.
-
- Written by Samuel.
- Modified by gdude.
- """
-
- url = "http://www.omdbapi.com/"
- page = random.randint(1, 27)
-
- response = await self.bot.http_session.get(
- url,
- params={
- "s": "snake",
- "page": page,
- "type": "movie",
- "apikey": Keys.omdb
- }
- )
- data = await response.json()
- movie = random.choice(data["Search"])["imdbID"]
-
- response = await self.bot.http_session.get(
- url,
- params={
- "i": movie,
- "apikey": Keys.omdb
- }
- )
- data = await response.json()
-
- embed = Embed(
- title=data["Title"],
- color=SNAKE_COLOR
- )
-
- del data["Response"], data["imdbID"], data["Title"]
-
- for key, value in data.items():
- if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"):
- continue
-
- if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}]
- rating = random.choice(value)
-
- if rating["Source"] != "Internet Movie Database":
- embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"])
-
- continue
-
- if key == "Poster":
- embed.set_image(url=value)
- continue
-
- elif key == "imdbRating":
- key = "IMDB Rating"
-
- elif key == "imdbVotes":
- key = "IMDB Votes"
-
- embed.add_field(name=key, value=value, inline=True)
-
- embed.set_footer(text="Data provided by the OMDB API")
-
- await ctx.channel.send(
- embed=embed
- )
-
- @snakes_group.command(name='quiz')
- @locked()
- async def quiz_command(self, ctx: Context):
- """
- Asks a snake-related question in the chat and validates the user's guess.
-
- This was created by Mushy and Cardium,
- and modified by Urthas and lemon.
- """
-
- # Prepare a question.
- response = await self.bot.http_session.get(URLs.site_quiz_api, headers=self.headers)
- question = await response.json()
- answer = question["answerkey"]
- options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()}
-
- # Build and send the embed.
- embed = Embed(
- color=SNAKE_COLOR,
- title=question["question"],
- description="\n".join(
- [f"**{key.upper()}**: {answer}" for key, answer in options.items()]
- )
- )
-
- quiz = await ctx.channel.send("", embed=embed)
- await self._validate_answer(ctx, quiz, answer, options)
-
- @snakes_group.command(name='name', aliases=('name_gen',))
- async def name_command(self, ctx: Context, *, name: str = None):
- """
- Slices the users name at the last vowel (or second last if the name
- ends with a vowel), and then combines it with a random snake name,
- which is sliced at the first vowel (or second if the name starts with
- a vowel).
-
- If the name contains no vowels, it just appends the snakename
- to the end of the name.
-
- Examples:
- lemon + anaconda = lemoconda
- krzsn + anaconda = krzsnconda
- gdude + anaconda = gduconda
- aperture + anaconda = apertuconda
- lucy + python = luthon
- joseph + taipan = joseipan
-
- This was written by Iceman, and modified for inclusion into the bot by lemon.
- """
-
- snake_name = await self._get_snake_name()
- snake_name = snake_name['name']
- snake_prefix = ""
-
- # Set aside every word in the snake name except the last.
- if " " in snake_name:
- snake_prefix = " ".join(snake_name.split()[:-1])
- snake_name = snake_name.split()[-1]
-
- # If no name is provided, use whoever called the command.
- if name:
- user_name = name
- else:
- user_name = ctx.author.display_name
-
- # Get the index of the vowel to slice the username at
- user_slice_index = len(user_name)
- for index, char in enumerate(reversed(user_name)):
- if index == 0:
- continue
- if char.lower() in "aeiouy":
- user_slice_index -= index
- break
-
- # Now, get the index of the vowel to slice the snake_name at
- snake_slice_index = 0
- for index, char in enumerate(snake_name):
- if index == 0:
- continue
- if char.lower() in "aeiouy":
- snake_slice_index = index + 1
- break
-
- # Combine!
- snake_name = snake_name[snake_slice_index:]
- user_name = user_name[:user_slice_index]
- result = f"{snake_prefix} {user_name}{snake_name}"
- result = string.capwords(result)
-
- # Embed and send
- embed = Embed(
- title="Snake name",
- description=f"Your snake-name is **{result}**",
- color=SNAKE_COLOR
- )
-
- return await ctx.send(embed=embed)
-
- @snakes_group.command(name='sal')
- @locked()
- async def sal_command(self, ctx: Context):
- """
- Play a game of Snakes and Ladders!
-
- Written by Momo and kel.
- Modified by lemon.
- """
-
- # check if there is already a game in this channel
- if ctx.channel in self.active_sal:
- await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.")
- return
-
- game = sal.SnakeAndLaddersGame(snakes=self, context=ctx)
- self.active_sal[ctx.channel] = game
-
- await game.open_game()
-
- @snakes_group.command(name='about')
- async def about_command(self, ctx: Context):
- """
- A command that shows an embed with information about the event,
- it's participants, and its winners.
- """
-
- contributors = [
- "<@!245270749919576066>",
- "<@!396290259907903491>",
- "<@!172395097705414656>",
- "<@!361708843425726474>",
- "<@!300302216663793665>",
- "<@!210248051430916096>",
- "<@!174588005745557505>",
- "<@!87793066227822592>",
- "<@!211619754039967744>",
- "<@!97347867923976192>",
- "<@!136081839474343936>",
- "<@!263560579770220554>",
- "<@!104749643715387392>",
- "<@!303940835005825024>",
- ]
-
- embed = Embed(
- title="About the snake cog",
- description=(
- "The features in this cog were created by members of the community "
- "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n"
- "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over "
- "48 hours. The staff then selected the best features from all the best teams, and made modifications "
- "to ensure they would all work together before integrating them into the community bot.\n\n"
- "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> "
- "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` "
- "and `!snakes hatch` to see what they came up with."
- )
- )
-
- embed.add_field(
- name="Contributors",
- value=(
- ", ".join(contributors)
- )
- )
-
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='card')
- async def card_command(self, ctx: Context, *, name: Snake = None):
- """
- Create an interesting little card from a snake!
-
- Created by juan and Someone during the first code jam.
- """
-
- # Get the snake data we need
- if not name:
- name_obj = await self._get_snake_name()
- name = name_obj['scientific']
- content = await self._get_snek(name)
-
- elif isinstance(name, dict):
- content = name
-
- else:
- content = await self._get_snek(name)
-
- # Make the card
- async with ctx.typing():
-
- stream = BytesIO()
- async with async_timeout.timeout(10):
- async with self.bot.http_session.get(content['image_list'][0]) as response:
- stream.write(await response.read())
-
- stream.seek(0)
-
- func = partial(self._generate_card, stream, content)
- final_buffer = await self.bot.loop.run_in_executor(None, func)
-
- # Send it!
- await ctx.send(
- f"A wild {content['name'].title()} appears!",
- file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png")
- )
-
- @snakes_group.command(name='fact')
- async def fact_command(self, ctx: Context):
- """
- Gets a snake-related fact
-
- Written by Andrew and Prithaj.
- Modified by lemon.
- """
-
- # Get a fact from the API.
- response = await self.bot.http_session.get(URLs.site_facts_api, headers=self.headers)
- question = await response.json()
-
- # Build and send the embed.
- embed = Embed(
- title="Snake fact",
- color=SNAKE_COLOR,
- description=question
- )
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='help')
- async def help_command(self, ctx: Context):
- """
- This just invokes the help command on this cog.
- """
-
- log.debug(f"{ctx.author} requested info about the snakes cog")
- return await ctx.invoke(self.bot.get_command("help"), "Snakes")
-
- @snakes_group.command(name='snakify')
- async def snakify_command(self, ctx: Context, *, message: str = None):
- """
- How would I talk if I were a snake?
- :param ctx: context
- :param message: If this is passed, it will snakify the message.
- If not, it will snakify a random message from
- the users history.
-
- Written by Momo and kel.
- Modified by lemon.
- """
-
- with ctx.typing():
- embed = Embed()
- user = ctx.message.author
-
- if not message:
-
- # Get a random message from the users history
- messages = []
- async for message in ctx.channel.history(limit=500).filter(
- lambda msg: msg.author == ctx.message.author # Message was sent by author.
- ):
- messages.append(message.content)
-
- message = self._get_random_long_message(messages)
-
- # Set the avatar
- if user.avatar is not None:
- avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}"
- else:
- avatar = ctx.author.default_avatar_url
-
- # Build and send the embed
- embed.set_author(
- name=f"{user.name}#{user.discriminator}",
- icon_url=avatar,
- )
- embed.description = f"*{self._snakify(message)}*"
-
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='video', aliases=('get_video',))
- async def video_command(self, ctx: Context, *, search: str = None):
- """
- Gets a YouTube video about snakes
- :param name: Optional, a name of a snake. Used to search for videos with that name
- :param ctx: Context object passed from discord.py
-
- Written by Andrew and Prithaj.
- """
-
- # Are we searching for anything specific?
- if search:
- query = search + ' snake'
- else:
- snake = await self._get_snake_name()
- query = snake['name']
-
- # Build the URL and make the request
- url = f'https://www.googleapis.com/youtube/v3/search'
- response = await self.bot.http_session.get(
- url,
- params={
- "part": "snippet",
- "q": urllib.parse.quote(query),
- "type": "video",
- "key": Keys.youtube
- }
- )
- response = await response.json()
- data = response['items']
-
- # Send the user a video
- if len(data) > 0:
- num = random.randint(0, len(data) - 1)
- youtube_base_url = 'https://www.youtube.com/watch?v='
- await ctx.channel.send(
- content=f"{youtube_base_url}{data[num]['id']['videoId']}"
- )
- else:
- log.warning(f"YouTube API error. Full response looks like {response}")
-
- @snakes_group.command(name='zen')
- async def zen_command(self, ctx: Context):
- """
- Gets a random quote from the Zen of Python,
- except as if spoken by a snake.
-
- Written by Prithaj and Andrew.
- Modified by lemon.
- """
-
- embed = Embed(
- title="Zzzen of Pythhon",
- color=SNAKE_COLOR
- )
-
- # Get the zen quote and snakify it
- zen_quote = random.choice(ZEN.splitlines())
- zen_quote = self._snakify(zen_quote)
-
- # Embed and send
- embed.description = zen_quote
- await ctx.channel.send(
- embed=embed
- )
- # endregion
-
- # region: Error handlers
- @get_command.error
- @card_command.error
- @video_command.error
- async def command_error(self, ctx, error):
-
- embed = Embed()
- embed.colour = Colour.red()
-
- if isinstance(error, BadArgument):
- embed.description = str(error)
- embed.title = random.choice(ERROR_REPLIES)
-
- elif isinstance(error, OSError):
- log.error(f"snake_card encountered an OSError: {error} ({error.original})")
- embed.description = "Could not generate the snake card! Please try again."
- embed.title = random.choice(ERROR_REPLIES)
-
- else:
- log.error(f"Unhandled tag command error: {error} ({error.original})")
- return
-
- await ctx.send(embed=embed)
- # endregion
-
-
-def setup(bot):
- bot.add_cog(Snakes(bot))
- log.info("Cog loaded: Snakes")
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 0f8d3e4b6..05834e421 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -11,7 +11,7 @@ from discord.ext.commands import (
Bot, CommandError, Context, NoPrivateMessage, command, guild_only
)
-from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs
+from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs
from bot.decorators import InChannelCheckFailure, in_channel
from bot.utils.messages import wait_for_deletion
@@ -37,7 +37,6 @@ RAW_CODE_REGEX = re.compile(
re.DOTALL # "." also matches newlines
)
-BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
MAX_PASTE_LEN = 1000
@@ -173,7 +172,7 @@ class Snekbox:
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
+ @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None):
"""
Run Python code and get the results.
diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py
index efa02cb43..4b26f3f40 100644
--- a/bot/cogs/superstarify/__init__.py
+++ b/bot/cogs/superstarify/__init__.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Bot, Context, command
from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.cogs.superstarify.stars import get_nick
-from bot.constants import Icons, POSITIVE_REPLIES, Roles
+from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES
from bot.converters import ExpirationDate
from bot.decorators import with_role
from bot.utils.moderation import post_infraction
@@ -150,7 +150,7 @@ class Superstarify:
)
@command(name='superstarify', aliases=('force_nick', 'star'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def superstarify(
self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None
):
@@ -221,7 +221,7 @@ class Superstarify:
await ctx.send(embed=embed)
@command(name='unsuperstarify', aliases=('release_nick', 'unstar'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def unsuperstarify(self, ctx: Context, member: Member):
"""
This command will remove the entry from our database, allowing the user
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index bb4d6ba71..7b1003148 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -4,7 +4,7 @@ import time
from discord import Colour, Embed
from discord.ext.commands import Bot, Context, group
-from bot.constants import Channels, Cooldowns, Keys, Roles
+from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles
from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -92,7 +92,7 @@ class Tags:
colour=Colour.red()
))
else:
- embed = Embed(title="**Current tags**")
+ embed: Embed = Embed(title="**Current tags**")
await LinePaginator.paginate(
sorted(f"**»** {tag['title']}" for tag in tags),
ctx,
@@ -103,7 +103,7 @@ class Tags:
)
@tags_group.command(name='set', aliases=('add', 'edit', 's'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def set_command(
self,
ctx: Context,
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 8277513a7..05298a2ff 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -16,9 +16,10 @@ log = logging.getLogger(__name__)
DELETION_MESSAGE_TEMPLATE = (
"Hey {mention}! I noticed you posted a seemingly valid Discord API "
- "token in your message and have removed your message to prevent abuse. "
- "We recommend regenerating your token regardless, which you can do here: "
- "<https://discordapp.com/developers/applications/me>\n"
+ "token in your message and have removed your message. "
+ "This means that your token has been **compromised**. "
+ "Please change your token **immediately** at: "
+ "<https://discordapp.com/developers/applications/me>\n\n"
"Feel free to re-post it with the token removed. "
"If you believe this was a mistake, please let us know!"
)
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 65c729414..0c6d9d2ba 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -8,13 +8,11 @@ from io import StringIO
from discord import Colour, Embed
from discord.ext.commands import AutoShardedBot, Context, command
-from bot.constants import Channels, NEGATIVE_REPLIES, Roles
+from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES
from bot.decorators import InChannelCheckFailure, in_channel
log = logging.getLogger(__name__)
-BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
-
class Utils:
"""
@@ -91,7 +89,7 @@ class Utils:
await ctx.message.channel.send(embed=pep_embed)
@command()
- @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
+ @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
async def charinfo(self, ctx, *, characters: str):
"""
Shows you information on up to 25 unicode characters.
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index c36ef6075..e8b16b243 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -8,7 +8,7 @@ from discord import Embed
from discord.ext import commands
from discord.ext.commands import BucketType, Context, check, group
-from bot.constants import Colours, Roles, Wolfram
+from bot.constants import Colours, STAFF_ROLES, Wolfram
from bot.pagination import ImagePaginator
log = logging.getLogger(__name__)
@@ -18,7 +18,6 @@ DEFAULT_OUTPUT_FORMAT = "JSON"
QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
-COOLDOWN_IGNORERS = Roles.moderator, Roles.owner, Roles.admin, Roles.helpers
MAX_PODS = 20
# Allows for 10 wolfram calls pr user pr day
@@ -75,7 +74,7 @@ def custom_cooldown(*ignore: List[int]) -> check:
async def predicate(ctx: Context) -> bool:
user_bucket = usercd.get_bucket(ctx.message)
- if ctx.author.top_role.id not in ignore:
+ if all(role.id not in ignore for role in ctx.author.roles):
user_rate = user_bucket.update_rate_limit()
if user_rate:
@@ -159,7 +158,7 @@ class Wolfram:
self.bot = bot
@group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_command(self, ctx: Context, *, query: str) -> None:
"""
Requests all answers on a single image,
@@ -201,7 +200,7 @@ class Wolfram:
await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
@wolfram_command.command(name="page", aliases=("pa", "p"))
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
"""
Requests a drawn image of given query
@@ -225,7 +224,7 @@ class Wolfram:
await ImagePaginator.paginate(pages, ctx, embed)
@wolfram_command.command(name="cut", aliases=("c",))
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_cut_command(self, ctx, *, query: str) -> None:
"""
Requests a drawn image of given query
@@ -248,7 +247,7 @@ class Wolfram:
await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
@wolfram_command.command(name="short", aliases=("sh", "s"))
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
"""
Requests an answer to a simple question
diff --git a/bot/constants.py b/bot/constants.py
index 82df8c6f0..ead26c91d 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -201,9 +201,15 @@ class Filter(metaclass=YAMLGetter):
filter_zalgo: bool
filter_invites: bool
filter_domains: bool
+ watch_rich_embeds: bool
watch_words: bool
watch_tokens: bool
+ # Notifications are not expected for "watchlist" type filters
+ notify_user_zalgo: bool
+ notify_user_invites: bool
+ notify_user_domains: bool
+
ping_everyone: bool
guild_invite_whitelist: List[int]
domain_blacklist: List[str]
@@ -310,6 +316,13 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+class Categories(metaclass=YAMLGetter):
+ section = "guild"
+ subsection = "categories"
+
+ python_help: int
+
+
class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
@@ -319,6 +332,7 @@ class Channels(metaclass=YAMLGetter):
big_brother_logs: int
bot: int
checkpoint_test: int
+ defcon: int
devlog: int
devtest: int
help_0: int
@@ -337,6 +351,8 @@ class Channels(metaclass=YAMLGetter):
python: int
reddit: int
talent_pool: int
+ userlog: int
+ user_event_a: int
verification: int
@@ -364,6 +380,7 @@ class Roles(metaclass=YAMLGetter):
owner: int
verified: int
helpers: int
+ team_leader: int
class Guild(metaclass=YAMLGetter):
@@ -378,9 +395,7 @@ class Keys(metaclass=YAMLGetter):
deploy_bot: str
deploy_site: str
- omdb: str
site_api: str
- youtube: str
class URLs(metaclass=YAMLGetter):
@@ -397,25 +412,19 @@ class URLs(metaclass=YAMLGetter):
bot_avatar: str
deploy: str
gitlab_bot_repo: str
- omdb: str
status: str
# Site endpoints
site: str
site_api: str
- site_facts_api: str
site_clean_api: str
site_superstarify_api: str
- site_idioms_api: str
site_logs_api: str
site_logs_view: str
- site_names_api: str
- site_quiz_api: str
site_reminders_api: str
site_reminders_user_api: str
site_schema: str
site_settings_api: str
- site_special_api: str
site_tags_api: str
site_user_api: str
site_user_complete_api: str
@@ -460,6 +469,21 @@ class BigBrother(metaclass=YAMLGetter):
header_message_limit: int
+class Free(metaclass=YAMLGetter):
+ section = 'free'
+
+ activity_timeout: int
+ cooldown_rate: int
+ cooldown_per: float
+
+
+class RedirectOutput(metaclass=YAMLGetter):
+ section = 'redirect_output'
+
+ delete_invocation: bool
+ delete_delay: int
+
+
# Debug mode
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
@@ -467,6 +491,11 @@ DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
BOT_DIR = os.path.dirname(__file__)
PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir))
+# Default role combinations
+MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
+STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
+
+
# Bot replies
NEGATIVE_REPLIES = [
"Noooooo!!",
@@ -483,7 +512,9 @@ NEGATIVE_REPLIES = [
"Not in a million years.",
"Fat chance.",
"Certainly not.",
- "NEGATORY."
+ "NEGATORY.",
+ "Nuh-uh.",
+ "Not in my house!",
]
POSITIVE_REPLIES = [
@@ -503,7 +534,7 @@ POSITIVE_REPLIES = [
"ROGER THAT",
"Of course!",
"Aye aye, cap'n!",
- "I'll allow it."
+ "I'll allow it.",
]
ERROR_REPLIES = [
@@ -515,7 +546,8 @@ ERROR_REPLIES = [
"You blew it.",
"You're bad at computers.",
"Are you trying to kill me?",
- "Noooooo!!"
+ "Noooooo!!",
+ "I can't believe you've done this",
]
diff --git a/bot/converters.py b/bot/converters.py
index 1100b502c..30ea7ca0f 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,119 +1,16 @@
import logging
-import random
-import socket
from datetime import datetime
from ssl import CertificateError
import dateparser
import discord
-from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector
+from aiohttp import ClientConnectorError
from discord.ext.commands import BadArgument, Context, Converter
-from fuzzywuzzy import fuzz
-
-from bot.constants import DEBUG_MODE, Keys, URLs
-from bot.utils import disambiguate
log = logging.getLogger(__name__)
-class Snake(Converter):
- snakes = None
- special_cases = None
-
- async def convert(self, ctx, name):
- await self.build_list()
- name = name.lower()
-
- if name == 'python':
- return 'Python (programming language)'
-
- def get_potential(iterable, *, threshold=80):
- nonlocal name
- potential = []
-
- for item in iterable:
- original, item = item, item.lower()
-
- if name == item:
- return [original]
-
- a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item)
- if a >= threshold or b >= threshold:
- potential.append(original)
-
- return potential
-
- # Handle special cases
- if name.lower() in self.special_cases:
- return self.special_cases.get(name.lower(), name.lower())
-
- names = {snake['name']: snake['scientific'] for snake in self.snakes}
- all_names = names.keys() | names.values()
- timeout = len(all_names) * (3 / 4)
-
- embed = discord.Embed(title='Found multiple choices. Please choose the correct one.', colour=0x59982F)
- embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url)
-
- name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed)
- return names.get(name, name)
-
- @classmethod
- async def build_list(cls):
-
- headers = {"X-API-KEY": Keys.site_api}
-
- # Set up the session
- if DEBUG_MODE:
- http_session = ClientSession(
- connector=TCPConnector(
- resolver=AsyncResolver(),
- family=socket.AF_INET,
- verify_ssl=False,
- )
- )
- else:
- http_session = ClientSession(
- connector=TCPConnector(
- resolver=AsyncResolver()
- )
- )
-
- # Get all the snakes
- if cls.snakes is None:
- response = await http_session.get(
- URLs.site_names_api,
- params={"get_all": "true"},
- headers=headers
- )
- cls.snakes = await response.json()
-
- # Get the special cases
- if cls.special_cases is None:
- response = await http_session.get(
- URLs.site_special_api,
- headers=headers
- )
- special_cases = await response.json()
- cls.special_cases = {snake['name'].lower(): snake for snake in special_cases}
-
- # Close the session
- http_session.close()
-
- @classmethod
- async def random(cls):
- """
- This is stupid. We should find a way to
- somehow get the global session into a
- global context, so I can get it from here.
- :return:
- """
-
- await cls.build_list()
- names = [snake['scientific'] for snake in cls.snakes]
- return random.choice(names)
-
-
class ValidPythonIdentifier(Converter):
"""
A converter that checks whether the given string is a valid Python identifier.
diff --git a/bot/decorators.py b/bot/decorators.py
index 87877ecbf..1ba2cd59e 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,15 +1,18 @@
import logging
import random
import typing
-from asyncio import Lock
+from asyncio import Lock, sleep
+from contextlib import suppress
from functools import wraps
from weakref import WeakValueDictionary
from discord import Colour, Embed
+from discord.errors import NotFound
from discord.ext import commands
from discord.ext.commands import CheckFailure, Context
-from bot.constants import ERROR_REPLIES
+from bot.constants import ERROR_REPLIES, RedirectOutput
+from bot.utils.checks import with_role_check, without_role_check
log = logging.getLogger(__name__)
@@ -47,35 +50,24 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None):
def with_role(*role_ids: int):
- async def predicate(ctx: Context):
- if not ctx.guild: # Return False in a DM
- log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
- "This command is restricted by the with_role decorator. Rejecting request.")
- return False
-
- for role in ctx.author.roles:
- if role.id in role_ids:
- log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.")
- return True
+ """
+ Returns True if the user has any one
+ of the roles in role_ids.
+ """
- log.debug(f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected.")
- return False
+ async def predicate(ctx: Context):
+ return with_role_check(ctx, *role_ids)
return commands.check(predicate)
def without_role(*role_ids: int):
- async def predicate(ctx: Context):
- if not ctx.guild: # Return False in a DM
- log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
- "This command is restricted by the without_role decorator. Rejecting request.")
- return False
+ """
+ Returns True if the user does not have any
+ of the roles in role_ids.
+ """
- author_roles = [role.id for role in ctx.author.roles]
- check = all(role not in author_roles for role in role_ids)
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the without_role check was {check}.")
- return check
+ async def predicate(ctx: Context):
+ return without_role_check(ctx, *role_ids)
return commands.check(predicate)
@@ -110,3 +102,47 @@ def locked():
return await func(self, ctx, *args, **kwargs)
return inner
return wrap
+
+
+def redirect_output(destination_channel: int, bypass_roles: typing.Container[int] = None):
+ """
+ Changes the channel in the context of the command to redirect the output
+ to a certain channel, unless the author has a role to bypass redirection
+ """
+
+ def wrap(func):
+ @wraps(func)
+ async def inner(self, ctx, *args, **kwargs):
+ if ctx.channel.id == destination_channel:
+ log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting")
+ return await func(self, ctx, *args, **kwargs)
+
+ if bypass_roles and any(role.id in bypass_roles for role in ctx.author.roles):
+ log.trace(f"{ctx.author} has role to bypass output redirection")
+ return await func(self, ctx, *args, **kwargs)
+
+ redirect_channel = ctx.guild.get_channel(destination_channel)
+ old_channel = ctx.channel
+
+ log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
+ ctx.channel = redirect_channel
+ await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
+ await func(self, ctx, *args, **kwargs)
+
+ message = await old_channel.send(
+ f"Hey, {ctx.author.mention}, you can find the output of your command here: "
+ f"{redirect_channel.mention}"
+ )
+
+ if RedirectOutput.delete_invocation:
+ await sleep(RedirectOutput.delete_delay)
+
+ with suppress(NotFound):
+ await message.delete()
+ log.trace("Redirect output: Deleted user redirection message")
+
+ with suppress(NotFound):
+ await ctx.message.delete()
+ log.trace("Redirect output: Deleted invocation message")
+ return inner
+ return wrap
diff --git a/bot/pagination.py b/bot/pagination.py
index 0d8e8aaa3..0ad5b81f1 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -17,6 +17,10 @@ PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO
log = logging.getLogger(__name__)
+class EmptyPaginatorEmbed(Exception):
+ pass
+
+
class LinePaginator(Paginator):
"""
A class that aids in paginating code blocks for Discord messages.
@@ -96,7 +100,7 @@ class LinePaginator(Paginator):
async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed,
prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500,
empty: bool = True, restrict_to_user: User = None, timeout: int = 300,
- footer_text: str = None):
+ footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False):
"""
Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to
switch page, or to finish with pagination.
@@ -118,6 +122,8 @@ class LinePaginator(Paginator):
:param max_size: The maximum number of characters on each page
:param empty: Whether to place an empty line between each given line
:param restrict_to_user: A user to lock pagination operations to for this message, if supplied
+ :param exception_on_empty_embed: Should there be an exception if the embed is empty?
+ :param url: the url to use for the embed headline
:param timeout: The amount of time in seconds to disable pagination of no reaction is added
:param footer_text: Text to prefix the page number in the footer with
"""
@@ -151,6 +157,14 @@ class LinePaginator(Paginator):
paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines)
current_page = 0
+ if not lines:
+ if exception_on_empty_embed:
+ log.exception(f"Pagination asked for empty lines iterable")
+ raise EmptyPaginatorEmbed("No lines to paginate")
+
+ log.debug("No lines to add to paginator, adding '(nothing to display)' message")
+ lines.append("(nothing to display)")
+
for line in lines:
try:
paginator.add_line(line, empty=empty)
@@ -169,6 +183,10 @@ class LinePaginator(Paginator):
embed.set_footer(text=footer_text)
log.trace(f"Setting embed footer to '{footer_text}'")
+ if url:
+ embed.url = url
+ log.trace(f"Setting embed url to '{url}'")
+
log.debug("There's less than two pages, so we won't paginate - sending single page on its own")
return await ctx.send(embed=embed)
else:
@@ -176,9 +194,12 @@ class LinePaginator(Paginator):
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
else:
embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
-
log.trace(f"Setting embed footer to '{embed.footer.text}'")
+ if url:
+ embed.url = url
+ log.trace(f"Setting embed url to '{url}'")
+
log.debug("Sending first page to channel...")
message = await ctx.send(embed=embed)
@@ -315,7 +336,8 @@ class ImagePaginator(Paginator):
@classmethod
async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed,
- prefix: str = "", suffix: str = "", timeout: int = 300):
+ prefix: str = "", suffix: str = "", timeout: int = 300,
+ exception_on_empty_embed: bool = False):
"""
Use a paginator and set of reactions to provide
pagination over a set of title/image pairs.The reactions are
@@ -361,6 +383,14 @@ class ImagePaginator(Paginator):
paginator = cls(prefix=prefix, suffix=suffix)
current_page = 0
+ if not pages:
+ if exception_on_empty_embed:
+ log.exception(f"Pagination asked for empty image list")
+ raise EmptyPaginatorEmbed("No images to paginate")
+
+ log.debug("No images to add to paginator, adding '(no images to display)' message")
+ pages.append(("(no images to display)", ""))
+
for text, image_url in pages:
paginator.add_line(text)
paginator.add_image(image_url)
diff --git a/bot/resources/snake_cards/backs/card_back1.jpg b/bot/resources/snake_cards/backs/card_back1.jpg
deleted file mode 100644
index 22959fa73..000000000
--- a/bot/resources/snake_cards/backs/card_back1.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/backs/card_back2.jpg b/bot/resources/snake_cards/backs/card_back2.jpg
deleted file mode 100644
index d56edc320..000000000
--- a/bot/resources/snake_cards/backs/card_back2.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_bottom.png b/bot/resources/snake_cards/card_bottom.png
deleted file mode 100644
index 8b2b91c5c..000000000
--- a/bot/resources/snake_cards/card_bottom.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_frame.png b/bot/resources/snake_cards/card_frame.png
deleted file mode 100644
index 149a0a5f6..000000000
--- a/bot/resources/snake_cards/card_frame.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_top.png b/bot/resources/snake_cards/card_top.png
deleted file mode 100644
index e329c873a..000000000
--- a/bot/resources/snake_cards/card_top.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/expressway.ttf b/bot/resources/snake_cards/expressway.ttf
deleted file mode 100644
index 39e157947..000000000
--- a/bot/resources/snake_cards/expressway.ttf
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/banner.jpg b/bot/resources/snakes_and_ladders/banner.jpg
deleted file mode 100644
index 69eaaf129..000000000
--- a/bot/resources/snakes_and_ladders/banner.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/board.jpg b/bot/resources/snakes_and_ladders/board.jpg
deleted file mode 100644
index 20032e391..000000000
--- a/bot/resources/snakes_and_ladders/board.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/stars.json b/bot/resources/stars.json
new file mode 100644
index 000000000..8071b9626
--- /dev/null
+++ b/bot/resources/stars.json
@@ -0,0 +1,82 @@
+{
+ "Adele": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Adele_2016.jpg/220px-Adele_2016.jpg",
+ "Steven Tyler": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Steven_Tyler_by_Gage_Skidmore_3.jpg/220px-Steven_Tyler_by_Gage_Skidmore_3.jpg",
+ "Alex Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Alex_Van_Halen_-_Van_Halen_Live.jpg/220px-Alex_Van_Halen_-_Van_Halen_Live.jpg",
+ "Aretha Franklin": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Aretha_Franklin_1968.jpg/220px-Aretha_Franklin_1968.jpg",
+ "Ayumi Hamasaki": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Ayumi_Hamasaki_2007.jpg/220px-Ayumi_Hamasaki_2007.jpg",
+ "Koshi Inaba": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_18.jpg/220px-B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_18.jpg",
+ "Barbra Streisand": "https://upload.wikimedia.org/wikipedia/en/thumb/a/a3/Barbra_Streisand_-_1966.jpg/220px-Barbra_Streisand_-_1966.jpg",
+ "Barry Manilow": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/BarryManilow.jpg/220px-BarryManilow.jpg",
+ "Barry White": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/Barry_White%2C_Bestanddeelnr_927-0099.jpg/220px-Barry_White%2C_Bestanddeelnr_927-0099.jpg",
+ "Beyonce": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Beyonce_-_The_Formation_World_Tour%2C_at_Wembley_Stadium_in_London%2C_England.jpg/220px-Beyonce_-_The_Formation_World_Tour%2C_at_Wembley_Stadium_in_London%2C_England.jpg",
+ "Billy Joel": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Billy_Joel_Shankbone_NYC_2009.jpg/220px-Billy_Joel_Shankbone_NYC_2009.jpg",
+ "Bob Dylan": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Bob_Dylan_-_Azkena_Rock_Festival_2010_2.jpg/220px-Bob_Dylan_-_Azkena_Rock_Festival_2010_2.jpg",
+ "Bob Marley": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Bob-Marley.jpg/220px-Bob-Marley.jpg",
+ "Bob Seger": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Bob_Seger_2013.jpg/220px-Bob_Seger_2013.jpg",
+ "Jon Bon Jovi": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Jon_Bon_Jovi_at_the_2009_Tribeca_Film_Festival_3.jpg/220px-Jon_Bon_Jovi_at_the_2009_Tribeca_Film_Festival_3.jpg",
+ "Britney Spears": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Britney_Spears_2013_%28Straighten_Crop%29.jpg/200px-Britney_Spears_2013_%28Straighten_Crop%29.jpg",
+ "Bruce Springsteen": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Bruce_Springsteen_-_Roskilde_Festival_2012.jpg/210px-Bruce_Springsteen_-_Roskilde_Festival_2012.jpg",
+ "Bruno Mars": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b0/BrunoMars24KMagicWorldTourLive_%28cropped%29.jpg/220px-BrunoMars24KMagicWorldTourLive_%28cropped%29.jpg",
+ "Bryan Adams": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Bryan_Adams_Hamburg_MG_0631_flickr.jpg/300px-Bryan_Adams_Hamburg_MG_0631_flickr.jpg",
+ "Celine Dion": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Celine_Dion_Concert_Singing_Taking_Chances_2008.jpg/220px-Celine_Dion_Concert_Singing_Taking_Chances_2008.jpg",
+ "Cher": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Cher_-_Casablanca.jpg/220px-Cher_-_Casablanca.jpg",
+ "Christina Aguilera": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Christina_Aguilera_in_2016.jpg/220px-Christina_Aguilera_in_2016.jpg",
+ "David Bowie": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e8/David-Bowie_Chicago_2002-08-08_photoby_Adam-Bielawski-cropped.jpg/220px-David-Bowie_Chicago_2002-08-08_photoby_Adam-Bielawski-cropped.jpg",
+ "David Lee Roth": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/David_Lee_Roth_-_Van_Halen.jpg/220px-David_Lee_Roth_-_Van_Halen.jpg",
+ "Donna Summer": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Nobel_Peace_Price_Concert_2009_Donna_Summer3.jpg/220px-Nobel_Peace_Price_Concert_2009_Donna_Summer3.jpg",
+ "Drake": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Drake_at_the_Velvet_Underground_-_2017_%2835986086223%29_%28cropped%29.jpg/220px-Drake_at_the_Velvet_Underground_-_2017_%2835986086223%29_%28cropped%29.jpg",
+ "Ed Sheeran": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Ed_Sheeran_2013.jpg/220px-Ed_Sheeran_2013.jpg",
+ "Eddie Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Eddie_Van_Halen.jpg/300px-Eddie_Van_Halen.jpg",
+ "Elton John": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Elton_John_2011_Shankbone_2.JPG/220px-Elton_John_2011_Shankbone_2.JPG",
+ "Elvis Presley": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Elvis_Presley_promoting_Jailhouse_Rock.jpg/220px-Elvis_Presley_promoting_Jailhouse_Rock.jpg",
+ "Eminem": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Eminem_-_Concert_for_Valor_in_Washington%2C_D.C._Nov._11%2C_2014_%282%29_%28Cropped%29.jpg/220px-Eminem_-_Concert_for_Valor_in_Washington%2C_D.C._Nov._11%2C_2014_%282%29_%28Cropped%29.jpg",
+ "Enya": "https://enya.com/wp-content/themes/enya%20full%20site/images/enya-about.jpg",
+ "Flo Rida": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Flo_Rida_%286924266548%29.jpg/220px-Flo_Rida_%286924266548%29.jpg",
+ "Frank Sinatra": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Frank_Sinatra_%2757.jpg/220px-Frank_Sinatra_%2757.jpg",
+ "Garth Brooks": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bc/Garth_Brooks_on_World_Tour_%28crop%29.png/220px-Garth_Brooks_on_World_Tour_%28crop%29.png",
+ "George Michael": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/George_Michael.jpeg/220px-George_Michael.jpeg",
+ "George Strait": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/George_Strait_2014_1.jpg/220px-George_Strait_2014_1.jpg",
+ "James Taylor": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/James_Taylor_-_Columbia.jpg/220px-James_Taylor_-_Columbia.jpg",
+ "Janet Jackson": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/JanetJacksonUnbreakableTourSanFran2015.jpg/220px-JanetJacksonUnbreakableTourSanFran2015.jpg",
+ "Jay-Z": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Jay-Z.png/220px-Jay-Z.png",
+ "Johnny Cash": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/JohnnyCash1969.jpg/220px-JohnnyCash1969.jpg",
+ "Johnny Hallyday": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a1/Johnny_Hallyday_Cannes.jpg/220px-Johnny_Hallyday_Cannes.jpg",
+ "Julio Iglesias": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Julio_Iglesias09.jpg/220px-Julio_Iglesias09.jpg",
+ "Justin Bieber": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Justin_Bieber_in_2015.jpg/220px-Justin_Bieber_in_2015.jpg",
+ "Justin Timberlake": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/Justin_Timberlake_by_Gage_Skidmore_2.jpg/220px-Justin_Timberlake_by_Gage_Skidmore_2.jpg",
+ "Kanye West": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Kanye_West_at_the_2009_Tribeca_Film_Festival.jpg/220px-Kanye_West_at_the_2009_Tribeca_Film_Festival.jpg",
+ "Katy Perry": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Katy_Perry_at_Madison_Square_Garden_%2837436531092%29_%28cropped%29.jpg/220px-Katy_Perry_at_Madison_Square_Garden_%2837436531092%29_%28cropped%29.jpg",
+ "Kenny G": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/KennyGHWOFMay2013.jpg/220px-KennyGHWOFMay2013.jpg",
+ "Kenny Rogers": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/KennyRogers.jpg/220px-KennyRogers.jpg",
+ "Lady Gaga": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2c/Lady_Gaga_interview_2016.jpg/220px-Lady_Gaga_interview_2016.jpg",
+ "Lil Wayne": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a6/Lil_Wayne_%2823513397583%29.jpg/220px-Lil_Wayne_%2823513397583%29.jpg",
+ "Linda Ronstadt": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/LindaRonstadtPerforming.jpg/220px-LindaRonstadtPerforming.jpg",
+ "Lionel Richie": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cd/Lionel_Richie_2017.jpg/220px-Lionel_Richie_2017.jpg",
+ "Madonna": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Madonna_Rebel_Heart_Tour_2015_-_Stockholm_%2823051472299%29_%28cropped_2%29.jpg/220px-Madonna_Rebel_Heart_Tour_2015_-_Stockholm_%2823051472299%29_%28cropped_2%29.jpg",
+ "Mariah Carey": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f2/Mariah_Carey_WBLS_2018_Interview_4.jpg/220px-Mariah_Carey_WBLS_2018_Interview_4.jpg",
+ "Meat Loaf": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/Meat_Loaf.jpg/220px-Meat_Loaf.jpg",
+ "Michael Jackson": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Michael_Jackson_in_1988.jpg/220px-Michael_Jackson_in_1988.jpg",
+ "Neil Diamond": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f4/Neil_Diamond_HWOF_Aug_2012_other_%28levels_adjusted_and_cropped%29.jpg/220px-Neil_Diamond_HWOF_Aug_2012_other_%28levels_adjusted_and_cropped%29.jpg",
+ "Nicki Minaj": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Nicki_Minaj_MTV_VMAs_4.jpg/250px-Nicki_Minaj_MTV_VMAs_4.jpg",
+ "Olivia Newton-John": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Olivia_Newton-John_2.jpg/220px-Olivia_Newton-John_2.jpg",
+ "Paul McCartney": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5d/Paul_McCartney_-_Out_There_Concert_-_140420-5941-jikatu_%2813950091384%29.jpg/220px-Paul_McCartney_-_Out_There_Concert_-_140420-5941-jikatu_%2813950091384%29.jpg",
+ "Phil Collins": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/1_collins.jpg/220px-1_collins.jpg",
+ "Pink": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/P%21nk_Live_2013.jpg/220px-P%21nk_Live_2013.jpg",
+ "Prince": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b2/Prince_1983_1st_Avenue.jpg/220px-Prince_1983_1st_Avenue.jpg",
+ "Reba McEntire": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Reba_McEntire_by_Gage_Skidmore.jpg/220px-Reba_McEntire_by_Gage_Skidmore.jpg",
+ "Rihanna": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/Rihanna_concert_in_Washington_DC_%282%29.jpg/250px-Rihanna_concert_in_Washington_DC_%282%29.jpg",
+ "Robbie Williams": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/21/Robbie_Williams.jpg/220px-Robbie_Williams.jpg",
+ "Rod Stewart": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Rod_stewart_05111976_12_400.jpg/220px-Rod_stewart_05111976_12_400.jpg",
+ "Carlos Santana": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Santana_2010.jpg/220px-Santana_2010.jpg",
+ "Shania Twain": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/ShaniaTwainJunoAwardsMar2011.jpg/220px-ShaniaTwainJunoAwardsMar2011.jpg",
+ "Stevie Wonder": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/54/Stevie_Wonder_1973.JPG/220px-Stevie_Wonder_1973.JPG",
+ "Tak Matsumoto": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_22.jpg/220px-B%27Z_at_Best_Buy_Theater_NYC_-_9-30-12_-_22.jpg",
+ "Taylor Swift": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/Taylor_Swift_112_%2818119055110%29_%28cropped%29.jpg/220px-Taylor_Swift_112_%2818119055110%29_%28cropped%29.jpg",
+ "Tim McGraw": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Tim_McGraw_October_24_2015.jpg/220px-Tim_McGraw_October_24_2015.jpg",
+ "Tina Turner": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Tina_turner_21021985_01_350.jpg/250px-Tina_turner_21021985_01_350.jpg",
+ "Tom Petty": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Tom_Petty_Live_in_Horsens_%28cropped2%29.jpg/220px-Tom_Petty_Live_in_Horsens_%28cropped2%29.jpg",
+ "Tupac Shakur": "https://upload.wikimedia.org/wikipedia/en/thumb/b/b5/Tupac_Amaru_Shakur2.jpg/220px-Tupac_Amaru_Shakur2.jpg",
+ "Usher": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Usher_Cannes_2016_retusche.jpg/220px-Usher_Cannes_2016_retusche.jpg",
+ "Whitney Houston": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Whitney_Houston_Welcome_Home_Heroes_1_cropped.jpg/220px-Whitney_Houston_Welcome_Home_Heroes_1_cropped.jpg",
+ "Wolfgang Van Halen": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Wolfgang_Van_Halen_Different_Kind_of_Truth_2012.jpg/220px-Wolfgang_Van_Halen_Different_Kind_of_Truth_2012.jpg"
+}
diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py
index a6a1a52d0..fdad6ffd3 100644
--- a/bot/rules/newlines.py
+++ b/bot/rules/newlines.py
@@ -1,5 +1,6 @@
"""Detects total newlines exceeding the set limit sent by a single user."""
+import re
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
@@ -17,12 +18,32 @@ async def apply(
if msg.author == last_message.author
)
- total_recent_newlines = sum(msg.content.count('\n') for msg in relevant_messages)
+ # Identify groups of newline characters and get group & total counts
+ exp = r"(\n+)"
+ newline_counts = []
+ for msg in relevant_messages:
+ newline_counts += [len(group) for group in re.findall(exp, msg.content)]
+ total_recent_newlines = sum(newline_counts)
+ # Get maximum newline group size
+ if newline_counts:
+ max_newline_group = max(newline_counts)
+ else:
+ # If no newlines are found, newline_counts will be an empty list, which will error out max()
+ max_newline_group = 0
+
+ # Check first for total newlines, if this passes then check for large groupings
if total_recent_newlines > config['max']:
return (
f"sent {total_recent_newlines} newlines in {config['interval']}s",
(last_message.author,),
relevant_messages
)
+ elif max_newline_group > config['max_consecutive']:
+ return (
+ f"sent {max_newline_group} consecutive newlines in {config['interval']}s",
+ (last_message.author,),
+ relevant_messages
+ )
+
return None
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 87351eaf3..4c99d50e8 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,84 +1,3 @@
-import asyncio
-from typing import List
-
-import discord
-from discord.ext.commands import BadArgument, Context
-
-from bot.pagination import LinePaginator
-
-
-async def disambiguate(
- ctx: Context, entries: List[str], *, timeout: float = 30,
- per_page: int = 20, empty: bool = False, embed: discord.Embed = None
-):
- """
- Has the user choose between multiple entries in case one could not be chosen automatically.
-
- This will raise a BadArgument if entries is empty, if the disambiguation event times out,
- or if the user makes an invalid choice.
-
- :param ctx: Context object from discord.py
- :param entries: List of items for user to choose from
- :param timeout: Number of seconds to wait before canceling disambiguation
- :param per_page: Entries per embed page
- :param empty: Whether the paginator should have an extra line between items
- :param embed: The embed that the paginator will use.
- :return: Users choice for correct entry.
- """
-
- if len(entries) == 0:
- raise BadArgument('No matches found.')
-
- if len(entries) == 1:
- return entries[0]
-
- choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1))
-
- def check(message):
- return (message.content.isdigit()
- and message.author == ctx.author
- and message.channel == ctx.channel)
-
- try:
- if embed is None:
- embed = discord.Embed()
-
- coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout)
- coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page,
- empty=empty, max_size=6000, timeout=9000)
-
- # wait_for timeout will go to except instead of the wait_for thing as I expected
- futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)]
- done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED, loop=ctx.bot.loop)
-
- # :yert:
- result = list(done)[0].result()
-
- # Pagination was canceled - result is None
- if result is None:
- for coro in pending:
- coro.cancel()
- raise BadArgument('Canceled.')
-
- # Pagination was not initiated, only one page
- if result.author == ctx.bot.user:
- # Continue the wait_for
- result = await list(pending)[0]
-
- # Love that duplicate code
- for coro in pending:
- coro.cancel()
- except asyncio.TimeoutError:
- raise BadArgument('Timed out.')
-
- # Guaranteed to not error because of isdigit() in check
- index = int(result.content)
-
- try:
- return entries[index - 1]
- except IndexError:
- raise BadArgument('Invalid choice.')
-
class CaseInsensitiveDict(dict):
"""
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
new file mode 100644
index 000000000..37dc657f7
--- /dev/null
+++ b/bot/utils/checks.py
@@ -0,0 +1,56 @@
+import logging
+
+from discord.ext.commands import Context
+
+log = logging.getLogger(__name__)
+
+
+def with_role_check(ctx: Context, *role_ids: int) -> bool:
+ """
+ Returns True if the user has any one
+ of the roles in role_ids.
+ """
+
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
+ "This command is restricted by the with_role decorator. Rejecting request.")
+ return False
+
+ for role in ctx.author.roles:
+ if role.id in role_ids:
+ log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")
+ return True
+
+ log.trace(f"{ctx.author} does not have the required role to use "
+ f"the '{ctx.command.name}' command, so the request is rejected.")
+ return False
+
+
+def without_role_check(ctx: Context, *role_ids: int) -> bool:
+ """
+ Returns True if the user does not have any
+ of the roles in role_ids.
+ """
+
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
+ "This command is restricted by the without_role decorator. Rejecting request.")
+ return False
+
+ author_roles = (role.id for role in ctx.author.roles)
+ check = all(role not in author_roles for role in role_ids)
+ log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the without_role check was {check}.")
+ return check
+
+
+def in_channel_check(ctx: Context, channel_id: int) -> bool:
+ """
+ Checks if the command was executed
+ inside of the specified channel.
+ """
+
+ check = ctx.channel.id == channel_id
+ log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the in_channel check was {check}.")
+ return check
diff --git a/bot/utils/snakes/__init__.py b/bot/utils/snakes/__init__.py
deleted file mode 100644
index e69de29bb..000000000
--- a/bot/utils/snakes/__init__.py
+++ /dev/null
diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py
deleted file mode 100644
index b9d29583f..000000000
--- a/bot/utils/snakes/hatching.py
+++ /dev/null
@@ -1,44 +0,0 @@
-h1 = r'''```
- ----
- ------
- /--------\
- |--------|
- |--------|
- \------/
- ----```'''
-
-h2 = r'''```
- ----
- ------
- /---\-/--\
- |-----\--|
- |--------|
- \------/
- ----```'''
-
-h3 = r'''```
- ----
- ------
- /---\-/--\
- |-----\--|
- |-----/--|
- \----\-/
- ----```'''
-
-h4 = r'''```
- -----
- ----- \
- /--| /---\
- |--\ -\---|
- |--\--/-- /
- \------- /
- ------```'''
-
-stages = [h1, h2, h3, h4]
-snakes = {
- "Baby Python": "https://i.imgur.com/SYOcmSa.png",
- "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png",
- "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png",
- "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png",
- "Baby Cobra": "https://i.imgur.com/jk14ryt.png"
-}
diff --git a/bot/utils/snakes/perlin.py b/bot/utils/snakes/perlin.py
deleted file mode 100644
index 0401787ef..000000000
--- a/bot/utils/snakes/perlin.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-Perlin noise implementation.
-Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1
-Licensed under ISC
-"""
-
-import math
-import random
-from itertools import product
-
-
-def smoothstep(t):
- """Smooth curve with a zero derivative at 0 and 1, making it useful for
- interpolating.
- """
- return t * t * (3. - 2. * t)
-
-
-def lerp(t, a, b):
- """Linear interpolation between a and b, given a fraction t."""
- return a + t * (b - a)
-
-
-class PerlinNoiseFactory(object):
- """Callable that produces Perlin noise for an arbitrary point in an
- arbitrary number of dimensions. The underlying grid is aligned with the
- integers.
- There is no limit to the coordinates used; new gradients are generated on
- the fly as necessary.
- """
-
- def __init__(self, dimension, octaves=1, tile=(), unbias=False):
- """Create a new Perlin noise factory in the given number of dimensions,
- which should be an integer and at least 1.
- More octaves create a foggier and more-detailed noise pattern. More
- than 4 octaves is rather excessive.
- ``tile`` can be used to make a seamlessly tiling pattern. For example:
- pnf = PerlinNoiseFactory(2, tile=(0, 3))
- This will produce noise that tiles every 3 units vertically, but never
- tiles horizontally.
- If ``unbias`` is true, the smoothstep function will be applied to the
- output before returning it, to counteract some of Perlin noise's
- significant bias towards the center of its output range.
- """
- self.dimension = dimension
- self.octaves = octaves
- self.tile = tile + (0,) * dimension
- self.unbias = unbias
-
- # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply
- # by this to scale to ±1
- self.scale_factor = 2 * dimension ** -0.5
-
- self.gradient = {}
-
- def _generate_gradient(self):
- # Generate a random unit vector at each grid point -- this is the
- # "gradient" vector, in that the grid tile slopes towards it
-
- # 1 dimension is special, since the only unit vector is trivial;
- # instead, use a slope between -1 and 1
- if self.dimension == 1:
- return (random.uniform(-1, 1),)
-
- # Generate a random point on the surface of the unit n-hypersphere;
- # this is the same as a random unit vector in n dimensions. Thanks
- # to: http://mathworld.wolfram.com/SpherePointPicking.html
- # Pick n normal random variables with stddev 1
- random_point = [random.gauss(0, 1) for _ in range(self.dimension)]
- # Then scale the result to a unit vector
- scale = sum(n * n for n in random_point) ** -0.5
- return tuple(coord * scale for coord in random_point)
-
- def get_plain_noise(self, *point):
- """Get plain noise for a single point, without taking into account
- either octaves or tiling.
- """
- if len(point) != self.dimension:
- raise ValueError("Expected {0} values, got {1}".format(
- self.dimension, len(point)))
-
- # Build a list of the (min, max) bounds in each dimension
- grid_coords = []
- for coord in point:
- min_coord = math.floor(coord)
- max_coord = min_coord + 1
- grid_coords.append((min_coord, max_coord))
-
- # Compute the dot product of each gradient vector and the point's
- # distance from the corresponding grid point. This gives you each
- # gradient's "influence" on the chosen point.
- dots = []
- for grid_point in product(*grid_coords):
- if grid_point not in self.gradient:
- self.gradient[grid_point] = self._generate_gradient()
- gradient = self.gradient[grid_point]
-
- dot = 0
- for i in range(self.dimension):
- dot += gradient[i] * (point[i] - grid_point[i])
- dots.append(dot)
-
- # Interpolate all those dot products together. The interpolation is
- # done with smoothstep to smooth out the slope as you pass from one
- # grid cell into the next.
- # Due to the way product() works, dot products are ordered such that
- # the last dimension alternates: (..., min), (..., max), etc. So we
- # can interpolate adjacent pairs to "collapse" that last dimension. Then
- # the results will alternate in their second-to-last dimension, and so
- # forth, until we only have a single value left.
- dim = self.dimension
- while len(dots) > 1:
- dim -= 1
- s = smoothstep(point[dim] - grid_coords[dim][0])
-
- next_dots = []
- while dots:
- next_dots.append(lerp(s, dots.pop(0), dots.pop(0)))
-
- dots = next_dots
-
- return dots[0] * self.scale_factor
-
- def __call__(self, *point):
- """Get the value of this Perlin noise function at the given point. The
- number of values given should match the number of dimensions.
- """
- ret = 0
- for o in range(self.octaves):
- o2 = 1 << o
- new_point = []
- for i, coord in enumerate(point):
- coord *= o2
- if self.tile[i]:
- coord %= self.tile[i] * o2
- new_point.append(coord)
- ret += self.get_plain_noise(*new_point) / o2
-
- # Need to scale n back down since adding all those extra octaves has
- # probably expanded it beyond ±1
- # 1 octave: ±1
- # 2 octaves: ±1½
- # 3 octaves: ±1¾
- ret /= 2 - 2 ** (1 - self.octaves)
-
- if self.unbias:
- # The output of the plain Perlin noise algorithm has a fairly
- # strong bias towards the center due to the central limit theorem
- # -- in fact the top and bottom 1/8 virtually never happen. That's
- # a quarter of our entire output range! If only we had a function
- # in [0..1] that could introduce a bias towards the endpoints...
- r = (ret + 1) / 2
- # Doing it this many times is a completely made-up heuristic.
- for _ in range(int(self.octaves / 2 + 0.5)):
- r = smoothstep(r)
- ret = r * 2 - 1
-
- return ret
diff --git a/bot/utils/snakes/perlinsneks.py b/bot/utils/snakes/perlinsneks.py
deleted file mode 100644
index 662281775..000000000
--- a/bot/utils/snakes/perlinsneks.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# perlin sneks!
-import io
-import math
-import random
-from typing import Tuple
-
-from PIL.ImageDraw import Image, ImageDraw
-
-from bot.utils.snakes import perlin
-
-DEFAULT_SNAKE_COLOR: int = 0x15c7ea
-DEFAULT_BACKGROUND_COLOR: int = 0
-DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200)
-DEFAULT_SNAKE_LENGTH: int = 22
-DEFAULT_SNAKE_WIDTH: int = 8
-DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10)
-DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50)
-DEFAULT_TEXT: str = "snek\nit\nup"
-DEFAULT_TEXT_POSITION: Tuple[int] = (
- 10,
- 10
-)
-DEFAULT_TEXT_COLOR: int = 0xf2ea15
-
-X = 0
-Y = 1
-ANGLE_RANGE = math.pi * 2
-
-
-def create_snek_frame(
- perlin_factory: perlin.PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0,
- image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS,
- snake_length: int = DEFAULT_SNAKE_LENGTH,
- snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR,
- segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH,
- text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION,
- text_color: Tuple[int] = DEFAULT_TEXT_COLOR
-) -> Image:
- """
- Creates a single random snek frame using Perlin noise.
- :param perlin_factory: the perlin noise factory used. Required.
- :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame
- :param image_dimensions: the size of the output image.
- :param image_margins: the margins to respect inside of the image.
- :param snake_length: the length of the snake, in segments.
- :param snake_color: the color of the snake.
- :param bg_color: the background color.
- :param segment_length_range: the range of the segment length. Values will be generated inside this range, including
- the bounds.
- :param snake_width: the width of the snek, in pixels.
- :param text: the text to display with the snek. Set to None for no text.
- :param text_position: the position of the text.
- :param text_color: the color of the text.
- :return: a PIL image, representing a single frame.
- """
- start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X])
- start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y])
- points = [(start_x, start_y)]
-
- for index in range(0, snake_length):
- angle = perlin_factory.get_plain_noise(
- ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift
- ) * ANGLE_RANGE
- current_point = points[index]
- segment_length = random.randint(segment_length_range[0], segment_length_range[1])
- points.append((
- current_point[X] + segment_length * math.cos(angle),
- current_point[Y] + segment_length * math.sin(angle)
- ))
-
- # normalize bounds
- min_dimensions = [start_x, start_y]
- max_dimensions = [start_x, start_y]
- for point in points:
- min_dimensions[X] = min(point[X], min_dimensions[X])
- min_dimensions[Y] = min(point[Y], min_dimensions[Y])
- max_dimensions[X] = max(point[X], max_dimensions[X])
- max_dimensions[Y] = max(point[Y], max_dimensions[Y])
-
- # shift towards middle
- dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y])
- shift = (
- image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]),
- image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y])
- )
-
- image = Image.new(mode='RGB', size=image_dimensions, color=bg_color)
- draw = ImageDraw(image)
- for index in range(1, len(points)):
- point = points[index]
- previous = points[index - 1]
- draw.line(
- (
- shift[X] + previous[X],
- shift[Y] + previous[Y],
- shift[X] + point[X],
- shift[Y] + point[Y]
- ),
- width=snake_width,
- fill=snake_color
- )
- if text is not None:
- draw.multiline_text(text_position, text, fill=text_color)
- del draw
- return image
-
-
-def frame_to_png_bytes(image: Image):
- stream = io.BytesIO()
- image.save(stream, format='PNG')
- return stream.getvalue()
diff --git a/bot/utils/snakes/sal.py b/bot/utils/snakes/sal.py
deleted file mode 100644
index 2528664aa..000000000
--- a/bot/utils/snakes/sal.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import asyncio
-import io
-import logging
-import math
-import os
-import random
-
-import aiohttp
-from PIL import Image
-from discord import File, Member, Reaction
-from discord.ext.commands import Context
-
-from bot.utils.snakes.sal_board import (
- BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE,
- BOARD_TILE_SIZE, MAX_PLAYERS, PLAYER_ICON_IMAGE_SIZE
-)
-
-log = logging.getLogger(__name__)
-
-# Emoji constants
-START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game
-CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game
-ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die!
-JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game.
-
-STARTUP_SCREEN_EMOJI = [
- JOIN_EMOJI,
- START_EMOJI,
- CANCEL_EMOJI
-]
-
-GAME_SCREEN_EMOJI = [
- ROLL_EMOJI,
- CANCEL_EMOJI
-]
-
-
-class SnakeAndLaddersGame:
- def __init__(self, snakes, context: Context):
- self.snakes = snakes
- self.ctx = context
- self.channel = self.ctx.channel
- self.state = 'booting'
- self.started = False
- self.author = self.ctx.author
- self.players = []
- self.player_tiles = {}
- self.round_has_rolled = {}
- self.avatar_images = {}
- self.board = None
- self.positions = None
- self.rolls = []
-
- async def open_game(self):
- """
- Create a new Snakes and Ladders game.
-
- Listen for reactions until players have joined,
- and the game has been started.
- """
-
- def startup_event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
- return (
- all((
- reaction_.message.id == startup.id, # Reaction is on startup message
- reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes
- user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot
- ))
- )
-
- # Check to see if the bot can remove reactions
- if not self.channel.permissions_for(self.ctx.guild.me).manage_messages:
- log.warning(
- "Unable to start Snakes and Ladders - "
- f"Missing manage_messages permissions in {self.channel}"
- )
- return
-
- await self._add_player(self.author)
- await self.channel.send(
- "**Snakes and Ladders**: A new game is about to start!",
- file=File(
- os.path.join("bot", "resources", "snakes_and_ladders", "banner.jpg"),
- filename='Snakes and Ladders.jpg'
- )
- )
- startup = await self.channel.send(
- f"Press {JOIN_EMOJI} to participate, and press "
- f"{START_EMOJI} to start the game"
- )
- for emoji in STARTUP_SCREEN_EMOJI:
- await startup.add_reaction(emoji)
-
- self.state = 'waiting'
-
- while not self.started:
- try:
- reaction, user = await self.ctx.bot.wait_for(
- "reaction_add",
- timeout=300,
- check=startup_event_check
- )
- if reaction.emoji == JOIN_EMOJI:
- await self.player_join(user)
- elif reaction.emoji == CANCEL_EMOJI:
- if self.ctx.author == user:
- await self.cancel_game(user)
- return
- else:
- await self.player_leave(user)
- elif reaction.emoji == START_EMOJI:
- if self.ctx.author == user:
- self.started = True
- await self.start_game(user)
- await startup.delete()
- break
-
- await startup.remove_reaction(reaction.emoji, user)
-
- except asyncio.TimeoutError:
- log.debug("Snakes and Ladders timed out waiting for a reaction")
- self.cancel_game(self.author)
- return # We're done, no reactions for the last 5 minutes
-
- async def _add_player(self, user: Member):
- self.players.append(user)
- self.player_tiles[user.id] = 1
- avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE)
- async with aiohttp.ClientSession() as session:
- async with session.get(avatar_url) as res:
- avatar_bytes = await res.read()
- im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE))
- self.avatar_images[user.id] = im
-
- async def player_join(self, user: Member):
- for p in self.players:
- if user == p:
- await self.channel.send(user.mention + " You are already in the game.", delete_after=10)
- return
- if self.state != 'waiting':
- await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10)
- return
- if len(self.players) is MAX_PLAYERS:
- await self.channel.send(user.mention + " The game is full!", delete_after=10)
- return
-
- await self._add_player(user)
-
- await self.channel.send(
- f"**Snakes and Ladders**: {user.mention} has joined the game.\n"
- f"There are now {str(len(self.players))} players in the game.",
- delete_after=10
- )
-
- async def player_leave(self, user: Member):
- if user == self.author:
- await self.channel.send(
- user.mention + " You are the author, and cannot leave the game. Execute "
- "`sal cancel` to cancel the game.",
- delete_after=10
- )
- return
- for p in self.players:
- if user == p:
- self.players.remove(p)
- self.player_tiles.pop(p.id, None)
- self.round_has_rolled.pop(p.id, None)
- await self.channel.send(
- "**Snakes and Ladders**: " + user.mention + " has left the game.",
- delete_after=10
- )
-
- if self.state != 'waiting' and len(self.players) == 1:
- await self.channel.send("**Snakes and Ladders**: The game has been surrendered!")
- self._destruct()
- return
- await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
-
- async def cancel_game(self, user: Member):
- if not user == self.author:
- await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10)
- return
- await self.channel.send("**Snakes and Ladders**: Game has been canceled.")
- self._destruct()
-
- async def start_game(self, user: Member):
- if not user == self.author:
- await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10)
- return
- if len(self.players) < 1:
- await self.channel.send(
- user.mention + " A minimum of 2 players is required to start the game.",
- delete_after=10
- )
- return
- if not self.state == 'waiting':
- await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10)
- return
- self.state = 'starting'
- player_list = ', '.join(user.mention for user in self.players)
- await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list)
- await self.start_round()
-
- async def start_round(self):
-
- def game_event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
- return (
- all((
- reaction_.message.id == self.positions.id, # Reaction is on positions message
- reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes
- user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot
- ))
- )
-
- self.state = 'roll'
- for user in self.players:
- self.round_has_rolled[user.id] = False
- board_img = Image.open(os.path.join("bot", "resources", "snakes_and_ladders", "board.jpg"))
- player_row_size = math.ceil(MAX_PLAYERS / 2)
-
- for i, player in enumerate(self.players):
- tile = self.player_tiles[player.id]
- tile_coordinates = self._board_coordinate_from_index(tile)
- x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE
- y_offset = \
- BOARD_MARGIN[1] + (
- (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE)
- x_offset += BOARD_PLAYER_SIZE * (i % player_row_size)
- y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size)
- board_img.paste(self.avatar_images[player.id],
- box=(x_offset, y_offset))
- stream = io.BytesIO()
- board_img.save(stream, format='JPEG')
- board_file = File(stream.getvalue(), filename='Board.jpg')
- player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players)
-
- # Store and send new messages
- temp_board = await self.channel.send(
- "**Snakes and Ladders**: A new round has started! Current board:",
- file=board_file
- )
- temp_positions = await self.channel.send(
- f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!"
- )
-
- # Delete the previous messages
- if self.board and self.positions:
- await self.board.delete()
- await self.positions.delete()
-
- # remove the roll messages
- for roll in self.rolls:
- await roll.delete()
- self.rolls = []
-
- # Save new messages
- self.board = temp_board
- self.positions = temp_positions
-
- # Wait for rolls
- for emoji in GAME_SCREEN_EMOJI:
- await self.positions.add_reaction(emoji)
-
- while True:
- try:
- reaction, user = await self.ctx.bot.wait_for(
- "reaction_add",
- timeout=300,
- check=game_event_check
- )
-
- if reaction.emoji == ROLL_EMOJI:
- await self.player_roll(user)
- elif reaction.emoji == CANCEL_EMOJI:
- if self.ctx.author == user:
- await self.cancel_game(user)
- return
- else:
- await self.player_leave(user)
-
- await self.positions.remove_reaction(reaction.emoji, user)
-
- if self._check_all_rolled():
- break
-
- except asyncio.TimeoutError:
- log.debug("Snakes and Ladders timed out waiting for a reaction")
- await self.cancel_game(self.author)
- return # We're done, no reactions for the last 5 minutes
-
- # Round completed
- await self._complete_round()
-
- async def player_roll(self, user: Member):
- if user.id not in self.player_tiles:
- await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
- return
- if self.state != 'roll':
- await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10)
- return
- if self.round_has_rolled[user.id]:
- return
- roll = random.randint(1, 6)
- self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!"))
- next_tile = self.player_tiles[user.id] + roll
-
- # apply snakes and ladders
- if next_tile in BOARD:
- target = BOARD[next_tile]
- if target < next_tile:
- await self.channel.send(
- f"{user.mention} slips on a snake and falls back to **{target}**",
- delete_after=15
- )
- else:
- await self.channel.send(
- f"{user.mention} climbs a ladder to **{target}**",
- delete_after=15
- )
- next_tile = target
-
- self.player_tiles[user.id] = min(100, next_tile)
- self.round_has_rolled[user.id] = True
-
- async def _complete_round(self):
-
- self.state = 'post_round'
-
- # check for winner
- winner = self._check_winner()
- if winner is None:
- # there is no winner, start the next round
- await self.start_round()
- return
-
- # announce winner and exit
- await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:")
- self._destruct()
-
- def _check_winner(self) -> Member:
- if self.state != 'post_round':
- return None
- return next((player for player in self.players if self.player_tiles[player.id] == 100),
- None)
-
- def _check_all_rolled(self):
- return all(rolled for rolled in self.round_has_rolled.values())
-
- def _destruct(self):
- del self.snakes.active_sal[self.channel]
-
- def _board_coordinate_from_index(self, index: int):
- # converts the tile number to the x/y coordinates for graphical purposes
- y_level = 9 - math.floor((index - 1) / 10)
- is_reversed = math.floor((index - 1) / 10) % 2 != 0
- x_level = (index - 1) % 10
- if is_reversed:
- x_level = 9 - x_level
- return x_level, y_level
diff --git a/bot/utils/snakes/sal_board.py b/bot/utils/snakes/sal_board.py
deleted file mode 100644
index 1b8eab451..000000000
--- a/bot/utils/snakes/sal_board.py
+++ /dev/null
@@ -1,33 +0,0 @@
-BOARD_TILE_SIZE = 56 # the size of each board tile
-BOARD_PLAYER_SIZE = 20 # the size of each player icon
-BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons)
-PLAYER_ICON_IMAGE_SIZE = 32 # the size of the image to download, should a power of 2 and higher than BOARD_PLAYER_SIZE
-MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board
-
-# board definition (from, to)
-BOARD = {
- # ladders
- 2: 38,
- 7: 14,
- 8: 31,
- 15: 26,
- 21: 42,
- 28: 84,
- 36: 44,
- 51: 67,
- 71: 91,
- 78: 98,
- 87: 94,
-
- # snakes
- 99: 80,
- 95: 75,
- 92: 88,
- 89: 68,
- 74: 53,
- 64: 60,
- 62: 19,
- 49: 11,
- 46: 25,
- 16: 6
-}
diff --git a/config-default.yml b/config-default.yml
index 8db12f70b..2f5dcf5dc 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -25,7 +25,7 @@ style:
green_chevron: "<:greenchevron:418104310329769993>"
red_chevron: "<:redchevron:418112778184818698>"
white_chevron: "<:whitechevron:418110396973711363>"
- bb_message: "<:bbmessage:472476937504423936>"
+ bb_message: "<:bbmessage:476273120999636992>"
status_online: "<:status_online:470326272351010816>"
status_idle: "<:status_idle:470326266625785866>"
@@ -85,12 +85,16 @@ style:
guild:
id: 267624335836053506
+ categories:
+ python_help: 356013061213126657
+
channels:
admins: &ADMINS 365960823622991872
announcements: 354619224620138496
big_brother_logs: &BBLOGS 468507907357409333
bot: 267659945086812160
checkpoint_test: 422077681434099723
+ defcon: 464469101889454091
devlog: &DEVLOG 409308876241108992
devtest: &DEVTEST 414574275865870337
help_0: 303906576991780866
@@ -110,6 +114,8 @@ guild:
reddit: 458224812528238616
staff_lounge: &STAFF_LOUNGE 464905259261755392
talent_pool: &TALENT_POOL 534321732593647616
+ userlog: 528976905546760203
+ user_event_a: &USER_EVENT_A 592000283102674944
verification: 352442727016693763
ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG]
@@ -128,6 +134,7 @@ guild:
verified: 352427296948486144
helpers: 267630620367257601
rockstars: &ROCKSTARS_ROLE 458226413825294336
+ team_leader: 501324292341104650
webhooks:
talent_pool: 569145364800602132
@@ -137,11 +144,18 @@ guild:
filter:
# What do we filter?
- filter_zalgo: false
- filter_invites: true
- filter_domains: true
- watch_words: true
- watch_tokens: true
+ filter_zalgo: false
+ filter_invites: true
+ filter_domains: true
+ watch_rich_embeds: true
+ watch_words: true
+ watch_tokens: true
+
+ # Notify user on filter?
+ # Notifications are not expected for "watchlist" type filters
+ notify_user_zalgo: false
+ notify_user_invites: true
+ notify_user_domains: false
# Filter configuration
ping_everyone: true # Ping @everyone when we send a mod-alert?
@@ -181,6 +195,8 @@ filter:
- (re+)tar+(d+|t+)(ed)?
- ta+r+d+
- cunts*
+ - trann*y
+ - shemale
token_watchlist:
- fa+g+s*
@@ -199,6 +215,8 @@ filter:
- *BBLOGS
- *STAFF_LOUNGE
- *DEVTEST
+ - *TALENT_POOL
+ - *USER_EVENT_A
role_whitelist:
- *ADMIN_ROLE
@@ -211,9 +229,7 @@ filter:
keys:
deploy_bot: !ENV "DEPLOY_BOT_KEY"
deploy_site: !ENV "DEPLOY_SITE"
- omdb: !ENV "OMDB_API_KEY"
site_api: !ENV "BOT_API_KEY"
- youtube: !ENV "YOUTUBE_API_KEY"
urls:
@@ -225,23 +241,19 @@ urls:
site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"]
site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"]
- site_facts_api: !JOIN [*SCHEMA, *API, "/bot/snake_facts"]
site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"]
- site_idioms_api: !JOIN [*SCHEMA, *API, "/bot/snake_idioms"]
site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"]
site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"]
site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"]
site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"]
site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
+ site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"]
site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"]
site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"]
- site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"]
site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
- site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"]
site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"]
- site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"]
site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"]
site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"]
site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"]
@@ -261,7 +273,6 @@ urls:
# Misc URLs
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot"
- omdb: "http://omdbapi.com"
anti_spam:
# Clean messages that violate a rule.
@@ -308,6 +319,7 @@ anti_spam:
newlines:
interval: 10
max: 100
+ max_consecutive: 10
role_mentions:
interval: 10
@@ -332,5 +344,16 @@ big_brother:
header_message_limit: 15
+free:
+ # Seconds to elapse for a channel
+ # to be considered inactive.
+ activity_timeout: 600
+ cooldown_rate: 1
+ cooldown_per: 60.0
+
+redirect_output:
+ delete_invocation: true
+ delete_delay: 15
+
config:
required_keys: ['bot.token']