aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--.pre-commit-config.yaml19
-rw-r--r--CONTRIBUTING.md3
-rw-r--r--Dockerfile23
-rw-r--r--Pipfile31
-rw-r--r--Pipfile.lock228
-rwxr-xr-xREADME.md285
-rw-r--r--azure-pipelines.yml22
-rw-r--r--bot/__init__.py2
-rw-r--r--bot/__main__.py5
-rw-r--r--bot/bot.py17
-rw-r--r--bot/constants.py84
-rw-r--r--bot/decorators.py107
-rw-r--r--bot/pagination.py93
-rw-r--r--bot/resources/easter/bunny_names.json29
-rw-r--r--bot/resources/easter/easter_egg_facts.json17
-rw-r--r--bot/resources/easter/easter_riddle.json82
-rw-r--r--bot/resources/easter/egghead_questions.json4
-rw-r--r--bot/resources/evergreen/game_recs/chrono_trigger.json7
-rw-r--r--bot/resources/evergreen/game_recs/digimon_world.json7
-rw-r--r--bot/resources/evergreen/game_recs/doom_2.json7
-rw-r--r--bot/resources/evergreen/game_recs/skyrim.json7
-rw-r--r--bot/resources/evergreen/speedrun_links.json18
-rw-r--r--bot/resources/halloween/github_links.json1
-rw-r--r--bot/resources/persist/egg_hunt.sqlitebin16384 -> 0 bytes
-rw-r--r--bot/seasons/__init__.py4
-rw-r--r--bot/seasons/christmas/adventofcode.py30
-rw-r--r--bot/seasons/christmas/hanukkah_embed.py11
-rw-r--r--bot/seasons/easter/april_fools_vids.py10
-rw-r--r--bot/seasons/easter/avatar_easterifier.py16
-rw-r--r--bot/seasons/easter/bunny_name_generator.py93
-rw-r--r--bot/seasons/easter/conversationstarters.py6
-rw-r--r--bot/seasons/easter/easter_riddle.py101
-rw-r--r--bot/seasons/easter/egg_decorating.py10
-rw-r--r--bot/seasons/easter/egg_facts.py62
-rw-r--r--bot/seasons/easter/egg_hunt/__init__.py11
-rw-r--r--bot/seasons/easter/egg_hunt/cog.py617
-rw-r--r--bot/seasons/easter/egg_hunt/constants.py39
-rw-r--r--bot/seasons/easter/egghead_quiz.py17
-rw-r--r--bot/seasons/easter/traditions.py8
-rw-r--r--bot/seasons/evergreen/8bitify.py54
-rw-r--r--bot/seasons/evergreen/error_handler.py23
-rw-r--r--bot/seasons/evergreen/fun.py122
-rw-r--r--bot/seasons/evergreen/issues.py57
-rw-r--r--bot/seasons/evergreen/magic_8ball.py6
-rw-r--r--bot/seasons/evergreen/minesweeper.py285
-rw-r--r--bot/seasons/evergreen/recommend_game.py51
-rw-r--r--bot/seasons/evergreen/showprojects.py13
-rw-r--r--bot/seasons/evergreen/snakes/__init__.py4
-rw-r--r--bot/seasons/evergreen/snakes/converter.py11
-rw-r--r--bot/seasons/evergreen/snakes/snakes_cog.py74
-rw-r--r--bot/seasons/evergreen/snakes/utils.py149
-rw-r--r--bot/seasons/evergreen/speedrun.py28
-rw-r--r--bot/seasons/evergreen/uptime.py6
-rw-r--r--bot/seasons/halloween/8ball.py6
-rw-r--r--bot/seasons/halloween/candy_collection.py36
-rw-r--r--bot/seasons/halloween/hacktoberstats.py55
-rw-r--r--bot/seasons/halloween/halloween_facts.py13
-rw-r--r--bot/seasons/halloween/halloweenify.py6
-rw-r--r--bot/seasons/halloween/monstersurvey.py37
-rw-r--r--bot/seasons/halloween/scarymovie.py10
-rw-r--r--bot/seasons/halloween/spookyavatar.py8
-rw-r--r--bot/seasons/halloween/spookygif.py6
-rw-r--r--bot/seasons/halloween/spookyrating.py8
-rw-r--r--bot/seasons/halloween/spookyreact.py8
-rw-r--r--bot/seasons/halloween/spookysound.py8
-rw-r--r--bot/seasons/halloween/timeleft.py11
-rw-r--r--bot/seasons/pride/pride_anthem.py6
-rw-r--r--bot/seasons/pride/pride_avatar.py12
-rw-r--r--bot/seasons/season.py33
-rw-r--r--bot/seasons/valentines/be_my_valentine.py37
-rw-r--r--bot/seasons/valentines/lovecalculator.py8
-rw-r--r--bot/seasons/valentines/movie_generator.py6
-rw-r--r--bot/seasons/valentines/myvalenstate.py8
-rw-r--r--bot/seasons/valentines/pickuplines.py6
-rw-r--r--bot/seasons/valentines/savethedate.py6
-rw-r--r--bot/seasons/valentines/valentine_zodiac.py8
-rw-r--r--bot/seasons/valentines/whoisvalentine.py8
-rw-r--r--bot/seasons/wildcard/__init__.py31
-rw-r--r--bot/utils/__init__.py74
-rw-r--r--bot/utils/halloween/spookifications.py8
-rw-r--r--bot/utils/persist.py66
-rw-r--r--docker-compose.yml24
-rw-r--r--docker/Dockerfile26
-rw-r--r--docker/docker-compose.yml12
-rwxr-xr-xscripts/deploy-azure.sh14
-rw-r--r--tox.ini7
87 files changed, 1972 insertions, 1661 deletions
diff --git a/.gitignore b/.gitignore
index 8f848483..8a21b668 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,4 +111,7 @@ venv.bak/
# jetbrains
.idea/
-.DS_Store \ No newline at end of file
+.DS_Store
+
+# vscode
+.vscode/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9f2062ce..86035786 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,13 +1,10 @@
repos:
-- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.2.3
+- repo: local
hooks:
- - id: flake8
- additional_dependencies: [
- "flake8-bugbear",
- "flake8-docstrings",
- "flake8-import-order",
- "flake8-polyfill",
- "flake8-string-format",
- "flake8-tidy-imports"
- ] \ No newline at end of file
+ - id: flake8
+ name: Flake8
+ description: This hook runs flake8 within our project's pipenv environment.
+ entry: pipenv run lint
+ language: python
+ types: [python]
+ require_serial: true \ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e2e48bd3..fd0c048f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,6 +13,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
3. **No force-pushes** or modifying the Git history in any way.
4. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there.
* It's common practice for a repository to reject direct pushes to `master`, so make branching a habit!
+ * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
5. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html).
* 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.
@@ -104,6 +105,8 @@ Github [has introduced a new PR feature](https://github.blog/2019-02-14-introduc
This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
+As stated earlier, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
+
## Footnotes
This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md).
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..940f90d5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.7-slim
+
+# Set pip to have cleaner logs and no saved cache
+ENV PIP_NO_CACHE_DIR=false \
+ PIPENV_HIDE_EMOJIS=1 \
+ PIPENV_IGNORE_VIRTUALENVS=1 \
+ PIPENV_NOSPIN=1
+
+# Install pipenv
+RUN pip install -U pipenv
+
+# Copy the project files into working directory
+WORKDIR /bot
+COPY . .
+
+# Install project dependencies
+RUN pipenv install --deploy --system
+
+ENTRYPOINT ["python"]
+CMD ["-m", "bot"]
+
+# Define docker persistent volumes
+VOLUME /bot/bot/log /bot/data
diff --git a/Pipfile b/Pipfile
index 85f8dc87..64c47b2a 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,23 +4,24 @@ verify_ssl = true
name = "pypi"
[packages]
-arrow = "*"
-beautifulsoup4 = "*"
-aiodns = "*"
-pillow = "*"
-pytz = "*"
-fuzzywuzzy = "*"
-discord-py = "~=1.1"
+aiodns = "~=2.0"
+arrow = "~=0.14"
+beautifulsoup4 = "~=4.8"
+discord-py = "~=1.2"
+fuzzywuzzy = "~=0.17"
+pillow = "~=6.1"
+pytz = "~=2019.2"
[dev-packages]
-"flake8" = "*"
-"flake8-bugbear" = "*"
-"flake8-import-order" = "*"
-"flake8-tidy-imports" = "*"
-"flake8-todo" = "*"
-"flake8-string-format" = "*"
-pre-commit = "*"
-flake8-docstrings = "*"
+flake8 = "~=3.7"
+flake8-annotations = "~=1.1"
+flake8-bugbear = "~=19.8"
+flake8-docstrings = "~=1.4"
+flake8-import-order = "~=0.18"
+flake8-string-format = "~=0.2"
+flake8-tidy-imports = "~=2.0"
+flake8-todo = "~=0.7"
+pre-commit = "~=1.18"
[requires]
python_version = "3.7"
diff --git a/Pipfile.lock b/Pipfile.lock
index 1cf9e44a..19ca0618 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "0e7e34beb8c746a91b7e2209586e5c93663bae113b2989af8a0df849cf0d7dc7"
+ "sha256": "da19ab2567a55706054eae245eb95a2b6f861836a47ef40641b0c6976b509c65"
},
"pipfile-spec": 6,
"requires": {
@@ -53,11 +53,11 @@
},
"arrow": {
"hashes": [
- "sha256:3397e5448952e18e1295bf047014659effa5ae8da6a5371d37ff0ddc46fa6872",
- "sha256:6f54d9f016c0b7811fac9fb8c2c7fa7421d80c54dbdd75ffb12913c55db60b8a"
+ "sha256:10257c5daba1a88db34afa284823382f4963feca7733b9107956bed041aff24f",
+ "sha256:c2325911fcd79972cf493cfd957072f9644af8ad25456201ae1ede3316576eb4"
],
"index": "pypi",
- "version": "==0.13.1"
+ "version": "==0.15.2"
},
"async-timeout": {
"hashes": [
@@ -75,12 +75,12 @@
},
"beautifulsoup4": {
"hashes": [
- "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858",
- "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348",
- "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718"
+ "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612",
+ "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b",
+ "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469"
],
"index": "pypi",
- "version": "==4.7.1"
+ "version": "==4.8.0"
},
"cffi": {
"hashes": [
@@ -124,10 +124,10 @@
},
"discord-py": {
"hashes": [
- "sha256:d0ab22f1fee1fcc02ac50a67ff49a5d1f6d7bc7eba77e34e35bd160b3ad3d7e8"
+ "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
],
"index": "pypi",
- "version": "==1.1.1"
+ "version": "==1.2.3"
},
"fuzzywuzzy": {
"hashes": [
@@ -180,35 +180,35 @@
},
"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"
+ "sha256:0804f77cb1e9b6dbd37601cee11283bba39a8d44b9ddb053400c58e0c0d7d9de",
+ "sha256:0ab7c5b5d04691bcbd570658667dd1e21ca311c62dcfd315ad2255b1cd37f64f",
+ "sha256:0b3e6cf3ea1f8cecd625f1420b931c83ce74f00c29a0ff1ce4385f99900ac7c4",
+ "sha256:365c06a45712cd723ec16fa4ceb32ce46ad201eb7bbf6d3c16b063c72b61a3ed",
+ "sha256:38301fbc0af865baa4752ddae1bb3cbb24b3d8f221bf2850aad96b243306fa03",
+ "sha256:3aef1af1a91798536bbab35d70d35750bd2884f0832c88aeb2499aa2d1ed4992",
+ "sha256:3fe0ab49537d9330c9bba7f16a5f8b02da615b5c809cdf7124f356a0f182eccd",
+ "sha256:45a619d5c1915957449264c81c008934452e3fd3604e36809212300b2a4dab68",
+ "sha256:49f90f147883a0c3778fd29d3eb169d56416f25758d0f66775db9184debc8010",
+ "sha256:571b5a758baf1cb6a04233fb23d6cf1ca60b31f9f641b1700bfaab1194020555",
+ "sha256:5ac381e8b1259925287ccc5a87d9cf6322a2dc88ae28a97fe3e196385288413f",
+ "sha256:6153db744a743c0c8c91b8e3b9d40e0b13a5d31dbf8a12748c6d9bfd3ddc01ad",
+ "sha256:6fd63afd14a16f5d6b408f623cc2142917a1f92855f0df997e09a49f0341be8a",
+ "sha256:70acbcaba2a638923c2d337e0edea210505708d7859b87c2bd81e8f9902ae826",
+ "sha256:70b1594d56ed32d56ed21a7fbb2a5c6fd7446cdb7b21e749c9791eac3a64d9e4",
+ "sha256:76638865c83b1bb33bcac2a61ce4d13c17dba2204969dedb9ab60ef62bede686",
+ "sha256:7b2ec162c87fc496aa568258ac88631a2ce0acfe681a9af40842fc55deaedc99",
+ "sha256:7cee2cef07c8d76894ebefc54e4bb707dfc7f258ad155bd61d87f6cd487a70ff",
+ "sha256:7d16d4498f8b374fc625c4037742fbdd7f9ac383fd50b06f4df00c81ef60e829",
+ "sha256:b50bc1780681b127e28f0075dfb81d6135c3a293e0c1d0211133c75e2179b6c0",
+ "sha256:bd0582f831ad5bcad6ca001deba4568573a4675437db17c4031939156ff339fa",
+ "sha256:cfd40d8a4b59f7567620410f966bb1f32dc555b2b19f82a91b147fac296f645c",
+ "sha256:e3ae410089de680e8f84c68b755b42bc42c0ceb8c03dbea88a5099747091d38e",
+ "sha256:e9046e559c299b395b39ac7dbf16005308821c2f24a63cae2ab173bd6aa11616",
+ "sha256:ef6be704ae2bc8ad0ebc5cb850ee9139493b0fc4e81abcc240fb392a63ebc808",
+ "sha256:f8dc19d92896558f9c4317ee365729ead9d7bbcf2052a9a19a3ef17abbb8ac5b"
],
"index": "pypi",
- "version": "==6.0.0"
+ "version": "==6.1.0"
},
"pycares": {
"hashes": [
@@ -243,11 +243,11 @@
},
"pytz": {
"hashes": [
- "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda",
- "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"
+ "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32",
+ "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7"
],
"index": "pypi",
- "version": "==2019.1"
+ "version": "==2019.2"
},
"six": {
"hashes": [
@@ -258,10 +258,10 @@
},
"soupsieve": {
"hashes": [
- "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece",
- "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca"
+ "sha256:605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3",
+ "sha256:b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6"
],
- "version": "==1.9.1"
+ "version": "==1.9.4"
},
"websockets": {
"hashes": [
@@ -309,10 +309,10 @@
"develop": {
"aspy.yaml": {
"hashes": [
- "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3",
- "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482"
+ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc",
+ "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"
],
- "version": "==1.2.0"
+ "version": "==1.3.0"
},
"attrs": {
"hashes": [
@@ -323,10 +323,10 @@
},
"cfgv": {
"hashes": [
- "sha256:6e9f2feea5e84bc71e56abd703140d7a2c250fc5ba38b8702fd6a68ed4e3b2ef",
- "sha256:e7f186d4a36c099a9e20b04ac3108bd8bb9b9257e692ce18c8c3764d5cb12172"
+ "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144",
+ "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289"
],
- "version": "==1.6.0"
+ "version": "==2.0.1"
},
"entrypoints": {
"hashes": [
@@ -337,27 +337,35 @@
},
"flake8": {
"hashes": [
- "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
- "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
+ "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
+ "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
],
"index": "pypi",
- "version": "==3.7.7"
+ "version": "==3.7.8"
+ },
+ "flake8-annotations": {
+ "hashes": [
+ "sha256:6ac7ca1e706307686b60af8043ff1db31dc2cfc1233c8210d67a3d9b8f364736",
+ "sha256:b51131007000d67217608fa028a35ff80aa400b474e5972f1f99c2cf9d26bd2e"
+ ],
+ "index": "pypi",
+ "version": "==1.1.0"
},
"flake8-bugbear": {
"hashes": [
- "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb",
- "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d"
+ "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571",
+ "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8"
],
"index": "pypi",
- "version": "==19.3.0"
+ "version": "==19.8.0"
},
"flake8-docstrings": {
"hashes": [
- "sha256:4e0ce1476b64e6291520e5570cf12b05016dd4e8ae454b8a8a9a48bc5f84e1cd",
- "sha256:8436396b5ecad51a122a2c99ba26e5b4e623bf6e913b0fea0cb6c2c4050f91eb"
+ "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd",
+ "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06"
],
"index": "pypi",
- "version": "==1.3.0"
+ "version": "==1.4.0"
},
"flake8-import-order": {
"hashes": [
@@ -367,13 +375,6 @@
"index": "pypi",
"version": "==0.18.1"
},
- "flake8-polyfill": {
- "hashes": [
- "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
- "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
- ],
- "version": "==1.0.2"
- },
"flake8-string-format": {
"hashes": [
"sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2",
@@ -399,17 +400,17 @@
},
"identify": {
"hashes": [
- "sha256:432c548d6138cb57a3d8f62f079a025a29b8ae34a50dd3b496bbf661818f2bc0",
- "sha256:d4401d60bf1938aa3074a352a5cc9044107edf11a6fedd3a1db172c141619b81"
+ "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017",
+ "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e"
],
- "version": "==1.4.3"
+ "version": "==1.4.7"
},
"importlib-metadata": {
"hashes": [
- "sha256:6a0080fdc87c8225e004b00b55bd1eab153a32ef5a11e17c14de81edbb8ed1a7",
- "sha256:c0bdce522d5b215c710f237cfc1f58ace432affd3052176bbb719f53e2465256"
+ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
+ "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
],
- "version": "==0.11"
+ "version": "==0.23"
},
"mccabe": {
"hashes": [
@@ -418,6 +419,13 @@
],
"version": "==0.6.1"
},
+ "more-itertools": {
+ "hashes": [
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ ],
+ "version": "==7.2.0"
+ },
"nodeenv": {
"hashes": [
"sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
@@ -426,11 +434,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:6ca409d1f22d444af427fb023a33ca8b69625d508a50e1b7eaabd59247c93043",
- "sha256:94dd519597f5bff06a4b0df194a79c524b78f4b1534c1ce63241a9d4fb23b926"
+ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f",
+ "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a"
],
"index": "pypi",
- "version": "==1.16.1"
+ "version": "==1.18.3"
},
"pycodestyle": {
"hashes": [
@@ -441,11 +449,10 @@
},
"pydocstyle": {
"hashes": [
- "sha256:2258f9b0df68b97bf3a6c29003edc5238ff8879f1efb6f1999988d934e432bd8",
- "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4",
- "sha256:ed79d4ec5e92655eccc21eb0c6cf512e69512b4a97d215ace46d17e4990f2039"
+ "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058",
+ "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59"
],
- "version": "==3.0.0"
+ "version": "==4.0.1"
},
"pyflakes": {
"hashes": [
@@ -456,19 +463,21 @@
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
- ],
- "version": "==5.1"
+ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
+ "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
+ "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
+ "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
+ "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
+ "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
+ "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
+ "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
+ "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
+ "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
+ "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
+ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
+ "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
+ ],
+ "version": "==5.1.2"
},
"six": {
"hashes": [
@@ -479,10 +488,9 @@
},
"snowballstemmer": {
"hashes": [
- "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
- "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
+ "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e"
],
- "version": "==1.2.1"
+ "version": "==1.9.1"
},
"toml": {
"hashes": [
@@ -491,19 +499,39 @@
],
"version": "==0.10.0"
},
+ "typed-ast": {
+ "hashes": [
+ "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
+ "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
+ "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
+ "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
+ "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
+ "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
+ "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
+ "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
+ "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
+ "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
+ "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
+ "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
+ "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
+ "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
+ "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
+ ],
+ "version": "==1.4.0"
+ },
"virtualenv": {
"hashes": [
- "sha256:15ee248d13e4001a691d9583948ad3947bcb8a289775102e4c4aa98a8b7a6d73",
- "sha256:bfc98bb9b42a3029ee41b96dc00a34c2f254cbf7716bec824477b2c82741a5c4"
+ "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
+ "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
],
- "version": "==16.5.0"
+ "version": "==16.7.5"
},
"zipp": {
"hashes": [
- "sha256:46dfd547d9ccbf8bdc26ecea52818046bb28509f12bb6a0de1cd66ab06e9a9be",
- "sha256:d7ac25f895fb65bff937b381353c14eb1fa23d35f40abd72a5342cd57eb57fd1"
+ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
+ "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
],
- "version": "==0.5.0"
+ "version": "==0.6.0"
}
}
}
diff --git a/README.md b/README.md
index faef1133..5aaa7530 100755
--- a/README.md
+++ b/README.md
@@ -14,287 +14,6 @@ We know it can be difficult to get into the whole open source thing at first. To
This later evolved into a bot that runs all year, providing season-appropriate functionality and issues that beginners can work on.
## Getting started
+Before you start, please take some time to read through our [contributing guidelines](CONTRIBUTING.md).
-Please ensure you read the [contributing guidelines](CONTRIBUTING.md) in full.
-
-If you are new to this you may find it easier to use PyCharm. [What is PyCharm?](#what-is-pycharm)
-
-1. [Fork the Project](#fork-the-project)
-2. [Clone & Install Packages](#clone--install-packages)
-3. [Test Server](#test-server)
-4. [Bot Account](#bot-account)
-5. [Configuration](#configuration)
-6. [Working with Git](#working-with-git)
-7. [Pull Requests (PRs)](#pull-requests-prs)
-8. [Further Reading](#further-reading)
-
-### What is PyCharm?
-
-PyCharm is a Python IDE (integrated development environment) that is used to make python development quicker and easier.
-
-Our instruction include PyCharm specific steps for those who have it, so you're welcome to give it a go if you haven't tried it before.
-
-You can download PyCharm for free [here](https://www.jetbrains.com/pycharm/).
-
-### Requirements
-
-- [Python 3.7](https://www.python.org/downloads/)
-- [Pipenv](https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv)
-- Git
- - [Windows Installer](https://git-scm.com/download/win)
- - [MacOS Installer](https://sourceforge.net/projects/git-osx-installer/files/) or `brew install git`
-
-### Fork the Project
-
-To fork this project, press the Fork button at the top of the [repository page](https://github.com/python-discord/seasonalbot):
-
-![](https://i.imgur.com/gPfSW8j.png)
-
-In your new forked repository, copy the git url by clicking the green `clone or download` button and then the copy link button:
-
-![](https://i.imgur.com/o6kuQcZ.png)
-
-### Clone & Install Packages
-
-#### With PyCharm:
-
-Load up PyCharm and click `Check Out from Version Control` -> `Git`:
-
-![](https://i.imgur.com/ND7Z5rN.png)
-
-Test the URL with `Test` first and then click `Clone`.
-
-![](https://i.imgur.com/gJkwHA0.png)
-
-Note: PyCharm automatically detects the Pipfile in the project and sets up your packages for you.
-
-#### With console:
-
-Open a console at the directory you want your project to be in and use the git clone command to copy the project files from your fork repository:
-
-```bash
-git clone https://github.com/yourusername/seasonalbot.git
-```
-
-Change your console directory to be within the new project folder (should be named `seasonalbot` usually) and run the pipenv sync command to install and setup your environment.
-```bash
-cd seasonalbot
-pipenv sync --dev
-```
-
-### Test Server
-
-Create a test discord server if you haven't got one already:
-
-![](https://i.imgur.com/49gBlQIl.png)
-
-In your test server, ensure you have the following:
- - `#announcements` channel
- - `#dev-logs` channel
- - `admins` role
- - A channel to test your commands in.
-
-### Bot Account
-
-Go to the [Discord Developers Portal](https://discordapp.com/developers/applications/) and click the `New Application` button, enter the new bot name and click `Create`:
-
-![](https://i.imgur.com/q5wlDPs.png)
-
-In your new application, go to the `Bot` tab, click `Add Bot` and confirm `Yes, do it!`:
-
-![](https://i.imgur.com/PxlscgF.png)
-
-Change your bot's `Public Bot` setting off so only you can invite it, save, and then get your token with the `Copy` button:
-
-![](https://i.imgur.com/EM3NIiB.png)
-
-Save your token somewhere safe so we can put it in the project settings later.
-
-In the `General Information` tab, grab the Client ID:
-
-![](https://i.imgur.com/pG7Ix3n.png)
-
-Add the Client ID to the following invite url and visit it in your browser to invite the bot to your new test server:
-```
-https://discordapp.com/api/oauth2/authorize?client_id=CLIENT_ID_HERE&permissions=8&scope=bot
-```
-
-Optionally, you can generate your own invite url in the `OAuth` tab, after selecting `bot` as the Scope.
-
-### Configuration
-
-#### Environment Variables:
-
-Create a `.env` file in your project root and copy the following text into it:
-```
-SEASONALBOT_DEBUG=True
-SEASONALBOT_TOKEN=0
-SEASONALBOT_GUILD=0
-SEASONALBOT_ADMIN_ROLE_ID=0
-CHANNEL_ANNOUNCEMENTS=0
-CHANNEL_DEVLOG=0
-```
-
-Edit the variable values to match your own bot token that you put aside earlier, the ID of your Guild, the Announcements and Dev-logs channel IDs and the admin role ID. To get the role ID, you can make it mentionable, and send an escaped mention like `\@admin`:
-
-![](https://i.imgur.com/qANC8jA.png)
-
-Note: These are only the basic environment variables. Each season will likely have their own variables needing to set, so be sure to check out the respective seasonal setups that you're working on.
-
-#### PyCharm Project Config:
-
-Copy the contents of your `.env` file first.
-Click the `Add Configuration` button in the top right of the window.
-
-![](https://i.imgur.com/Qj2RDwU.png)
-
-In the Configuration Window do the following:
-1. Click the plus on the top left and select `Python`.
-2. Add `bot` to the `Script path` input box.
-5. Add `-m` to the `Interpreter options` input box.
-
-![](https://i.imgur.com/3Cs05fg.png)
-
-Click the folder icon for the `Environment variables` input box. In the new window, click the paste icon on the right side and click `OK`:
-
-![](https://i.imgur.com/ET6gC3e.png)
-
-Note: This should have your actual bot token and IDs as the values. If you copied the blank template from above and didn't update it, go back to do so first.
-
-Click `OK` to save and close the Configuration window.
-
-From now on, to run the bot you only need to click the `Run` button in the top right of PyCharm:
-
-![](https://i.imgur.com/YmqnLqC.png)
-
-#### In console:
-
-Since we have the `.env` file in the project root, anytime we run Pipenv and it'll automatically load the environment variables present within.
-
-To run the the bot, use:
-```bash
-pipenv run start
-```
-
-### Working with Git
-
-Working with git can be intimidating at first, so feel free to ask for any help in the server.
-
-For each push request you work on, you'll want to push your commits to a branch just for it in your repository. This keeps your `master` branch clean, so you can pull changes from the original repository without pulling the same commits you've already added to your fork.
-
-Once you finish a push request, you can delete your branch, and pull the changes from the original repository into your master branch. With the master branch updated, you can now create a new branch to work on something else.
-
-Note: You should never commit directly to the original repository's `master` branch. All additions to the `master` branch of the original repository **must** be put through a PR first.
-
-#### Precommit Hook
-
-Projects need to pass linting checks to be able to successfully build and pass as in push requests.
-Read [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
-
-To install the precommit hook, which checks your code before commits are submitted, do the following:
-```bash
-pipenv run precommit
-```
-
-#### In PyCharm
-
-The PyCharm interface has a set of Git actions on the top right section of your workspace:
-
-![](https://i.imgur.com/1LsyMx9.png)
-
-In order from left to right these are:
-- Update
-- Commit
-- History
-- Revert
-
-As we work on our project we are going to want to make commits. Commits are effectively a log of changes you made over time.
-
-Before making any commits, you should make the branch for the PR you're about to work on. At the bottom of the PyCharm workspace, you'll see the current git branch, which you can click to show a menu of actions. You can click `New Branch` to create a new one, and name it something short that references what the PR is to accomplish. Before making new branches, be sure you change to `master` and ensure it's up to date before creating a new branch.
-
-![](https://i.imgur.com/A9zV4lF.png)
-
-After making changes to the project files, you can commit by clicking the `Commit` button that's part of the git actions available on the top right of your workspace.
-
-The flow of making a commit is as follows:
-
-1. Select the files you wish to commit.
-2. Write a brief description of what your commit is.
-3. See the actual changes you commit does, and optionally tick/untick specific changes to only commit the changes you want.
-4. Click `Commit`.
-
-![](https://i.imgur.com/MvGQKT9.png)
-
-To create a new branch,
-
-Once you have made a few commits and are ready to push them to your fork repository, you can do any of the following:
-- Use the VSC Menu `VSC` -> `Git` -> `Push`
-- Use the VSC popup `alt-\ ` -> `Push`
-- Use the Push shortcut: `CTRL+SHIFT+K`
-
-You should see a window listing commits and changed files.
-Click `Push` when you're ready and it'll submit the commits to your forked repository.
-
-#### With console:
-
-When you create a commit, you will either want to add certain files for that commit, or apply all changes to every file.
-
-It's recommended to add only the files that you want to apply changes to before committing, rather than committing everything tracked. This brings more caution, and allows you to check first each file is relevant to the PR you're working on.
-
-To only add certain files, you will need to do that first before committing:
-```bash
-# create/use feature branch
-git checkout some_new_feature
-
-# stage a single file
-git add my_file.py
-
-# stage multiple files in one command
-git add file_one.py file_two.py file_three.py
-
-# stage all files in current directory
-git add .
-
-# remove a file from staging
-git rm --cached wrong_file.py
-
-# finally commit
-git commit -m "commit message here"
-```
-
-If you're absolutely sure that the tracked changes are all within scope of your PR, you can try using the commit all command.
-To commit all changes across all tracked files, just use the `-a` flag:
-```bash
-git commit -a -m "commit message here"
-```
-
-### Pull Requests (PRs)
-
-Go to the [Pull requests](https://github.com/python-discord/seasonalbot/pulls) and click the green New Pull Request button:
-
-![](https://i.imgur.com/fB4a2wQ.png)
-
-Click `Compare across forks`.
-Change the `head fork` dropdown box to your fork (`your_username/seasonalbot`).
-Click `Create pull request`.
-
-![](https://i.imgur.com/N2X9A9v.png)
-
-In the PR details, put a concise and informative title.
-Write a description for the PR regarding the changes and any related issues.
-Ensure `Allow edits from maintainers.` has been ticked.
-Click `Create pull request`.
-
-![](https://i.imgur.com/yMJVMNj.png)
-
-A maintainer or other participants may comment or review your PR, and may suggest changes. Communicate in the PR ticket and try ensure you respond timely on any questions or feedback you might get to prevent a PR being marked stale.
-
-After a successful review by a maintainer, the PR may be merged successfully, and the new feature will deploy to the live bot within 10 minutes usually.
-
-### Further Reading
-
-- [Git](https://try.github.io)
-- [PyCharm](https://www.jetbrains.com/help/pycharm/quick-start-guide.html)
-- [Pipenv](https://pipenv.readthedocs.io)
-- [discord.py](https://discordpy.readthedocs.io/en/v1.1.1/)
-- [Environment Variables (youtube)](https://youtu.be/bEroNNzqlF4?t=27)
+See [Seasonalbot's Wiki](https://github.com/python-discord/seasonalbot/wiki) for in-depth guides on getting started with the project! \ No newline at end of file
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index c98bc4fc..89205c1e 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -33,20 +33,16 @@ jobs:
displayName: 'Run linter'
- job: build
- displayName: 'Build Containers'
+ displayName: 'Build & Push Container'
+ condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
dependsOn: 'test'
steps:
- - task: Docker@1
- displayName: 'Login: Docker Hub'
-
- inputs:
- containerregistrytype: 'Container Registry'
- dockerRegistryEndpoint: 'DockerHub'
- command: 'login'
-
- - task: ShellScript@2
- displayName: 'Build and deploy containers'
-
+ - task: Docker@2
+ displayName: 'Build & Push Container'
inputs:
- scriptPath: scripts/deploy-azure.sh
+ containerRegistry: 'DockerHub'
+ repository: 'pythondiscord/seasonalbot'
+ command: 'buildAndPush'
+ Dockerfile: '**/Dockerfile'
+ tags: 'latest'
diff --git a/bot/__init__.py b/bot/__init__.py
index 9e0290a7..4729e50c 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -13,7 +13,7 @@ logging.TRACE = 5
logging.addLevelName(logging.TRACE, "TRACE")
-def monkeypatch_trace(self, msg, *args, **kwargs):
+def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
"""
Log 'msg % args' with severity 'TRACE'.
diff --git a/bot/__main__.py b/bot/__main__.py
index a3b68ec1..9dc0b173 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,8 +1,11 @@
import logging
-from bot.constants import Client, bot
+from bot.bot import bot
+from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS
+from bot.decorators import in_channel_check
log = logging.getLogger(__name__)
+bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES))
bot.load_extension("bot.seasons")
bot.run(Client.token)
diff --git a/bot/bot.py b/bot/bot.py
index 24e919f2..2a723021 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -4,14 +4,14 @@ from traceback import format_exc
from typing import List
from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord import Embed
+from discord import DiscordException, Embed
from discord.ext import commands
-from bot import constants
+from bot.constants import Channels, Client
log = logging.getLogger(__name__)
-__all__ = ('SeasonalBot',)
+__all__ = ('SeasonalBot', 'bot')
class SeasonalBot(commands.Bot):
@@ -23,7 +23,7 @@ class SeasonalBot(commands.Bot):
connector=TCPConnector(resolver=AsyncResolver(), family=socket.AF_INET)
)
- def load_extensions(self, exts: List[str]):
+ def load_extensions(self, exts: List[str]) -> None:
"""Unload all current extensions, then load the given extensions."""
# Unload all cogs
extensions = list(self.extensions.keys())
@@ -40,9 +40,9 @@ class SeasonalBot(commands.Bot):
except Exception as e:
log.error(f'Failed to load extension {cog}: {repr(e)} {format_exc()}')
- async def send_log(self, title: str, details: str = None, *, icon: str = None):
+ async def send_log(self, title: str, details: str = None, *, icon: str = None) -> None:
"""Send an embed message to the devlog channel."""
- devlog = self.get_channel(constants.Channels.devlog)
+ devlog = self.get_channel(Channels.devlog)
if not devlog:
log.warning("Log failed to send. Devlog channel not found.")
@@ -56,9 +56,12 @@ class SeasonalBot(commands.Bot):
await devlog.send(embed=embed)
- async def on_command_error(self, context, exception):
+ async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:
"""Check command errors for UserInputError and reset the cooldown if thrown."""
if isinstance(exception, commands.UserInputError):
context.command.reset_cooldown(context)
else:
await super().on_command_error(context, exception)
+
+
+bot = SeasonalBot(command_prefix=Client.prefix)
diff --git a/bot/constants.py b/bot/constants.py
index 67dd9328..dbf35754 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -2,11 +2,10 @@ import logging
from os import environ
from typing import NamedTuple
-from bot.bot import SeasonalBot
-
__all__ = (
- "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles",
- "Tokens", "ERROR_REPLIES", "bot"
+ "AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens",
+ "WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES",
+ "POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES",
)
log = logging.getLogger(__name__)
@@ -23,12 +22,12 @@ class AdventOfCode:
class Channels(NamedTuple):
admins = 365960823622991872
- announcements = int(environ.get('CHANNEL_ANNOUNCEMENTS', 354619224620138496))
+ announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496))
big_brother_logs = 468507907357409333
bot = 267659945086812160
checkpoint_test = 422077681434099723
devalerts = 460181980097675264
- devlog = int(environ.get('CHANNEL_DEVLOG', 548438471685963776))
+ devlog = int(environ.get("CHANNEL_DEVLOG", 548438471685963776))
devtest = 414574275865870337
help_0 = 303906576991780866
help_1 = 303906556754395136
@@ -45,20 +44,22 @@ class Channels(NamedTuple):
off_topic_2 = 463035268514185226
python = 267624335836053506
reddit = 458224812528238616
- seasonalbot_chat = int(environ.get('CHANNEL_SEASONALBOT_CHAT', 542272993192050698))
+ seasonalbot_chat = int(environ.get("CHANNEL_SEASONALBOT_CHAT", 542272993192050698))
+ seasonalbot_commands = int(environ.get("CHANNEL_SEASONALBOT_COMMANDS", 607247579608121354))
+ seasonalbot_voice = int(environ.get("CHANNEL_SEASONALBOT_VOICE", 606259004230074378))
staff_lounge = 464905259261755392
verification = 352442727016693763
python_discussion = 267624335836053506
- show_your_projects = 303934982764625920
+ show_your_projects = int(environ.get("CHANNEL_SHOW_YOUR_PROJECTS", 303934982764625920))
show_your_projects_discussion = 360148304664723466
class Client(NamedTuple):
- guild = int(environ.get('SEASONALBOT_GUILD', 267624335836053506))
+ guild = int(environ.get("SEASONALBOT_GUILD", 267624335836053506))
prefix = environ.get("PREFIX", ".")
- token = environ.get('SEASONALBOT_TOKEN')
- debug = environ.get('SEASONALBOT_DEBUG', '').lower() == 'true'
- season_override = environ.get('SEASON_OVERRIDE')
+ token = environ.get("SEASONALBOT_TOKEN")
+ debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true"
+ season_override = environ.get("SEASON_OVERRIDE")
class Colours:
@@ -80,7 +81,7 @@ class Emojis:
terning1 = "<:terning1:431249668983488527>"
terning2 = "<:terning2:462339216987127808>"
terning3 = "<:terning3:431249694467948544>"
- terning4 = "<:terning4:431249704769290241>"
+ terning4 = "<:terning4:579980271475228682>"
terning5 = "<:terning5:431249716328792064>"
terning6 = "<:terning6:431249726705369098>"
@@ -94,7 +95,7 @@ class Hacktoberfest(NamedTuple):
class Roles(NamedTuple):
- admin = int(environ.get('SEASONALBOT_ADMIN_ROLE_ID', 267628507062992896))
+ admin = int(environ.get("SEASONALBOT_ADMIN_ROLE_ID", 267628507062992896))
announcements = 463658397560995840
champion = 430492892331769857
contributor = 295488872404484098
@@ -116,6 +117,58 @@ class Tokens(NamedTuple):
youtube = environ.get("YOUTUBE_API_KEY")
+# Default role combinations
+MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
+STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
+
+# Whitelisted channels
+WHITELISTED_CHANNELS = (
+ Channels.bot, Channels.seasonalbot_commands,
+ Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2,
+ Channels.devtest,
+)
+
+# Bot replies
+NEGATIVE_REPLIES = [
+ "Noooooo!!",
+ "Nope.",
+ "I'm sorry Dave, I'm afraid I can't do that.",
+ "I don't think so.",
+ "Not gonna happen.",
+ "Out of the question.",
+ "Huh? No.",
+ "Nah.",
+ "Naw.",
+ "Not likely.",
+ "No way, José.",
+ "Not in a million years.",
+ "Fat chance.",
+ "Certainly not.",
+ "NEGATORY.",
+ "Nuh-uh.",
+ "Not in my house!",
+]
+
+POSITIVE_REPLIES = [
+ "Yep.",
+ "Absolutely!",
+ "Can do!",
+ "Affirmative!",
+ "Yeah okay.",
+ "Sure.",
+ "Sure thing!",
+ "You're the boss!",
+ "Okay.",
+ "No problem.",
+ "I got you.",
+ "Alright.",
+ "You got it!",
+ "ROGER THAT",
+ "Of course!",
+ "Aye aye, cap'n!",
+ "I'll allow it.",
+]
+
ERROR_REPLIES = [
"Please don't do that.",
"You have to stop.",
@@ -128,6 +181,3 @@ ERROR_REPLIES = [
"Noooooo!!",
"I can't believe you've done this",
]
-
-
-bot = SeasonalBot(command_prefix=Client.prefix)
diff --git a/bot/decorators.py b/bot/decorators.py
index dfe80e5c..dbaad4a2 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,24 +1,33 @@
import logging
import random
+import typing
from asyncio import Lock
from functools import wraps
from weakref import WeakValueDictionary
from discord import Colour, Embed
from discord.ext import commands
-from discord.ext.commands import Context
+from discord.ext.commands import CheckFailure, Context
from bot.constants import ERROR_REPLIES
log = logging.getLogger(__name__)
-def with_role(*role_ids: int):
+class InChannelCheckFailure(CheckFailure):
+ """Check failure when the user runs a command in a non-whitelisted channel."""
+
+ pass
+
+
+def with_role(*role_ids: int) -> bool:
"""Check to see whether the invoking user has any of the roles specified in role_ids."""
- async def predicate(ctx: Context):
+ async def predicate(ctx: Context) -> bool:
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.")
+ 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:
@@ -26,39 +35,89 @@ def with_role(*role_ids: int):
log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.")
return True
- log.debug(f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected.")
+ 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
return commands.check(predicate)
-def without_role(*role_ids: int):
+def without_role(*role_ids: int) -> bool:
"""Check whether the invoking user does not have all of the roles specified in role_ids."""
- async def predicate(ctx: Context):
+ async def predicate(ctx: Context) -> bool:
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.")
+ 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
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}.")
+ 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
return commands.check(predicate)
-def in_channel(channel_id):
- """Check that the command invocation is in the channel specified by channel_id."""
- async def predicate(ctx: Context):
- check = ctx.channel.id == channel_id
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the in_channel check was {check}.")
- return check
- return commands.check(predicate)
+def in_channel_check(*channels: int, bypass_roles: typing.Container[int] = None) -> typing.Callable[[Context], bool]:
+ """Checks that the message is in a whitelisted channel or optionally has a bypass role."""
+ def predicate(ctx: Context) -> bool:
+ if not ctx.guild:
+ log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.")
+ return True
+
+ if ctx.channel.id in channels:
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command "
+ f"and the command was used in a whitelisted channel."
+ )
+ return True
+
+ if hasattr(ctx.command.callback, "in_channel_override"):
+ log.debug(
+ f"{ctx.author} called the '{ctx.command.name}' command "
+ f"and the command was whitelisted to bypass the in_channel check."
+ )
+ return True
+
+ if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles):
+ log.debug(
+ f"{ctx.author} called the '{ctx.command.name}' command and "
+ f"had a role to bypass the in_channel check."
+ )
+ return True
+
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The in_channel check failed."
+ )
+
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ raise InChannelCheckFailure(
+ f"Sorry, but you may only use this command within {channels_str}."
+ )
+
+ return predicate
+
+
+in_channel = commands.check(in_channel_check)
+
+
+def override_in_channel(func: typing.Callable) -> typing.Callable:
+ """
+ Set command callback attribute for detection in `in_channel_check`.
+
+ This decorator has to go before (below) below the `command` decorator.
+ """
+ func.in_channel_override = True
+ return func
-def locked():
+def locked() -> typing.Union[typing.Callable, None]:
"""
Allows the user to only run one instance of the decorated command at a time.
@@ -66,11 +125,11 @@ def locked():
This decorator has to go before (below) the `command` decorator.
"""
- def wrap(func):
+ def wrap(func: typing.Callable) -> typing.Union[typing.Callable, None]:
func.__locks = WeakValueDictionary()
@wraps(func)
- async def inner(self, ctx, *args, **kwargs):
+ async def inner(self: typing.Callable, ctx: Context, *args, **kwargs) -> typing.Union[typing.Callable, None]:
lock = func.__locks.setdefault(ctx.author.id, Lock())
if lock.locked():
embed = Embed()
diff --git a/bot/pagination.py b/bot/pagination.py
index e6cea41f..f1233482 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -22,26 +22,16 @@ class EmptyPaginatorEmbed(Exception):
class LinePaginator(Paginator):
- """
- A class that aids in paginating code blocks for Discord messages.
-
- Attributes
- -----------
- prefix: :class:`str`
- The prefix inserted to every page. e.g. three backticks.
- suffix: :class:`str`
- The suffix appended at the end of every page. e.g. three backticks.
- max_size: :class:`int`
- The maximum amount of codepoints allowed in a page.
- max_lines: :class:`int`
- The maximum amount of lines allowed in a page.
- """
+ """A class that aids in paginating code blocks for Discord messages."""
- def __init__(self, prefix='```', suffix='```', max_size=2000, max_lines=None):
+ def __init__(self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None):
"""
Overrides the Paginator.__init__ from inside discord.ext.commands.
- Allows for configuration of the maximum number of lines per page.
+ `prefix` and `suffix` will be prepended and appended respectively to every page.
+
+ `max_size` and `max_lines` denote the maximum amount of codepoints and lines
+ allowed per page.
"""
self.prefix = prefix
self.suffix = suffix
@@ -52,26 +42,16 @@ class LinePaginator(Paginator):
self._count = len(prefix) + 1 # prefix + newline
self._pages = []
- def add_line(self, line='', *, empty=False):
+ def add_line(self, line: str = '', *, empty: bool = False) -> None:
"""
Adds a line to the current page.
- If the line exceeds the `max_size` then an exception is raised.
+ If the line exceeds the `max_size` then a RuntimeError is raised.
Overrides the Paginator.add_line from inside discord.ext.commands in order to allow
configuration of the maximum number of lines per page.
- Parameters
- -----------
- line: str
- The line to add.
- empty: bool
- Indicates if another empty line should be added.
-
- Raises
- ------
- RuntimeError
- The line was too big for the current `max_size`.
+ If `empty` is True, an empty line will be placed after the a given `line`.
"""
if len(line) > self.max_size - len(self.prefix) - 2:
raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2))
@@ -105,7 +85,11 @@ class LinePaginator(Paginator):
When used, this will send a message using `ctx.send()` and apply a set of reactions to it.
These reactions may be used to change page, or to remove pagination from the message.
- Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds).
+
+ Pagination will also be removed automatically if no reaction is added for `timeout` seconds,
+ defaulting to five minutes (300 seconds).
+
+ If `empty` is True, an empty line will be placed between each given line.
>>> embed = Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
@@ -113,22 +97,8 @@ class LinePaginator(Paginator):
... (line for line in lines),
... ctx, embed
... )
-
- :param lines: The lines to be paginated
- :param ctx: Current context object
- :param embed: A pre-configured embed to be used as a template for each page
- :param prefix: Text to place before each page
- :param suffix: Text to place after each page
- :param max_lines: The maximum number of lines on each page
- :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
"""
- def event_check(reaction_: Reaction, user_: Member):
+ def event_check(reaction_: Reaction, user_: Member) -> bool:
"""Make sure that this reaction is what we want to operate on."""
no_restrictions = (
# Pagination is not restricted
@@ -304,7 +274,7 @@ class ImagePaginator(Paginator):
Refer to ImagePaginator.paginate for documentation on how to use.
"""
- def __init__(self, prefix="", suffix=""):
+ def __init__(self, prefix: str = "", suffix: str = ""):
super().__init__(prefix, suffix)
self._current_page = [prefix]
self.images = []
@@ -314,8 +284,7 @@ class ImagePaginator(Paginator):
"""
Adds a line to each page, usually just 1 line in this context.
- :param line: str to be page content / title
- :param empty: if there should be new lines between entries
+ If `empty` is True, an empty line will be placed after a given `line`.
"""
if line:
self._count = len(line)
@@ -325,11 +294,7 @@ class ImagePaginator(Paginator):
self.close_page()
def add_image(self, image: str = None) -> None:
- """
- Adds an image to a page.
-
- :param image: image url to be appended
- """
+ """Adds an image to a page given the url."""
self.images.append(image)
@classmethod
@@ -339,33 +304,21 @@ class ImagePaginator(Paginator):
"""
Use a paginator and set of reactions to provide pagination over a set of title/image pairs.
- The reactions are used to switch page, or to finish with pagination.
+ `pages` is a list of tuples of page title/image url pairs.
+ `prefix` and `suffix` will be prepended and appended respectively to the message.
When used, this will send a message using `ctx.send()` and apply a set of reactions to it.
These reactions may be used to change page, or to remove pagination from the message.
- Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
+ Note: Pagination will be removed automatically if no reaction is added for `timeout` seconds,
+ defaulting to five minutes (300 seconds).
>>> embed = Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
>>> await ImagePaginator.paginate(pages, ctx, embed)
-
- Parameters
- -----------
- :param pages: An iterable of tuples with title for page, and img url
- :param ctx: ctx for message
- :param embed: base embed to modify
- :param prefix: prefix of message
- :param suffix: suffix of message
- :param timeout: timeout for when reactions get auto-removed
"""
def check_event(reaction_: Reaction, member: Member) -> bool:
- """
- Checks each reaction added, if it matches our conditions pass the wait_for.
-
- :param reaction_: reaction added
- :param member: reaction added by member
- """
+ """Checks each reaction added, if it matches our conditions pass the wait_for."""
return all((
# Reaction is on the same message sent
reaction_.message.id == message.id,
diff --git a/bot/resources/easter/bunny_names.json b/bot/resources/easter/bunny_names.json
new file mode 100644
index 00000000..8c97169c
--- /dev/null
+++ b/bot/resources/easter/bunny_names.json
@@ -0,0 +1,29 @@
+{
+ "names": [
+ "Flopsy",
+ "Hopsalot",
+ "Thumper",
+ "Nibbles",
+ "Daisy",
+ "Fuzzy",
+ "Cottontail",
+ "Carrot Top",
+ "Marshmallow",
+ "Lucky",
+ "Clover",
+ "Daffodil",
+ "Buttercup",
+ "Goldie",
+ "Dizzy",
+ "Trixie",
+ "Snuffles",
+ "Hopscotch",
+ "Skipper",
+ "Thunderfoot",
+ "Bigwig",
+ "Dandelion",
+ "Pipkin",
+ "Buckthorn",
+ "Skipper"
+ ]
+}
diff --git a/bot/resources/easter/easter_egg_facts.json b/bot/resources/easter/easter_egg_facts.json
new file mode 100644
index 00000000..b0650b84
--- /dev/null
+++ b/bot/resources/easter/easter_egg_facts.json
@@ -0,0 +1,17 @@
+[
+ "The first story of a rabbit (later named the \"Easter Bunny\") hiding eggs in a garden was published in 1680.",
+ "Rabbits are known to be prolific pro creators and are an ancient symbol of fertility and new life. The German immigrants brought the tale of Easter Bunny in the 1700s with the tradition of an egg-laying hare called \"Osterhase\". The kids then would make nests in which the creature would lay coloured eggs. The tradition has been revolutionized in the form of candies and gifts instead of eggs.",
+ "In earlier days, a festival of egg throwing was held in church, when the priest would throw a hard-boiled egg to one of the choirboys. It was then tossed from one choirboy to the next and whoever held the egg when the clock struck 12 on Easter, was the winner and could keep it.",
+ "In medieval times, Easter eggs were boiled with onions to give them a golden sheen. Edward I went beyond this tradition in 1290 and ordered 450 eggs to be covered in gold leaf and given as Easter gifts.",
+ "Decorating Easter eggs is an ancient tradition that dates back to 13th century. One of the explanations for this custom is that eggs were considered as a forbidden food during the Lenten season (40 days before Easter). Therefore, people would paint and decorate them to mark an end of the period of penance and fasting and later eat them on Easter. The tradition of decorating eggs is called Pysanka which is creating a traditional Ukrainian folk design using wax-resist method.",
+ "Members of the Greek Orthodox faith often paint their Easter eggs red, which symbolizes Jesus' blood and his victory over death. The color red, symbolizes renewal of life, such as, Jesus' resurrection.",
+ "Eggs rolling take place in many parts of the world which symbolizes stone which was rolled away from the tomb where Jesus' body was laid after his death.",
+ "Easter eggs have been considered as a symbol of fertility, rebirth and new life. The custom of giving eggs has been derived from Egyptians, Persians, Gauls, Greeks, and Romans.",
+ "The first chocolate Easter egg was made by Fry's in 1873. Before this, people would give hollow cardboard eggs, filled with gifts.",
+ "The tallest chocolate Easter egg was made in Italy in 2011. Standing 10.39 metres tall and weighing 7,200 kg, it was taller than a giraffe and heavier than an elephant.",
+ "The largest ever Easter egg hunt was in Florida, where 9,753 children searched for 501,000 eggs.",
+ "In 2007, an Easter egg covered in diamonds sold for almost £9 million. Every hour, a cockerel made of jewels pops up from the top of the Faberge egg, flaps its wings four times, nods its head three times and makes a crowing noise. The gold-and-pink enamel egg was made by the Russian royal family as an engagement gift for French aristocrat Baron Edouard de Rothschild.",
+ "The White House held their first official egg roll in 1878 when Rutherford B. Hayes was the President. It is a race in which children push decorated, hard-boiled eggs across the White House lawn as an annual event held the Monday after Easter. In 2009, the Obamas hosted their first Easter egg roll with the theme, \"Let's go play\" which was meant to encourage young people to lead healthy and active lives.",
+ "80 million chocolate Easter eggs are sold each year. This accounts for 10% of Britain's annual spending on chocolate!",
+ "John Cadbury soon followed suit and made his first Cadbury Easter egg in 1875. By 1892 the company was producing 19 different lines, all made from dark chocolate."
+]
diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json
new file mode 100644
index 00000000..e93f6dad
--- /dev/null
+++ b/bot/resources/easter/easter_riddle.json
@@ -0,0 +1,82 @@
+[
+ {
+ "question": "What kind of music do bunnies like?",
+ "riddles": [
+ "Two words",
+ "Jump to the beat"
+ ],
+ "correct_answer": "Hip hop"
+ },
+ {
+ "question": "What kind of jewelry do rabbits wear?",
+ "riddles": [
+ "They can eat it too",
+ "14 ___ gold"
+ ],
+ "correct_answer": "14 carrot gold"
+ },
+ {
+ "question": "What does the easter bunny get for making a basket?",
+ "riddles": [
+ "KOBE!",
+ "1+1 = ?"
+ ],
+ "correct_answer": "2 points"
+ },
+ {
+ "question": "Where does the easter bunny eat breakfast?",
+ "riddles": [
+ "No waffles here",
+ "An international home"
+ ],
+ "correct_answer": "IHOP"
+ },
+ {
+ "question": "What do you call a rabbit with fleas?",
+ "riddles": [
+ "A bit of a looney tune",
+ "What's up Doc?"
+ ],
+ "correct_answer": "Bugs Bunny"
+ },
+ {
+ "question": "Why was the little girl sad after the race?",
+ "riddles": [
+ "2nd place?",
+ "Who beat her?"
+ ],
+ "correct_answer": "Because an egg beater"
+ },
+ {
+ "question": "What happened to the Easter Bunny when he misbehaved at school?",
+ "riddles": [
+ "Won't be back anymore",
+ "Worse than suspension"
+ ],
+ "correct_answer": "He was eggspelled"
+ },
+ {
+ "question": "What kind of bunny can't hop?",
+ "riddles": [
+ "Might melt in the sun",
+ "Fragile and yummy"
+ ],
+ "correct_answer": "A chocolate one"
+ },
+ {
+ "question": "Where does the Easter Bunny get his eggs?",
+ "riddles": [
+ "Not a bush or tree",
+ "Emoji for a body part"
+ ],
+ "correct_answer": "Eggplants"
+ },
+ {
+ "question": "Why did the Easter Bunny have to fire the duck?",
+ "riddles": [
+ "Quack",
+ "MY EGGS!!"
+ ],
+ "correct_answer": "He kept quacking the eggs"
+ }
+]
diff --git a/bot/resources/easter/egghead_questions.json b/bot/resources/easter/egghead_questions.json
index e4e21ebe..5535f8e0 100644
--- a/bot/resources/easter/egghead_questions.json
+++ b/bot/resources/easter/egghead_questions.json
@@ -47,7 +47,7 @@
"1999",
"1970"
],
- "correct_answer": 2
+ "correct_answer": 3
},
{
"question": "Who is considered to be the founder of Earth Day?",
@@ -178,4 +178,4 @@
],
"correct_answer": 0
}
-] \ No newline at end of file
+]
diff --git a/bot/resources/evergreen/game_recs/chrono_trigger.json b/bot/resources/evergreen/game_recs/chrono_trigger.json
new file mode 100644
index 00000000..219c1e78
--- /dev/null
+++ b/bot/resources/evergreen/game_recs/chrono_trigger.json
@@ -0,0 +1,7 @@
+{
+ "title": "Chrono Trigger",
+ "description": "One of the best games of all time. A brilliant story involving time-travel with loveable characters. It has a brilliant score by Yasonuri Mitsuda and artwork by Akira Toriyama. With over 20 endings and New Game+, there is a huge amount of replay value here.",
+ "link": "https://rawg.io/games/chrono-trigger-1995",
+ "image": "https://vignette.wikia.nocookie.net/chrono/images/2/24/Chrono_Trigger_cover.jpg",
+ "author": "352635617709916161"
+} \ No newline at end of file
diff --git a/bot/resources/evergreen/game_recs/digimon_world.json b/bot/resources/evergreen/game_recs/digimon_world.json
new file mode 100644
index 00000000..a2820f8e
--- /dev/null
+++ b/bot/resources/evergreen/game_recs/digimon_world.json
@@ -0,0 +1,7 @@
+{
+ "title": "Digimon World",
+ "description": "A great mix of town-building and pet-raising set in the Digimon universe. With plenty of Digimon to raise and recruit to the village, this charming game will keep you occupied for a long time.",
+ "image": "https://www.mobygames.com/images/covers/l/437308-digimon-world-playstation-front-cover.jpg",
+ "link": "https://rawg.io/games/digimon-world",
+ "author": "352635617709916161"
+} \ No newline at end of file
diff --git a/bot/resources/evergreen/game_recs/doom_2.json b/bot/resources/evergreen/game_recs/doom_2.json
new file mode 100644
index 00000000..e228e2b1
--- /dev/null
+++ b/bot/resources/evergreen/game_recs/doom_2.json
@@ -0,0 +1,7 @@
+{
+ "title": "Doom II",
+ "description": "Doom 2 was one of the first FPS games that I truly enjoyed. It offered awesome weapons, terrifying demons to kill, and a great atmosphere to do it in.",
+ "image": "https://upload.wikimedia.org/wikipedia/en/thumb/2/29/Doom_II_-_Hell_on_Earth_Coverart.png/220px-Doom_II_-_Hell_on_Earth_Coverart.png",
+ "link": "https://rawg.io/games/doom-ii",
+ "author": "352635617709916161"
+} \ No newline at end of file
diff --git a/bot/resources/evergreen/game_recs/skyrim.json b/bot/resources/evergreen/game_recs/skyrim.json
new file mode 100644
index 00000000..09f93563
--- /dev/null
+++ b/bot/resources/evergreen/game_recs/skyrim.json
@@ -0,0 +1,7 @@
+{
+ "title": "Elder Scrolls V: Skyrim",
+ "description": "The latest mainline Elder Scrolls game offered a fantastic role-playing experience with untethered freedom and a great story. Offering vast mod support, the game has endless customization and replay value.",
+ "image": "https://upload.wikimedia.org/wikipedia/en/1/15/The_Elder_Scrolls_V_Skyrim_cover.png",
+ "link": "https://rawg.io/games/the-elder-scrolls-v-skyrim",
+ "author": "352635617709916161"
+} \ No newline at end of file
diff --git a/bot/resources/evergreen/speedrun_links.json b/bot/resources/evergreen/speedrun_links.json
new file mode 100644
index 00000000..acb5746a
--- /dev/null
+++ b/bot/resources/evergreen/speedrun_links.json
@@ -0,0 +1,18 @@
+ [
+ "https://www.youtube.com/watch?v=jNE28SDXdyQ",
+ "https://www.youtube.com/watch?v=iI8Giq7zQDk",
+ "https://www.youtube.com/watch?v=VqNnkqQgFbc",
+ "https://www.youtube.com/watch?v=Gum4GI2Jr0s",
+ "https://www.youtube.com/watch?v=5YHjHzHJKkU",
+ "https://www.youtube.com/watch?v=X0pJSTy4tJI",
+ "https://www.youtube.com/watch?v=aVFq0H6D6_M",
+ "https://www.youtube.com/watch?v=1O6LuJbEbSI",
+ "https://www.youtube.com/watch?v=Bgh30BiWG58",
+ "https://www.youtube.com/watch?v=wwvgAAvhxM8",
+ "https://www.youtube.com/watch?v=0TWQr0_fi80",
+ "https://www.youtube.com/watch?v=hatqZby-0to",
+ "https://www.youtube.com/watch?v=tmnMq2Hw72w",
+ "https://www.youtube.com/watch?v=UTkyeTCAucA",
+ "https://www.youtube.com/watch?v=67kQ3l-1qMs",
+ "https://www.youtube.com/watch?v=14wqBA5Q1yc"
+]
diff --git a/bot/resources/halloween/github_links.json b/bot/resources/halloween/github_links.json
index e69de29b..0967ef42 100644
--- a/bot/resources/halloween/github_links.json
+++ b/bot/resources/halloween/github_links.json
@@ -0,0 +1 @@
+{}
diff --git a/bot/resources/persist/egg_hunt.sqlite b/bot/resources/persist/egg_hunt.sqlite
deleted file mode 100644
index 6a7ae32d..00000000
--- a/bot/resources/persist/egg_hunt.sqlite
+++ /dev/null
Binary files differ
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py
index 1512fae2..7faf9164 100644
--- a/bot/seasons/__init__.py
+++ b/bot/seasons/__init__.py
@@ -1,5 +1,7 @@
import logging
+from discord.ext import commands
+
from bot.seasons.season import SeasonBase, SeasonManager, get_season
__all__ = ("SeasonBase", "get_season")
@@ -7,6 +9,6 @@ __all__ = ("SeasonBase", "get_season")
log = logging.getLogger(__name__)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
bot.add_cog(SeasonManager(bot))
log.info("SeasonManager cog loaded")
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py
index 08b07e83..6609387e 100644
--- a/bot/seasons/christmas/adventofcode.py
+++ b/bot/seasons/christmas/adventofcode.py
@@ -14,6 +14,7 @@ from discord.ext import commands
from pytz import timezone
from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens
+from bot.decorators import override_in_channel
log = logging.getLogger(__name__)
@@ -45,7 +46,7 @@ def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]:
return tomorrow, tomorrow - datetime.now(EST)
-async def countdown_status(bot: commands.Bot):
+async def countdown_status(bot: commands.Bot) -> None:
"""Set the playing status of the bot to the minutes & hours left until the next day's challenge."""
while is_in_advent():
_, time_left = time_left_to_aoc_midnight()
@@ -72,7 +73,7 @@ async def countdown_status(bot: commands.Bot):
await asyncio.sleep(delay)
-async def day_countdown(bot: commands.Bot):
+async def day_countdown(bot: commands.Bot) -> None:
"""
Calculate the number of seconds left until the next day of Advent.
@@ -125,7 +126,8 @@ class AdventOfCode(commands.Cog):
self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro))
@commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True)
- async def adventofcode_group(self, ctx: commands.Context):
+ @override_in_channel
+ async def adventofcode_group(self, ctx: commands.Context) -> None:
"""All of the Advent of Code commands."""
await ctx.send_help(ctx.command)
@@ -134,7 +136,7 @@ class AdventOfCode(commands.Cog):
aliases=("sub", "notifications", "notify", "notifs"),
brief="Notifications for new days"
)
- async def aoc_subscribe(self, ctx: commands.Context):
+ async def aoc_subscribe(self, ctx: commands.Context) -> None:
"""Assign the role for notifications about new days being ready."""
role = ctx.guild.get_role(AocConfig.role_id)
unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe"
@@ -148,7 +150,7 @@ class AdventOfCode(commands.Cog):
f"If you don't want them any more, run `{unsubscribe_command}` instead.")
@adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days")
- async def aoc_unsubscribe(self, ctx: commands.Context):
+ async def aoc_unsubscribe(self, ctx: commands.Context) -> None:
"""Remove the role for notifications about new days being ready."""
role = ctx.guild.get_role(AocConfig.role_id)
@@ -159,7 +161,7 @@ class AdventOfCode(commands.Cog):
await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.")
@adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day")
- async def aoc_countdown(self, ctx: commands.Context):
+ async def aoc_countdown(self, ctx: commands.Context) -> None:
"""Return time left until next day."""
if not is_in_advent():
datetime_now = datetime.now(EST)
@@ -176,12 +178,12 @@ class AdventOfCode(commands.Cog):
await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.")
@adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code")
- async def about_aoc(self, ctx: commands.Context):
+ async def about_aoc(self, ctx: commands.Context) -> None:
"""Respond with an explanation of all things Advent of Code."""
await ctx.send("", embed=self.cached_about_aoc)
@adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard")
- async def join_leaderboard(self, ctx: commands.Context):
+ async def join_leaderboard(self, ctx: commands.Context) -> None:
"""DM the user the information for joining the PyDis AoC private leaderboard."""
author = ctx.message.author
log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code")
@@ -201,7 +203,7 @@ class AdventOfCode(commands.Cog):
aliases=("board", "lb"),
brief="Get a snapshot of the PyDis private AoC leaderboard",
)
- async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10):
+ async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None:
"""
Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed.
@@ -242,7 +244,7 @@ class AdventOfCode(commands.Cog):
aliases=("dailystats", "ds"),
brief="Get daily statistics for the PyDis private leaderboard"
)
- async def private_leaderboard_daily_stats(self, ctx: commands.Context):
+ async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
"""
Respond with a table of the daily completion statistics for the PyDis private leaderboard.
@@ -285,7 +287,7 @@ class AdventOfCode(commands.Cog):
aliases=("globalboard", "gb"),
brief="Get a snapshot of the global AoC leaderboard",
)
- async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10):
+ async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None:
"""
Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed.
@@ -317,7 +319,7 @@ class AdventOfCode(commands.Cog):
embed=aoc_embed,
)
- async def _check_leaderboard_cache(self, ctx, global_board: bool = False):
+ async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None:
"""
Check age of current leaderboard & pull a new one if the board is too old.
@@ -357,7 +359,7 @@ class AdventOfCode(commands.Cog):
)
async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int:
- """Check for n > max_entries and n <= 0"""
+ """Check for n > max_entries and n <= 0."""
max_entries = AocConfig.leaderboard_max_displayed_members
author = ctx.message.author
if not 0 <= number_of_people_to_display <= max_entries:
@@ -388,7 +390,7 @@ class AdventOfCode(commands.Cog):
return about_embed
- async def _boardgetter(self, global_board: bool):
+ async def _boardgetter(self, global_board: bool) -> None:
"""Invoke the proper leaderboard getter based on the global_board boolean."""
if global_board:
self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url()
diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py
index 652a1f35..aaa02b27 100644
--- a/bot/seasons/christmas/hanukkah_embed.py
+++ b/bot/seasons/christmas/hanukkah_embed.py
@@ -1,5 +1,6 @@
import datetime
import logging
+from typing import List
from discord import Embed
from discord.ext import commands
@@ -13,7 +14,7 @@ log = logging.getLogger(__name__)
class HanukkahEmbed(commands.Cog):
"""A cog that returns information about Hanukkah festival."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&"
"year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on")
@@ -21,7 +22,7 @@ class HanukkahEmbed(commands.Cog):
self.hanukkah_months = []
self.hanukkah_years = []
- async def get_hanukkah_dates(self):
+ async def get_hanukkah_dates(self) -> List[str]:
"""Gets the dates for hanukkah festival."""
hanukkah_dates = []
async with self.bot.http_session.get(self.url) as response:
@@ -34,7 +35,7 @@ class HanukkahEmbed(commands.Cog):
return hanukkah_dates
@commands.command(name='hanukkah', aliases=['chanukah'])
- async def hanukkah_festival(self, ctx):
+ async def hanukkah_festival(self, ctx: commands.Context) -> None:
"""Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""
hanukkah_dates = await self.get_hanukkah_dates()
self.hanukkah_dates_split(hanukkah_dates)
@@ -98,7 +99,7 @@ class HanukkahEmbed(commands.Cog):
await ctx.send(embed=embed)
- def hanukkah_dates_split(self, hanukkah_dates):
+ def hanukkah_dates_split(self, hanukkah_dates: List[str]) -> None:
"""We are splitting the dates for hanukkah into days, months and years."""
for date in hanukkah_dates:
self.hanukkah_days.append(date[8:10])
@@ -106,7 +107,7 @@ class HanukkahEmbed(commands.Cog):
self.hanukkah_years.append(date[0:4])
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(HanukkahEmbed(bot))
log.info("Hanukkah embed cog loaded")
diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py
index 9fbe87a0..4869f510 100644
--- a/bot/seasons/easter/april_fools_vids.py
+++ b/bot/seasons/easter/april_fools_vids.py
@@ -11,21 +11,21 @@ log = logging.getLogger(__name__)
class AprilFoolVideos(commands.Cog):
"""A cog for April Fools' that gets a random April Fools' video from Youtube."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.yt_vids = self.load_json()
self.youtubers = ['google'] # will add more in future
@staticmethod
- def load_json():
+ def load_json() -> dict:
"""A function to load JSON data."""
- p = Path('bot/resources/easterapril_fools_vids.json')
+ p = Path('bot/resources/easter/april_fools_vids.json')
with p.open() as json_file:
all_vids = load(json_file)
return all_vids
@commands.command(name='fool')
- async def aprial_fools(self, ctx):
+ async def april_fools(self, ctx: commands.Context) -> None:
"""Get a random April Fools' video from Youtube."""
random_youtuber = random.choice(self.youtubers)
category = self.yt_vids[random_youtuber]
@@ -33,7 +33,7 @@ class AprilFoolVideos(commands.Cog):
await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""April Fools' Cog load."""
bot.add_cog(AprilFoolVideos(bot))
log.info('April Fools videos cog loaded!')
diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py
index ad8b5473..e21e35fc 100644
--- a/bot/seasons/easter/avatar_easterifier.py
+++ b/bot/seasons/easter/avatar_easterifier.py
@@ -2,7 +2,7 @@ import asyncio
import logging
from io import BytesIO
from pathlib import Path
-from typing import Union
+from typing import Tuple, Union
import discord
from PIL import Image
@@ -21,11 +21,11 @@ COLOURS = [
class AvatarEasterifier(commands.Cog):
"""Put an Easter spin on your avatar or image!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def closest(x):
+ def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]:
"""
Finds the closest easter colour to a given pixel.
@@ -33,8 +33,8 @@ class AvatarEasterifier(commands.Cog):
"""
r1, g1, b1 = x
- def distance(point):
- """Finds the difference between a pastel colour and the original pixel colour"""
+ def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]:
+ """Finds the difference between a pastel colour and the original pixel colour."""
r2, g2, b2 = point
return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2)
@@ -47,7 +47,7 @@ class AvatarEasterifier(commands.Cog):
return (r, g, b)
@commands.command(pass_context=True, aliases=["easterify"])
- async def avatareasterify(self, ctx, *colours: Union[discord.Colour, str]):
+ async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None:
"""
This "Easterifies" the user's avatar.
@@ -56,7 +56,7 @@ class AvatarEasterifier(commands.Cog):
Colours are split by spaces, unless you wrap the colour name in double quotes.
Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
"""
- async def send(*args, **kwargs):
+ async def send(*args, **kwargs) -> str:
"""
This replaces the original ctx.send.
@@ -123,7 +123,7 @@ class AvatarEasterifier(commands.Cog):
await ctx.send(file=file, embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Avatar Easterifier Cog load."""
bot.add_cog(AvatarEasterifier(bot))
log.info("AvatarEasterifier cog loaded")
diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/seasons/easter/bunny_name_generator.py
new file mode 100644
index 00000000..97c467e1
--- /dev/null
+++ b/bot/seasons/easter/bunny_name_generator.py
@@ -0,0 +1,93 @@
+import json
+import logging
+import random
+import re
+from pathlib import Path
+from typing import List, Union
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+
+with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f:
+ BUNNY_NAMES = json.load(f)
+
+
+class BunnyNameGenerator(commands.Cog):
+ """Generate a random bunny name, or bunnify your Discord username!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def find_separators(self, displayname: str) -> Union[List[str], None]:
+ """Check if Discord name contains spaces so we can bunnify an individual word in the name."""
+ new_name = re.split(r'[_.\s]', displayname)
+ if displayname not in new_name:
+ return new_name
+
+ def find_vowels(self, displayname: str) -> str:
+ """
+ Finds vowels in the user's display name.
+
+ If the Discord name contains a vowel and the letter y, it will match one or more of these patterns.
+
+ Only the most recently matched pattern will apply the changes.
+ """
+ expressions = [
+ (r'a.+y', 'patchy'),
+ (r'e.+y', 'ears'),
+ (r'i.+y', 'ditsy'),
+ (r'o.+y', 'oofy'),
+ (r'u.+y', 'uffy'),
+ ]
+
+ for exp, vowel_sub in expressions:
+ new_name = re.sub(exp, vowel_sub, displayname)
+ if new_name != displayname:
+ return new_name
+
+ def append_name(self, displayname: str) -> str:
+ """Adds a suffix to the end of the Discord name."""
+ extensions = ['foot', 'ear', 'nose', 'tail']
+ suffix = random.choice(extensions)
+ appended_name = displayname + suffix
+
+ return appended_name
+
+ @commands.command()
+ async def bunnyname(self, ctx: commands.Context) -> None:
+ """Picks a random bunny name from a JSON file."""
+ await ctx.send(random.choice(BUNNY_NAMES["names"]))
+
+ @commands.command()
+ async def bunnifyme(self, ctx: commands.Context) -> None:
+ """Gets your Discord username and bunnifies it."""
+ username = ctx.message.author.display_name
+
+ # If name contains spaces or other separators, get the individual words to randomly bunnify
+ spaces_in_name = self.find_separators(username)
+
+ # If name contains vowels, see if it matches any of the patterns in this function
+ # If there are matches, the bunnified name is returned.
+ vowels_in_name = self.find_vowels(username)
+
+ # Default if the checks above return None
+ unmatched_name = self.append_name(username)
+
+ if spaces_in_name is not None:
+ replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot']
+ word_to_replace = random.choice(spaces_in_name)
+ substitute = random.choice(replacements)
+ bunnified_name = username.replace(word_to_replace, substitute)
+ elif vowels_in_name is not None:
+ bunnified_name = vowels_in_name
+ elif unmatched_name:
+ bunnified_name = unmatched_name
+
+ await ctx.send(bunnified_name)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Bunny Name Generator Cog load."""
+ bot.add_cog(BunnyNameGenerator(bot))
+ log.info("BunnyNameGenerator cog loaded.")
diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py
index c2cdf26c..3f38ae82 100644
--- a/bot/seasons/easter/conversationstarters.py
+++ b/bot/seasons/easter/conversationstarters.py
@@ -14,16 +14,16 @@ with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f:
class ConvoStarters(commands.Cog):
"""Easter conversation topics."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command()
- async def topic(self, ctx):
+ async def topic(self, ctx: commands.Context) -> None:
"""Responds with a random topic to start a conversation."""
await ctx.send(random.choice(starters['starters']))
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Conversation starters Cog load."""
bot.add_cog(ConvoStarters(bot))
log.info("ConvoStarters cog loaded")
diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py
new file mode 100644
index 00000000..4b98b204
--- /dev/null
+++ b/bot/seasons/easter/easter_riddle.py
@@ -0,0 +1,101 @@
+import asyncio
+import logging
+import random
+from json import load
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f:
+ RIDDLE_QUESTIONS = load(f)
+
+TIMELIMIT = 10
+
+
+class EasterRiddle(commands.Cog):
+ """This cog contains the command for the Easter quiz!"""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.winners = []
+ self.correct = ""
+ self.current_channel = None
+
+ @commands.command(aliases=["riddlemethis", "riddleme"])
+ async def riddle(self, ctx: commands.Context) -> None:
+ """
+ Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer.
+
+ The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file.
+ """
+ if self.current_channel:
+ return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+
+ self.current_channel = ctx.message.channel
+
+ random_question = random.choice(RIDDLE_QUESTIONS)
+ question = random_question["question"]
+ hints = random_question["riddles"]
+ self.correct = random_question["correct_answer"]
+
+ description = f"You have {TIMELIMIT} seconds before the first hint."
+
+ riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink)
+
+ await ctx.send(embed=riddle_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[0]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[1]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ if self.winners:
+ win_list = " ".join(self.winners)
+ content = f"Well done {win_list} for getting it right!"
+ else:
+ content = "Nobody got it right..."
+
+ answer_embed = discord.Embed(
+ title=f"The answer is: {self.correct}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(content, embed=answer_embed)
+
+ self.winners = []
+ self.current_channel = None
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Messaged) -> None:
+ """If a non-bot user enters a correct answer, their username gets added to self.winners."""
+ if self.current_channel != message.channel:
+ return
+
+ if self.bot.user == message.author:
+ return
+
+ if message.content.lower() == self.correct.lower():
+ self.winners.append(message.author.mention)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Easter Riddle Cog load."""
+ bot.add_cog(EasterRiddle(bot))
+ log.info("Easter Riddle bot loaded")
diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py
index ee8a80e5..51f52264 100644
--- a/bot/seasons/easter/egg_decorating.py
+++ b/bot/seasons/easter/egg_decorating.py
@@ -31,11 +31,11 @@ IRREPLACEABLE = [
class EggDecorating(commands.Cog):
"""Decorate some easter eggs!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@staticmethod
- def replace_invalid(colour: str):
+ def replace_invalid(colour: str) -> Union[int, None]:
"""Attempts to match with HTML or XKCD colour names, returning the int value."""
with suppress(KeyError):
return int(HTML_COLOURS[colour], 16)
@@ -44,7 +44,9 @@ class EggDecorating(commands.Cog):
return None
@commands.command(aliases=["decorateegg"])
- async def eggdecorate(self, ctx, *colours: Union[discord.Colour, str]):
+ async def eggdecorate(
+ self, ctx: commands.Context, *colours: Union[discord.Colour, str]
+ ) -> Union[Image, discord.Message]:
"""
Picks a random egg design and decorates it using the given colours.
@@ -111,7 +113,7 @@ class EggDecorating(commands.Cog):
return new_im
-def setup(bot):
+def setup(bot: commands.bot) -> None:
"""Egg decorating Cog load."""
bot.add_cog(EggDecorating(bot))
log.info("EggDecorating cog loaded.")
diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py
new file mode 100644
index 00000000..9e6fb1cb
--- /dev/null
+++ b/bot/seasons/easter/egg_facts.py
@@ -0,0 +1,62 @@
+import asyncio
+import logging
+import random
+from json import load
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Channels
+from bot.constants import Colours
+
+
+log = logging.getLogger(__name__)
+
+
+class EasterFacts(commands.Cog):
+ """
+ A cog contains a command that will return an easter egg fact when called.
+
+ It also contains a background task which sends an easter egg fact in the event channel everyday.
+ """
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.facts = self.load_json()
+
+ @staticmethod
+ def load_json() -> dict:
+ """Load a list of easter egg facts from the resource JSON file."""
+ p = Path("bot/resources/easter/easter_egg_facts.json")
+ with p.open(encoding="utf8") as f:
+ return load(f)
+
+ async def send_egg_fact_daily(self) -> None:
+ """A background task that sends an easter egg fact in the event channel everyday."""
+ channel = self.bot.get_channel(Channels.seasonalbot_chat)
+ while True:
+ embed = self.make_embed()
+ await channel.send(embed=embed)
+ await asyncio.sleep(24 * 60 * 60)
+
+ @commands.command(name='eggfact', aliases=['fact'])
+ async def easter_facts(self, ctx: commands.Context) -> None:
+ """Get easter egg facts."""
+ embed = self.make_embed()
+ await ctx.send(embed=embed)
+
+ def make_embed(self) -> discord.Embed:
+ """Makes a nice embed for the message to be sent."""
+ return discord.Embed(
+ colour=Colours.soft_red,
+ title="Easter Egg Fact",
+ description=random.choice(self.facts)
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Easter Egg facts cog load."""
+ bot.loop.create_task(EasterFacts(bot).send_egg_fact_daily())
+ bot.add_cog(EasterFacts(bot))
+ log.info("EasterFacts cog loaded")
diff --git a/bot/seasons/easter/egg_hunt/__init__.py b/bot/seasons/easter/egg_hunt/__init__.py
deleted file mode 100644
index 0e4b9e16..00000000
--- a/bot/seasons/easter/egg_hunt/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import logging
-
-from .cog import EggHunt
-
-log = logging.getLogger(__name__)
-
-
-def setup(bot):
- """Easter Egg Hunt Cog load."""
- bot.add_cog(EggHunt())
- log.info("EggHunt cog loaded")
diff --git a/bot/seasons/easter/egg_hunt/cog.py b/bot/seasons/easter/egg_hunt/cog.py
deleted file mode 100644
index 30fd3284..00000000
--- a/bot/seasons/easter/egg_hunt/cog.py
+++ /dev/null
@@ -1,617 +0,0 @@
-import asyncio
-import contextlib
-import logging
-import random
-import sqlite3
-from datetime import datetime, timezone
-from pathlib import Path
-
-import discord
-from discord.ext import commands
-
-from bot.constants import Channels, Client, Roles as MainRoles, bot
-from bot.decorators import with_role
-from .constants import Colours, EggHuntSettings, Emoji, Roles
-
-log = logging.getLogger(__name__)
-
-DB_PATH = Path("bot/resources/persist/egg_hunt.sqlite")
-
-TEAM_MAP = {
- Roles.white: Emoji.egg_white,
- Roles.blurple: Emoji.egg_blurple,
- Emoji.egg_white: Roles.white,
- Emoji.egg_blurple: Roles.blurple
-}
-
-GUILD = bot.get_guild(Client.guild)
-
-MUTED = GUILD.get_role(MainRoles.muted)
-
-
-def get_team_role(user: discord.Member) -> discord.Role:
- """Helper function to get the team role for a member."""
- if Roles.white in user.roles:
- return Roles.white
- if Roles.blurple in user.roles:
- return Roles.blurple
-
-
-async def assign_team(user: discord.Member) -> discord.Member:
- """Helper function to assign a new team role for a member."""
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
- c.execute(f"SELECT team FROM user_scores WHERE user_id = {user.id}")
- result = c.fetchone()
- if not result:
- c.execute(
- "SELECT team, COUNT(*) AS count FROM user_scores "
- "GROUP BY team ORDER BY count ASC LIMIT 1;"
- )
- result = c.fetchone()
- result = result[0] if result else "WHITE"
-
- if result[0] == "WHITE":
- new_team = Roles.white
- else:
- new_team = Roles.blurple
-
- db.close()
-
- log.debug(f"Assigned role {new_team} to {user}.")
-
- await user.add_roles(new_team)
- return GUILD.get_member(user.id)
-
-
-class EggMessage:
- """Handles a single egg reaction drop session."""
-
- def __init__(self, message: discord.Message, egg: discord.Emoji):
- self.message = message
- self.egg = egg
- self.first = None
- self.users = set()
- self.teams = {Roles.white: "WHITE", Roles.blurple: "BLURPLE"}
- self.new_team_assignments = {}
- self.timeout_task = None
-
- @staticmethod
- def add_user_score_sql(user_id: int, team: str, score: int) -> str:
- """Builds the SQL for adding a score to a user in the database."""
- return (
- "INSERT INTO user_scores(user_id, team, score)"
- f"VALUES({user_id}, '{team}', {score})"
- f"ON CONFLICT (user_id) DO UPDATE SET score=score+{score}"
- )
-
- @staticmethod
- def add_team_score_sql(team_name: str, score: int) -> str:
- """Builds the SQL for adding a score to a team in the database."""
- return f"UPDATE team_scores SET team_score=team_score+{score} WHERE team_id='{team_name}'"
-
- def finalise_score(self):
- """Sums and actions scoring for this egg drop session."""
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
-
- team_scores = {"WHITE": 0, "BLURPLE": 0}
-
- first_team = get_team_role(self.first)
- if not first_team:
- log.debug("User without team role!")
- db.close()
- return
-
- score = 3 if first_team == TEAM_MAP[first_team] else 2
-
- c.execute(self.add_user_score_sql(self.first.id, self.teams[first_team], score))
- team_scores[self.teams[first_team]] += score
-
- for user in self.users:
- team = get_team_role(user)
- if not team:
- log.debug("User without team role!")
- continue
-
- team_name = self.teams[team]
- team_scores[team_name] += 1
- score = 2 if team == first_team else 1
- c.execute(self.add_user_score_sql(user.id, team_name, score))
-
- for team_name, score in team_scores.items():
- if not score:
- continue
- c.execute(self.add_team_score_sql(team_name, score))
-
- db.commit()
- db.close()
-
- log.debug(
- f"EggHunt session finalising: ID({self.message.id}) "
- f"FIRST({self.first}) REST({self.users})."
- )
-
- async def start_timeout(self, seconds: int = 5):
- """Begins a task that will sleep until the given seconds before finalizing the session."""
- if self.timeout_task:
- self.timeout_task.cancel()
- self.timeout_task = None
-
- await asyncio.sleep(seconds)
-
- bot.remove_listener(self.collect_reacts, name="on_reaction_add")
-
- with contextlib.suppress(discord.Forbidden):
- await self.message.clear_reactions()
-
- if self.first:
- self.finalise_score()
-
- def is_valid_react(self, reaction: discord.Reaction, user: discord.Member) -> bool:
- """Validates a reaction event was meant for this session."""
- if user.bot:
- return False
- if reaction.message.id != self.message.id:
- return False
- if reaction.emoji != self.egg:
- return False
-
- # Ignore the punished
- if MUTED in user.roles:
- return False
-
- return True
-
- async def collect_reacts(self, reaction: discord.Reaction, user: discord.Member):
- """Handles emitted reaction_add events via listener."""
- if not self.is_valid_react(reaction, user):
- return
-
- team = get_team_role(user)
- if not team:
- log.debug(f"Assigning a team for {user}.")
- user = await assign_team(user)
-
- if not self.first:
- log.debug(f"{user} was first to react to egg on {self.message.id}.")
- self.first = user
- await self.start_timeout()
- else:
- if user != self.first:
- self.users.add(user)
-
- async def start(self):
- """Starts the egg drop session."""
- log.debug(f"EggHunt session started for message {self.message.id}.")
- bot.add_listener(self.collect_reacts, name="on_reaction_add")
- with contextlib.suppress(discord.Forbidden):
- await self.message.add_reaction(self.egg)
- self.timeout_task = asyncio.create_task(self.start_timeout(300))
- while True:
- if not self.timeout_task:
- break
- if not self.timeout_task.done():
- await self.timeout_task
- else:
- # make sure any exceptions raise if necessary
- self.timeout_task.result()
- break
-
-
-class SuperEggMessage(EggMessage):
- """Handles a super egg session."""
-
- def __init__(self, message: discord.Message, egg: discord.Emoji, window: int):
- super().__init__(message, egg)
- self.window = window
-
- async def finalise_score(self):
- """Sums and actions scoring for this super egg session."""
- try:
- message = await self.message.channel.fetch_message(self.message.id)
- except discord.NotFound:
- return
-
- count = 0
- white = 0
- blurple = 0
- react_users = []
- for reaction in message.reactions:
- if reaction.emoji == self.egg:
- react_users = await reaction.users().flatten()
- for user in react_users:
- team = get_team_role(user)
- if team == Roles.white:
- white += 1
- elif team == Roles.blurple:
- blurple += 1
- count = reaction.count - 1
- break
-
- score = 50 if self.egg == Emoji.egg_gold else 100
- if white == blurple:
- log.debug("Tied SuperEgg Result.")
- team = None
- score /= 2
- elif white > blurple:
- team = Roles.white
- else:
- team = Roles.blurple
-
- embed = self.message.embeds[0]
-
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
-
- user_bonus = 5 if self.egg == Emoji.egg_gold else 10
- for user in react_users:
- if user.bot:
- continue
- role = get_team_role(user)
- if not role:
- print("issue")
- user_score = 1 if user != self.first else user_bonus
- c.execute(self.add_user_score_sql(user.id, self.teams[role], user_score))
-
- if not team:
- embed.description = f"{embed.description}\n\nA Tie!\nBoth got {score} points!"
- c.execute(self.add_team_score_sql(self.teams[Roles.white], score))
- c.execute(self.add_team_score_sql(self.teams[Roles.blurple], score))
- team_name = "TIE"
- else:
- team_name = self.teams[team]
- embed.description = (
- f"{embed.description}\n\nTeam {team_name.capitalize()} won the points!"
- )
- c.execute(self.add_team_score_sql(team_name, score))
-
- c.execute(
- "INSERT INTO super_eggs (message_id, egg_type, team, window) "
- f"VALUES ({self.message.id}, '{self.egg.name}', '{team_name}', {self.window});"
- )
-
- log.debug("Committing Super Egg scores.")
- db.commit()
- db.close()
-
- embed.set_footer(text=f"Finished with {count} total reacts.")
- with contextlib.suppress(discord.HTTPException):
- await self.message.edit(embed=embed)
-
- async def start_timeout(self, seconds=None):
- """Starts the super egg session."""
- if not seconds:
- return
- count = 4
- for _ in range(count):
- await asyncio.sleep(60)
- embed = self.message.embeds[0]
- embed.set_footer(text=f"Finishing in {count} minutes.")
- try:
- await self.message.edit(embed=embed)
- except discord.HTTPException:
- break
- count -= 1
- bot.remove_listener(self.collect_reacts, name="on_reaction_add")
- await self.finalise_score()
-
-
-class EggHunt(commands.Cog):
- """Easter Egg Hunt Event."""
-
- def __init__(self):
- self.event_channel = GUILD.get_channel(Channels.seasonalbot_chat)
- self.super_egg_buffer = 60*60
- self.tables = {
- "super_eggs": (
- "CREATE TABLE super_eggs ("
- "message_id INTEGER NOT NULL "
- " CONSTRAINT super_eggs_pk PRIMARY KEY, "
- "egg_type TEXT NOT NULL, "
- "team TEXT NOT NULL, "
- "window INTEGER);"
- ),
- "team_scores": (
- "CREATE TABLE team_scores ("
- "team_id TEXT, "
- "team_score INTEGER DEFAULT 0);"
- ),
- "user_scores": (
- "CREATE TABLE user_scores("
- "user_id INTEGER NOT NULL "
- " CONSTRAINT user_scores_pk PRIMARY KEY, "
- "team TEXT NOT NULL, "
- "score INTEGER DEFAULT 0 NOT NULL);"
- ),
- "react_logs": (
- "CREATE TABLE react_logs("
- "member_id INTEGER NOT NULL, "
- "message_id INTEGER NOT NULL, "
- "reaction_id TEXT NOT NULL, "
- "react_timestamp REAL NOT NULL);"
- )
- }
- self.prepare_db()
- self.task = asyncio.create_task(self.super_egg())
- self.task.add_done_callback(self.task_cleanup)
-
- def prepare_db(self):
- """Ensures database tables all exist and if not, creates them."""
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
-
- exists_sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}';"
-
- missing_tables = []
- for table in self.tables:
- c.execute(exists_sql.format(table_name=table))
- result = c.fetchone()
- if not result:
- missing_tables.append(table)
-
- for table in missing_tables:
- log.info(f"Table {table} is missing, building new one.")
- c.execute(self.tables[table])
-
- db.commit()
- db.close()
-
- def task_cleanup(self, task):
- """Returns task result and restarts. Used as a done callback to show raised exceptions."""
- task.result()
- self.task = asyncio.create_task(self.super_egg())
-
- @staticmethod
- def current_timestamp() -> float:
- """Returns a timestamp of the current UTC time."""
- return datetime.utcnow().replace(tzinfo=timezone.utc).timestamp()
-
- async def super_egg(self):
- """Manages the timing of super egg drops."""
- while True:
- now = int(self.current_timestamp())
-
- if now > EggHuntSettings.end_time:
- log.debug("Hunt ended. Ending task.")
- break
-
- if now < EggHuntSettings.start_time:
- remaining = EggHuntSettings.start_time - now
- log.debug(f"Hunt not started yet. Sleeping for {remaining}.")
- await asyncio.sleep(remaining)
-
- log.debug(f"Hunt started.")
-
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
-
- current_window = None
- next_window = None
- windows = EggHuntSettings.windows.copy()
- windows.insert(0, EggHuntSettings.start_time)
- for i, window in enumerate(windows):
- c.execute(f"SELECT COUNT(*) FROM super_eggs WHERE window={window}")
- already_dropped = c.fetchone()[0]
-
- if already_dropped:
- log.debug(f"Window {window} already dropped, checking next one.")
- continue
-
- if now < window:
- log.debug("Drop windows up to date, sleeping until next one.")
- await asyncio.sleep(window-now)
- now = int(self.current_timestamp())
-
- current_window = window
- next_window = windows[i+1]
- break
-
- count = c.fetchone()
- db.close()
-
- if not current_window:
- log.debug("No drop windows left, ending task.")
- break
-
- log.debug(f"Current Window: {current_window}. Next Window {next_window}")
-
- if not count:
- if next_window < now:
- log.debug("An Egg Drop Window was missed, dropping one now.")
- next_drop = 0
- else:
- next_drop = random.randrange(now, next_window)
-
- if next_drop:
- log.debug(f"Sleeping until next super egg drop: {next_drop}.")
- await asyncio.sleep(next_drop)
-
- if random.randrange(10) <= 2:
- egg = Emoji.egg_diamond
- egg_type = "Diamond"
- score = "100"
- colour = Colours.diamond
- else:
- egg = Emoji.egg_gold
- egg_type = "Gold"
- score = "50"
- colour = Colours.gold
-
- embed = discord.Embed(
- title=f"A {egg_type} Egg Has Appeared!",
- description=f"**Worth {score} team points!**\n\n"
- "The team with the most reactions after 5 minutes wins!",
- colour=colour
- )
- embed.set_thumbnail(url=egg.url)
- embed.set_footer(text="Finishing in 5 minutes.")
- msg = await self.event_channel.send(embed=embed)
- await SuperEggMessage(msg, egg, current_window).start()
-
- log.debug("Sleeping until next window.")
- next_loop = max(next_window - int(self.current_timestamp()), self.super_egg_buffer)
- await asyncio.sleep(next_loop)
-
- @commands.Cog.listener()
- async def on_raw_reaction_add(self, payload):
- """Reaction event listener for reaction logging for later anti-cheat analysis."""
- if payload.channel_id not in EggHuntSettings.allowed_channels:
- return
-
- now = self.current_timestamp()
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
- c.execute(
- "INSERT INTO react_logs(member_id, message_id, reaction_id, react_timestamp) "
- f"VALUES({payload.user_id}, {payload.message_id}, '{payload.emoji}', {now})"
- )
- db.commit()
- db.close()
-
- @commands.Cog.listener()
- async def on_message(self, message):
- """Message event listener for random egg drops."""
- if self.current_timestamp() < EggHuntSettings.start_time:
- return
-
- if message.channel.id not in EggHuntSettings.allowed_channels:
- log.debug("Message not in Egg Hunt channel; ignored.")
- return
-
- if message.author.bot:
- return
-
- if random.randrange(100) <= 5:
- await EggMessage(message, random.choice([Emoji.egg_white, Emoji.egg_blurple])).start()
-
- @commands.group(invoke_without_command=True)
- async def hunt(self, ctx):
- """
- For 48 hours, hunt down as many eggs randomly appearing as possible.
-
- Standard Eggs
- --------------
- Egg React: +1pt
- Team Bonus for Claimed Egg: +1pt
- First React on Other Team Egg: +1pt
- First React on Your Team Egg: +2pt
-
- If you get first react, you will claim that egg for your team, allowing
- your team to get the Team Bonus point, but be quick, as the egg will
- disappear after 5 seconds of the first react.
-
- Super Eggs
- -----------
- Gold Egg: 50 team pts, 5pts to first react
- Diamond Egg: 100 team pts, 10pts to first react
-
- Super Eggs only appear in #seasonalbot-chat so be sure to keep an eye
- out. They stay around for 5 minutes and the team with the most reacts
- wins the points.
- """
- await ctx.invoke(bot.get_command("help"), command="hunt")
-
- @hunt.command()
- async def countdown(self, ctx):
- """Show the time status of the Egg Hunt event."""
- now = self.current_timestamp()
- if now > EggHuntSettings.end_time:
- return await ctx.send("The Hunt has ended.")
-
- difference = EggHuntSettings.start_time - now
- if difference < 0:
- difference = EggHuntSettings.end_time - now
- msg = "The Egg Hunt will end in"
- else:
- msg = "The Egg Hunt will start in"
-
- hours, r = divmod(difference, 3600)
- minutes, r = divmod(r, 60)
- await ctx.send(f"{msg} {hours:.0f}hrs, {minutes:.0f}mins & {r:.0f}secs")
-
- @hunt.command()
- async def leaderboard(self, ctx):
- """Show the Egg Hunt Leaderboards."""
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
- c.execute(f"SELECT *, RANK() OVER(ORDER BY score DESC) AS rank FROM user_scores LIMIT 10")
- user_result = c.fetchall()
- c.execute(f"SELECT * FROM team_scores ORDER BY team_score DESC")
- team_result = c.fetchall()
- db.close()
- output = []
- if user_result:
- # Get the alignment needed for the score
- score_lengths = []
- for result in user_result:
- length = len(str(result[2]))
- score_lengths.append(length)
-
- score_length = max(score_lengths)
- for user_id, team, score, rank in user_result:
- user = GUILD.get_member(user_id) or user_id
- team = team.capitalize()
- score = f"{score}pts"
- output.append(f"{rank:>2}. {score:>{score_length+3}} - {user} ({team})")
- user_board = "\n".join(output)
- else:
- user_board = "No entries."
- if team_result:
- output = []
- for team, score in team_result:
- output.append(f"{team:<7}: {score}")
- team_board = "\n".join(output)
- else:
- team_board = "No entries."
- embed = discord.Embed(
- title="Egg Hunt Leaderboards",
- description=f"**Team Scores**\n```\n{team_board}\n```\n"
- f"**Top 10 Members**\n```\n{user_board}\n```"
- )
- await ctx.send(embed=embed)
-
- @hunt.command()
- async def rank(self, ctx, *, member: discord.Member = None):
- """Get your ranking in the Egg Hunt Leaderboard."""
- member = member or ctx.author
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
- c.execute(
- "SELECT rank FROM "
- "(SELECT RANK() OVER(ORDER BY score DESC) AS rank, user_id FROM user_scores)"
- f"WHERE user_id = {member.id};"
- )
- result = c.fetchone()
- db.close()
- if not result:
- embed = discord.Embed().set_author(name=f"Egg Hunt - No Ranking")
- else:
- embed = discord.Embed().set_author(name=f"Egg Hunt - Rank #{result[0]}")
- await ctx.send(embed=embed)
-
- @with_role(MainRoles.admin)
- @hunt.command()
- async def clear_db(self, ctx):
- """Resets the database to it's initial state."""
- def check(msg):
- if msg.author != ctx.author:
- return False
- if msg.channel != ctx.channel:
- return False
- return True
- await ctx.send(
- "WARNING: This will delete all current event data.\n"
- "Please verify this action by replying with 'Yes, I want to delete all data.'"
- )
- reply_msg = await bot.wait_for('message', check=check)
- if reply_msg.content != "Yes, I want to delete all data.":
- return await ctx.send("Reply did not match. Aborting database deletion.")
- db = sqlite3.connect(DB_PATH)
- c = db.cursor()
- c.execute("DELETE FROM super_eggs;")
- c.execute("DELETE FROM user_scores;")
- c.execute("UPDATE team_scores SET team_score=0")
- db.commit()
- db.close()
- await ctx.send("Database successfully cleared.")
diff --git a/bot/seasons/easter/egg_hunt/constants.py b/bot/seasons/easter/egg_hunt/constants.py
deleted file mode 100644
index c7d9818b..00000000
--- a/bot/seasons/easter/egg_hunt/constants.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import os
-
-from discord import Colour
-
-from bot.constants import Channels, Client, bot
-
-
-GUILD = bot.get_guild(Client.guild)
-
-
-class EggHuntSettings:
- start_time = int(os.environ["HUNT_START"])
- end_time = start_time + 172800 # 48 hrs later
- windows = [int(w) for w in os.environ.get("HUNT_WINDOWS").split(',')] or []
- allowed_channels = [
- Channels.seasonalbot_chat,
- Channels.off_topic_0,
- Channels.off_topic_1,
- Channels.off_topic_2,
- ]
-
-
-class Roles:
- white = GUILD.get_role(569304397054607363)
- blurple = GUILD.get_role(569304472820514816)
-
-
-class Emoji:
- egg_white = bot.get_emoji(569266762428841989)
- egg_blurple = bot.get_emoji(569266666094067819)
- egg_gold = bot.get_emoji(569266900106739712)
- egg_diamond = bot.get_emoji(569266839738384384)
-
-
-class Colours:
- white = Colour(0xFFFFFF)
- blurple = Colour(0x7289DA)
- gold = Colour(0xE4E415)
- diamond = Colour(0xECF5FF)
diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py
index 3e0cc598..bd179fe2 100644
--- a/bot/seasons/easter/egghead_quiz.py
+++ b/bot/seasons/easter/egghead_quiz.py
@@ -3,6 +3,7 @@ import logging
import random
from json import load
from pathlib import Path
+from typing import Union
import discord
from discord.ext import commands
@@ -30,14 +31,14 @@ TIMELIMIT = 30
class EggheadQuiz(commands.Cog):
"""This cog contains the command for the Easter quiz!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
self.quiz_messages = {}
@commands.command(aliases=["eggheadquiz", "easterquiz"])
- async def eggquiz(self, ctx):
+ async def eggquiz(self, ctx: commands.Context) -> None:
"""
- Gives a random quiz question, waits 30 seconds and then outputs the answer
+ Gives a random quiz question, waits 30 seconds and then outputs the answer.
Also informs of the percentages and votes of each option
"""
@@ -95,14 +96,14 @@ class EggheadQuiz(commands.Cog):
await ctx.send(content, embed=a_embed)
@staticmethod
- async def already_reacted(message, user):
- """Returns whether a given user has reacted more than once to a given message"""
+ async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool:
+ """Returns whether a given user has reacted more than once to a given message."""
users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction]
return users.count(user.id) > 1 # Old reaction plus new reaction
@commands.Cog.listener()
- async def on_reaction_add(self, reaction, user):
- """Listener to listen specifically for reactions of quiz messages"""
+ async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None:
+ """Listener to listen specifically for reactions of quiz messages."""
if user.bot:
return
if reaction.message.id not in self.quiz_messages:
@@ -113,7 +114,7 @@ class EggheadQuiz(commands.Cog):
return await reaction.message.remove_reaction(reaction, user)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Egghead Quiz Cog load."""
bot.add_cog(EggheadQuiz(bot))
log.info("EggheadQuiz bot loaded")
diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py
index f04b8828..9529823f 100644
--- a/bot/seasons/easter/traditions.py
+++ b/bot/seasons/easter/traditions.py
@@ -14,18 +14,18 @@ with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as
class Traditions(commands.Cog):
"""A cog which allows users to get a random easter tradition or custom from a random country."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(aliases=('eastercustoms',))
- async def easter_tradition(self, ctx):
- """Responds with a random tradition or custom"""
+ async def easter_tradition(self, ctx: commands.Context) -> None:
+ """Responds with a random tradition or custom."""
random_country = random.choice(list(traditions))
await ctx.send(f"{random_country}:\n{traditions[random_country]}")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Traditions Cog load."""
bot.add_cog(Traditions(bot))
log.info("Traditions cog loaded")
diff --git a/bot/seasons/evergreen/8bitify.py b/bot/seasons/evergreen/8bitify.py
new file mode 100644
index 00000000..60062fc1
--- /dev/null
+++ b/bot/seasons/evergreen/8bitify.py
@@ -0,0 +1,54 @@
+from io import BytesIO
+
+import discord
+from PIL import Image
+from discord.ext import commands
+
+
+class EightBitify(commands.Cog):
+ """Make your avatar 8bit!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @staticmethod
+ def pixelate(image: Image) -> Image:
+ """Takes an image and pixelates it."""
+ return image.resize((32, 32)).resize((1024, 1024))
+
+ @staticmethod
+ def quantize(image: Image) -> Image:
+ """Reduces colour palette to 256 colours."""
+ return image.quantize(colors=32)
+
+ @commands.command(name="8bitify")
+ async def eightbit_command(self, ctx: commands.Context) -> None:
+ """Pixelates your avatar and changes the palette to an 8bit one."""
+ async with ctx.typing():
+ image_bytes = await ctx.author.avatar_url.read()
+ avatar = Image.open(BytesIO(image_bytes))
+ avatar = avatar.convert("RGBA").resize((1024, 1024))
+
+ eightbit = self.pixelate(avatar)
+ eightbit = self.quantize(eightbit)
+
+ bufferedio = BytesIO()
+ eightbit.save(bufferedio, format="PNG")
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="8bitavatar.png")
+
+ embed = discord.Embed(
+ title="Your 8-bit avatar",
+ description='Here is your avatar. I think it looks all cool and "retro"'
+ )
+
+ embed.set_image(url="attachment://8bitavatar.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(EightBitify(bot))
diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py
index f4457f8f..120462ee 100644
--- a/bot/seasons/evergreen/error_handler.py
+++ b/bot/seasons/evergreen/error_handler.py
@@ -1,21 +1,26 @@
import logging
import math
+import random
import sys
import traceback
+from discord import Colour, Embed, Message
from discord.ext import commands
+from bot.constants import NEGATIVE_REPLIES
+from bot.decorators import InChannelCheckFailure
+
log = logging.getLogger(__name__)
class CommandErrorHandler(commands.Cog):
"""A error handler for the PythonDiscord server."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def revert_cooldown_counter(command, message):
+ def revert_cooldown_counter(command: commands.Command, message: Message) -> None:
"""Undoes the last cooldown counter for user-error cases."""
if command._buckets.valid:
bucket = command._buckets.get_bucket(message)
@@ -25,7 +30,7 @@ class CommandErrorHandler(commands.Cog):
)
@commands.Cog.listener()
- async def on_command_error(self, ctx, error):
+ async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
"""Activates when a command opens an error."""
if hasattr(ctx.command, 'on_error'):
return logging.debug(
@@ -34,6 +39,16 @@ class CommandErrorHandler(commands.Cog):
error = getattr(error, 'original', error)
+ if isinstance(error, InChannelCheckFailure):
+ logging.debug(
+ f"{ctx.author} the command '{ctx.command}', but they did not have "
+ f"permissions to run commands in the channel {ctx.channel}!"
+ )
+ embed = Embed(colour=Colour.red())
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = str(error)
+ return await ctx.send(embed=embed)
+
if isinstance(error, commands.CommandNotFound):
return logging.debug(
f"{ctx.author} called '{ctx.message.content}' but no command was found."
@@ -98,7 +113,7 @@ class CommandErrorHandler(commands.Cog):
traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Error handler Cog load."""
bot.add_cog(CommandErrorHandler(bot))
log.info("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py
index ce3484f7..889ae079 100644
--- a/bot/seasons/evergreen/fun.py
+++ b/bot/seasons/evergreen/fun.py
@@ -1,21 +1,39 @@
+import functools
import logging
import random
+from typing import Callable, Tuple, Union
+from discord import Embed, Message
from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context, MessageConverter
+from bot import utils
from bot.constants import Emojis
log = logging.getLogger(__name__)
+UWU_WORDS = {
+ "fi": "fwi",
+ "l": "w",
+ "r": "w",
+ "some": "sum",
+ "th": "d",
+ "thing": "fing",
+ "tho": "fo",
+ "you're": "yuw'we",
+ "your": "yur",
+ "you": "yuw",
+}
-class Fun(commands.Cog):
+
+class Fun(Cog):
"""A collection of general commands for fun."""
- def __init__(self, bot):
+ def __init__(self, bot: Bot) -> None:
self.bot = bot
@commands.command()
- async def roll(self, ctx, num_rolls: int = 1):
+ async def roll(self, ctx: Context, num_rolls: int = 1) -> None:
"""Outputs a number of random dice emotes (up to 6)."""
output = ""
if num_rolls > 6:
@@ -27,8 +45,104 @@ class Fun(commands.Cog):
output += getattr(Emojis, terning, '')
await ctx.send(output)
+ @commands.command(name="uwu", aliases=("uwuwize", "uwuify",))
+ async def uwu_command(self, ctx: Context, *, text: str) -> None:
+ """
+ Converts a given `text` into it's uwu equivalent.
+
+ Also accepts a valid discord Message ID or link.
+ """
+ conversion_func = functools.partial(
+ utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True
+ )
+ text, embed = await Fun._get_text_and_embed(ctx, text)
+ # Convert embed if it exists
+ if embed is not None:
+ embed = Fun._convert_embed(conversion_func, embed)
+ converted_text = conversion_func(text)
+ # Don't put >>> if only embed present
+ if converted_text:
+ converted_text = f">>> {converted_text.lstrip('> ')}"
+ await ctx.send(content=converted_text, embed=embed)
+
+ @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",))
+ async def randomcase_command(self, ctx: Context, *, text: str) -> None:
+ """
+ Randomly converts the casing of a given `text`.
+
+ Also accepts a valid discord Message ID or link.
+ """
+ def conversion_func(text: str) -> str:
+ """Randomly converts the casing of a given string."""
+ return "".join(
+ char.upper() if round(random.random()) else char.lower() for char in text
+ )
+ text, embed = await Fun._get_text_and_embed(ctx, text)
+ # Convert embed if it exists
+ if embed is not None:
+ embed = Fun._convert_embed(conversion_func, embed)
+ converted_text = conversion_func(text)
+ # Don't put >>> if only embed present
+ if converted_text:
+ converted_text = f">>> {converted_text.lstrip('> ')}"
+ await ctx.send(content=converted_text, embed=embed)
+
+ @staticmethod
+ async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]:
+ """
+ Attempts to extract the text and embed from a possible link to a discord Message.
+
+ Returns a tuple of:
+ str: If `text` is a valid discord Message, the contents of the message, else `text`.
+ Union[Embed, None]: The embed if found in the valid Message, else None
+ """
+ embed = None
+ message = await Fun._get_discord_message(ctx, text)
+ if isinstance(message, Message):
+ text = message.content
+ # Take first embed because we can't send multiple embeds
+ if message.embeds:
+ embed = message.embeds[0]
+ return (text, embed)
+
+ @staticmethod
+ async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]:
+ """
+ Attempts to convert a given `text` to a discord Message object and return it.
+
+ Conversion will succeed if given a discord Message ID or link.
+ Returns `text` if the conversion fails.
+ """
+ try:
+ text = await MessageConverter().convert(ctx, text)
+ except commands.BadArgument:
+ log.debug(f"Input '{text:.20}...' is not a valid Discord Message")
+ return text
+
+ @staticmethod
+ def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed:
+ """
+ Converts the text in an embed using a given conversion function, then return the embed.
+
+ Only modifies the following fields: title, description, footer, fields
+ """
+ embed_dict = embed.to_dict()
+
+ embed_dict["title"] = func(embed_dict.get("title", ""))
+ embed_dict["description"] = func(embed_dict.get("description", ""))
+
+ if "footer" in embed_dict:
+ embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", ""))
+
+ if "fields" in embed_dict:
+ for field in embed_dict["fields"]:
+ field["name"] = func(field.get("name", ""))
+ field["value"] = func(field.get("value", ""))
+
+ return Embed.from_dict(embed_dict)
+
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Fun Cog load."""
bot.add_cog(Fun(bot))
log.info("Fun cog loaded")
diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py
new file mode 100644
index 00000000..0ba74d9c
--- /dev/null
+++ b/bot/seasons/evergreen/issues.py
@@ -0,0 +1,57 @@
+import logging
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+from bot.decorators import override_in_channel
+
+log = logging.getLogger(__name__)
+
+
+class Issues(commands.Cog):
+ """Cog that allows users to retrieve issues from GitHub."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=("issues",))
+ @override_in_channel
+ async def issue(
+ self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord"
+ ) -> None:
+ """Command to retrieve issues from a GitHub repository."""
+ api_url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
+ failed_status = {
+ 404: f"Issue #{number} doesn't exist in the repository {user}/{repository}.",
+ 403: f"Rate limit exceeded. Please wait a while before trying again!"
+ }
+
+ async with self.bot.http_session.get(api_url) as r:
+ json_data = await r.json()
+ response_code = r.status
+
+ if response_code in failed_status:
+ return await ctx.send(failed_status[response_code])
+
+ repo_url = f"https://github.com/{user}/{repository}"
+ issue_embed = discord.Embed(colour=Colours.bright_green)
+ issue_embed.add_field(name="Repository", value=f"[{user}/{repository}]({repo_url})", inline=False)
+ issue_embed.add_field(name="Issue Number", value=f"#{number}", inline=False)
+ issue_embed.add_field(name="Status", value=json_data["state"].title())
+ issue_embed.add_field(name="Link", value=json_data["html_url"], inline=False)
+
+ description = json_data["body"]
+ if len(description) > 1024:
+ placeholder = " [...]"
+ description = f"{description[:1024 - len(placeholder)]}{placeholder}"
+
+ issue_embed.add_field(name="Description", value=description, inline=False)
+
+ await ctx.send(embed=issue_embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Github Issues Cog Load."""
+ bot.add_cog(Issues(bot))
+ log.info("Issues cog loaded")
diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py
index 55652af7..e47ef454 100644
--- a/bot/seasons/evergreen/magic_8ball.py
+++ b/bot/seasons/evergreen/magic_8ball.py
@@ -11,13 +11,13 @@ log = logging.getLogger(__name__)
class Magic8ball(commands.Cog):
"""A Magic 8ball command to respond to a user's question."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
with open(Path("bot/resources/evergreen/magic8ball.json"), "r") as file:
self.answers = json.load(file)
@commands.command(name="8ball")
- async def output_answer(self, ctx, *, question):
+ async def output_answer(self, ctx: commands.Context, *, question: str) -> None:
"""Return a Magic 8ball answer from answers list."""
if len(question.split()) >= 3:
answer = random.choice(self.answers)
@@ -26,7 +26,7 @@ class Magic8ball(commands.Cog):
await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Magic 8ball Cog load."""
bot.add_cog(Magic8ball(bot))
log.info("Magic8ball cog loaded")
diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py
new file mode 100644
index 00000000..b0ba8145
--- /dev/null
+++ b/bot/seasons/evergreen/minesweeper.py
@@ -0,0 +1,285 @@
+import logging
+import typing
+from dataclasses import dataclass
+from random import randint, random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Client
+
+MESSAGE_MAPPING = {
+ 0: ":stop_button:",
+ 1: ":one:",
+ 2: ":two:",
+ 3: ":three:",
+ 4: ":four:",
+ 5: ":five:",
+ 6: ":six:",
+ 7: ":seven:",
+ 8: ":eight:",
+ 9: ":nine:",
+ 10: ":keycap_ten:",
+ "bomb": ":bomb:",
+ "hidden": ":grey_question:",
+ "flag": ":flag_black:",
+ "x": ":x:"
+}
+
+log = logging.getLogger(__name__)
+
+
+class CoordinateConverter(commands.Converter):
+ """Converter for Coordinates."""
+
+ async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]:
+ """Take in a coordinate string and turn it into an (x, y) tuple."""
+ if not 2 <= len(coordinate) <= 3:
+ raise commands.BadArgument('Invalid co-ordinate provided')
+
+ coordinate = coordinate.lower()
+ if coordinate[0].isalpha():
+ digit = coordinate[1:]
+ letter = coordinate[0]
+ else:
+ digit = coordinate[:-1]
+ letter = coordinate[-1]
+
+ if not digit.isdigit():
+ raise commands.BadArgument
+
+ x = ord(letter) - ord('a')
+ y = int(digit) - 1
+
+ if (not 0 <= x <= 9) or (not 0 <= y <= 9):
+ raise commands.BadArgument
+ return x, y
+
+
+GameBoard = typing.List[typing.List[typing.Union[str, int]]]
+
+
+@dataclass
+class Game:
+ """The data for a game."""
+
+ board: GameBoard
+ revealed: GameBoard
+ dm_msg: discord.Message
+ chat_msg: discord.Message
+ activated_on_server: bool
+
+
+GamesDict = typing.Dict[int, Game]
+
+
+class Minesweeper(commands.Cog):
+ """Play a game of Minesweeper."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.games: GamesDict = {} # Store the currently running games
+
+ @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True)
+ async def minesweeper_group(self, ctx: commands.Context) -> None:
+ """Commands for Playing Minesweeper."""
+ await ctx.send_help(ctx.command)
+
+ @staticmethod
+ def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]:
+ """Get all the neighbouring x and y including it self."""
+ for x_ in [x - 1, x, x + 1]:
+ for y_ in [y - 1, y, y + 1]:
+ if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10:
+ yield x_, y_
+
+ def generate_board(self, bomb_chance: float) -> GameBoard:
+ """Generate a 2d array for the board."""
+ board: GameBoard = [
+ [
+ "bomb" if random() <= bomb_chance else "number"
+ for _ in range(10)
+ ] for _ in range(10)
+ ]
+
+ # make sure there is always a free cell
+ board[randint(0, 9)][randint(0, 9)] = "number"
+
+ for y, row in enumerate(board):
+ for x, cell in enumerate(row):
+ if cell == "number":
+ # calculate bombs near it
+ bombs = 0
+ for x_, y_ in self.get_neighbours(x, y):
+ if board[y_][x_] == "bomb":
+ bombs += 1
+ board[y][x] = bombs
+ return board
+
+ @staticmethod
+ def format_for_discord(board: GameBoard) -> str:
+ """Format the board as a string for Discord."""
+ discord_msg = (
+ ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:"
+ ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:"
+ ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n"
+ )
+ rows = []
+ for row_number, row in enumerate(board):
+ new_row = f"{MESSAGE_MAPPING[row_number + 1]} "
+ new_row += "".join(MESSAGE_MAPPING[cell] for cell in row)
+ rows.append(new_row)
+
+ discord_msg += "\n".join(rows)
+ return discord_msg
+
+ @minesweeper_group.command(name="start")
+ async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None:
+ """Start a game of Minesweeper."""
+ if ctx.author.id in self.games: # Player is already playing
+ await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2)
+ await ctx.message.delete(delay=2)
+ return
+
+ # Add game to list
+ board: GameBoard = self.generate_board(bomb_chance)
+ revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)]
+
+ if ctx.guild:
+ await ctx.send(f"{ctx.author.mention} is playing Minesweeper")
+ chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}")
+ else:
+ chat_msg = None
+
+ await ctx.author.send(
+ f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n"
+ f"Close the game with `{Client.prefix}ms end`\n"
+ )
+ dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}")
+
+ self.games[ctx.author.id] = Game(
+ board=board,
+ revealed=revealed_board,
+ dm_msg=dm_msg,
+ chat_msg=chat_msg,
+ activated_on_server=ctx.guild is not None
+ )
+
+ async def update_boards(self, ctx: commands.Context) -> None:
+ """Update both playing boards."""
+ game = self.games[ctx.author.id]
+ await game.dm_msg.delete()
+ game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}")
+ if game.activated_on_server:
+ await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}")
+
+ @commands.dm_only()
+ @minesweeper_group.command(name="flag")
+ async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None:
+ """Place multiple flags on the board."""
+ board: GameBoard = self.games[ctx.author.id].revealed
+ for x, y in coordinates:
+ if board[y][x] == "hidden":
+ board[y][x] = "flag"
+
+ await self.update_boards(ctx)
+
+ @staticmethod
+ def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None:
+ """Reveals all the bombs."""
+ for y, row in enumerate(board):
+ for x, cell in enumerate(row):
+ if cell == "bomb":
+ revealed[y][x] = cell
+
+ async def lost(self, ctx: commands.Context) -> None:
+ """The player lost the game."""
+ game = self.games[ctx.author.id]
+ self.reveal_bombs(game.revealed, game.board)
+ await ctx.author.send(":fire: You lost! :fire:")
+ if game.activated_on_server:
+ await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:")
+
+ async def won(self, ctx: commands.Context) -> None:
+ """The player won the game."""
+ game = self.games[ctx.author.id]
+ await ctx.author.send(":tada: You won! :tada:")
+ if game.activated_on_server:
+ await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:")
+
+ def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None:
+ """Recursively reveal adjacent cells when a 0 cell is encountered."""
+ for x_, y_ in self.get_neighbours(x, y):
+ if revealed[y_][x_] != "hidden":
+ continue
+ revealed[y_][x_] = board[y_][x_]
+ if board[y_][x_] == 0:
+ self.reveal_zeros(revealed, board, x_, y_)
+
+ async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool:
+ """Checks if a player has won."""
+ if any(
+ revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb"
+ for x in range(10)
+ for y in range(10)
+ ):
+ return False
+ else:
+ await self.won(ctx)
+ return True
+
+ async def reveal_one(
+ self,
+ ctx: commands.Context,
+ revealed: GameBoard,
+ board: GameBoard,
+ x: int,
+ y: int
+ ) -> bool:
+ """
+ Reveal one square.
+
+ return is True if the game ended, breaking the loop in `reveal_command` and deleting the game
+ """
+ revealed[y][x] = board[y][x]
+ if board[y][x] == "bomb":
+ await self.lost(ctx)
+ revealed[y][x] = "x" # mark bomb that made you lose with a x
+ return True
+ elif board[y][x] == 0:
+ self.reveal_zeros(revealed, board, x, y)
+ return await self.check_if_won(ctx, revealed, board)
+
+ @commands.dm_only()
+ @minesweeper_group.command(name="reveal")
+ async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None:
+ """Reveal multiple cells."""
+ game = self.games[ctx.author.id]
+ revealed: GameBoard = game.revealed
+ board: GameBoard = game.board
+
+ for x, y in coordinates:
+ # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game
+ if await self.reveal_one(ctx, revealed, board, x, y):
+ await self.update_boards(ctx)
+ del self.games[ctx.author.id]
+ break
+ else:
+ await self.update_boards(ctx)
+
+ @minesweeper_group.command(name="end")
+ async def end_command(self, ctx: commands.Context) -> None:
+ """End your current game."""
+ game = self.games[ctx.author.id]
+ game.revealed = game.board
+ await self.update_boards(ctx)
+ new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}"
+ await game.dm_msg.edit(content=new_msg)
+ if game.activated_on_server:
+ await game.chat_msg.edit(content=new_msg)
+ del self.games[ctx.author.id]
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Minesweeper cog."""
+ bot.add_cog(Minesweeper(bot))
+ log.info("Minesweeper cog loaded")
diff --git a/bot/seasons/evergreen/recommend_game.py b/bot/seasons/evergreen/recommend_game.py
new file mode 100644
index 00000000..835a4e53
--- /dev/null
+++ b/bot/seasons/evergreen/recommend_game.py
@@ -0,0 +1,51 @@
+import json
+import logging
+from pathlib import Path
+from random import shuffle
+
+import discord
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+game_recs = []
+
+# Populate the list `game_recs` with resource files
+for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"):
+ with rec_path.open(encoding='utf-8') as file:
+ data = json.load(file)
+ game_recs.append(data)
+shuffle(game_recs)
+
+
+class RecommendGame(commands.Cog):
+ """Commands related to recommending games."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.index = 0
+
+ @commands.command(name="recommendgame", aliases=['gamerec'])
+ async def recommend_game(self, ctx: commands.Context) -> None:
+ """Sends an Embed of a random game recommendation."""
+ if self.index >= len(game_recs):
+ self.index = 0
+ shuffle(game_recs)
+ game = game_recs[self.index]
+ self.index += 1
+
+ author = self.bot.get_user(int(game['author']))
+
+ # Creating and formatting Embed
+ embed = discord.Embed(color=discord.Colour.blue())
+ if author is not None:
+ embed.set_author(name=author.name, icon_url=author.avatar_url)
+ embed.set_image(url=game['image'])
+ embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description'])
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Loads the RecommendGame cog."""
+ bot.add_cog(RecommendGame(bot))
+ log.info("RecommendGame cog loaded")
diff --git a/bot/seasons/evergreen/showprojects.py b/bot/seasons/evergreen/showprojects.py
index 37809b33..a943e548 100644
--- a/bot/seasons/evergreen/showprojects.py
+++ b/bot/seasons/evergreen/showprojects.py
@@ -1,5 +1,6 @@
import logging
+from discord import Message
from discord.ext import commands
from bot.constants import Channels
@@ -8,15 +9,15 @@ log = logging.getLogger(__name__)
class ShowProjects(commands.Cog):
- """Cog that reacts to posts in the #show-your-projects"""
+ """Cog that reacts to posts in the #show-your-projects."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.lastPoster = 0 # Given 0 as the default last poster ID as no user can actually have 0 assigned to them
@commands.Cog.listener()
- async def on_message(self, message):
- """Adds reactions to posts in #show-your-projects"""
+ async def on_message(self, message: Message) -> None:
+ """Adds reactions to posts in #show-your-projects."""
reactions = ["\U0001f44d", "\U00002764", "\U0001f440", "\U0001f389", "\U0001f680", "\U00002b50", "\U0001f6a9"]
if (message.channel.id == Channels.show_your_projects
and message.author.bot is False
@@ -27,7 +28,7 @@ class ShowProjects(commands.Cog):
self.lastPoster = message.author.id
-def setup(bot):
- """Show Projects Reaction Cog"""
+def setup(bot: commands.Bot) -> None:
+ """Show Projects Reaction Cog."""
bot.add_cog(ShowProjects(bot))
log.info("ShowProjects cog loaded")
diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py
index d0e57dae..d7f9f20c 100644
--- a/bot/seasons/evergreen/snakes/__init__.py
+++ b/bot/seasons/evergreen/snakes/__init__.py
@@ -1,11 +1,13 @@
import logging
+from discord.ext import commands
+
from bot.seasons.evergreen.snakes.snakes_cog import Snakes
log = logging.getLogger(__name__)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Snakes Cog load."""
bot.add_cog(Snakes(bot))
log.info("Snakes cog loaded")
diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py
index f2637530..57103b57 100644
--- a/bot/seasons/evergreen/snakes/converter.py
+++ b/bot/seasons/evergreen/snakes/converter.py
@@ -1,9 +1,10 @@
import json
import logging
import random
+from typing import Iterable, List
import discord
-from discord.ext.commands import Converter
+from discord.ext.commands import Context, Converter
from fuzzywuzzy import fuzz
from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES
@@ -18,7 +19,7 @@ class Snake(Converter):
snakes = None
special_cases = None
- async def convert(self, ctx, name):
+ async def convert(self, ctx: Context, name: str) -> str:
"""Convert the input snake name to the closest matching Snake object."""
await self.build_list()
name = name.lower()
@@ -26,7 +27,7 @@ class Snake(Converter):
if name == 'python':
return 'Python (programming language)'
- def get_potential(iterable, *, threshold=80):
+ def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]:
nonlocal name
potential = []
@@ -58,7 +59,7 @@ class Snake(Converter):
return names.get(name, name)
@classmethod
- async def build_list(cls):
+ async def build_list(cls) -> None:
"""Build list of snakes from the static snake resources."""
# Get all the snakes
if cls.snakes is None:
@@ -72,7 +73,7 @@ class Snake(Converter):
cls.special_cases = {snake['name'].lower(): snake for snake in special_cases}
@classmethod
- async def random(cls):
+ async def random(cls) -> str:
"""
Get a random Snake from the loaded resources.
diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py
index 1d138aff..1ed38f86 100644
--- a/bot/seasons/evergreen/snakes/snakes_cog.py
+++ b/bot/seasons/evergreen/snakes/snakes_cog.py
@@ -9,13 +9,13 @@ import textwrap
import urllib
from functools import partial
from io import BytesIO
-from typing import Any, Dict
+from typing import Any, Dict, List
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, Cog, Context, bot_has_permissions, group
+from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group
from bot.constants import ERROR_REPLIES, Tokens
from bot.decorators import locked
@@ -154,7 +154,7 @@ class Snakes(Cog):
# region: Helper methods
@staticmethod
- def _beautiful_pastel(hue):
+ def _beautiful_pastel(hue: float) -> int:
"""Returns random bright pastels."""
light = random.uniform(0.7, 0.85)
saturation = 1
@@ -250,7 +250,7 @@ class Snakes(Cog):
return buffer
@staticmethod
- def _snakify(message):
+ def _snakify(message: str) -> str:
"""Sssnakifffiesss a sstring."""
# Replace fricatives with exaggerated snake fricatives.
simple_fricatives = [
@@ -272,7 +272,7 @@ class Snakes(Cog):
return message
- async def _fetch(self, session, url, params=None):
+ async def _fetch(self, session: aiohttp.ClientSession, url: str, params: dict = None) -> dict:
"""Asynchronous web request helper method."""
if params is None:
params = {}
@@ -281,7 +281,7 @@ class Snakes(Cog):
async with session.get(url, params=params) as response:
return await response.json()
- def _get_random_long_message(self, messages, retries=10):
+ def _get_random_long_message(self, messages: List[str], retries: int = 10) -> str:
"""
Fetch a message that's at least 3 words long, if possible to do so in retries attempts.
@@ -303,9 +303,6 @@ class Snakes(Cog):
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 = {}
@@ -403,16 +400,12 @@ class Snakes(Cog):
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.
- """
+ """Gets a random snake name."""
return random.choice(self.snake_names)
- async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list):
+ async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list) -> None:
"""Validate the answer using a reaction event loop."""
- def predicate(reaction, user):
+ def predicate(reaction: Reaction, user: Member) -> bool:
"""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.
@@ -443,14 +436,14 @@ class Snakes(Cog):
# region: Commands
@group(name='snakes', aliases=('snake',), invoke_without_command=True)
- async def snakes_group(self, ctx: Context):
+ async def snakes_group(self, ctx: Context) -> None:
"""Commands from our first code jam."""
await ctx.send_help(ctx.command)
@bot_has_permissions(manage_messages=True)
@snakes_group.command(name='antidote')
@locked()
- async def antidote_command(self, ctx: Context):
+ async def antidote_command(self, ctx: Context) -> None:
"""
Antidote! Can you create the antivenom before the patient dies?
@@ -465,7 +458,7 @@ class Snakes(Cog):
This game was created by Lord Bisk and Runew0lf.
"""
- def predicate(reaction_: Reaction, user_: Member):
+ def predicate(reaction_: Reaction, user_: Member) -> bool:
"""Make sure that this reaction is what we want to operate on."""
return (
all((
@@ -591,7 +584,7 @@ class Snakes(Cog):
await board_id.clear_reactions()
@snakes_group.command(name='draw')
- async def draw_command(self, ctx: Context):
+ async def draw_command(self, ctx: Context) -> None:
"""
Draws a random snek using Perlin noise.
@@ -631,14 +624,10 @@ class Snakes(Cog):
@snakes_group.command(name='get')
@bot_has_permissions(manage_messages=True)
@locked()
- async def get_command(self, ctx: Context, *, name: Snake = None):
+ async def get_command(self, ctx: Context, *, name: Snake = None) -> 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():
@@ -683,7 +672,7 @@ class Snakes(Cog):
@snakes_group.command(name='guess', aliases=('identify',))
@locked()
- async def guess_command(self, ctx):
+ async def guess_command(self, ctx: Context) -> None:
"""
Snake identifying game.
@@ -717,7 +706,7 @@ class Snakes(Cog):
await self._validate_answer(ctx, guess, answer, options)
@snakes_group.command(name='hatch')
- async def hatch_command(self, ctx: Context):
+ async def hatch_command(self, ctx: Context) -> None:
"""
Hatches your personal snake.
@@ -748,7 +737,7 @@ class Snakes(Cog):
await ctx.channel.send(embed=my_snake_embed)
@snakes_group.command(name='movie')
- async def movie_command(self, ctx: Context):
+ async def movie_command(self, ctx: Context) -> None:
"""
Gets a random snake-related movie from OMDB.
@@ -818,7 +807,7 @@ class Snakes(Cog):
@snakes_group.command(name='quiz')
@locked()
- async def quiz_command(self, ctx: Context):
+ async def quiz_command(self, ctx: Context) -> None:
"""
Asks a snake-related question in the chat and validates the user's guess.
@@ -843,7 +832,7 @@ class Snakes(Cog):
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):
+ async def name_command(self, ctx: Context, *, name: str = None) -> None:
"""
Snakifies a username.
@@ -915,7 +904,7 @@ class Snakes(Cog):
@snakes_group.command(name='sal')
@locked()
- async def sal_command(self, ctx: Context):
+ async def sal_command(self, ctx: Context) -> None:
"""
Play a game of Snakes and Ladders.
@@ -933,7 +922,7 @@ class Snakes(Cog):
await game.open_game()
@snakes_group.command(name='about')
- async def about_command(self, ctx: Context):
+ async def about_command(self, ctx: Context) -> None:
"""Show an embed with information about the event, its participants, and its winners."""
contributors = [
"<@!245270749919576066>",
@@ -976,7 +965,7 @@ class Snakes(Cog):
await ctx.channel.send(embed=embed)
@snakes_group.command(name='card')
- async def card_command(self, ctx: Context, *, name: Snake = None):
+ async def card_command(self, ctx: Context, *, name: Snake = None) -> None:
"""
Create an interesting little card from a snake.
@@ -1014,7 +1003,7 @@ class Snakes(Cog):
)
@snakes_group.command(name='fact')
- async def fact_command(self, ctx: Context):
+ async def fact_command(self, ctx: Context) -> None:
"""
Gets a snake-related fact.
@@ -1030,14 +1019,12 @@ class Snakes(Cog):
await ctx.channel.send(embed=embed)
@snakes_group.command(name='snakify')
- async def snakify_command(self, ctx: Context, *, message: str = None):
+ async def snakify_command(self, ctx: Context, *, message: str = None) -> 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.
+ If `message` is passed, the bot will snakify the message.
+ Otherwise, a random message from the user's history is snakified.
Written by Momo and kel.
Modified by lemon.
@@ -1073,12 +1060,11 @@ class Snakes(Cog):
await ctx.channel.send(embed=embed)
@snakes_group.command(name='video', aliases=('get_video',))
- async def video_command(self, ctx: Context, *, search: str = None):
+ async def video_command(self, ctx: Context, *, search: str = None) -> None:
"""
Gets a YouTube video about snakes.
- :param ctx: Context object passed from discord.py
- :param search: Optional, a name of a snake. Used to search for videos with that name
+ If `search` is given, a snake with that name will be searched on Youtube.
Written by Andrew and Prithaj.
"""
@@ -1114,7 +1100,7 @@ class Snakes(Cog):
log.warning(f"YouTube API error. Full response looks like {response}")
@snakes_group.command(name='zen')
- async def zen_command(self, ctx: Context):
+ async def zen_command(self, ctx: Context) -> None:
"""
Gets a random quote from the Zen of Python, except as if spoken by a snake.
@@ -1141,7 +1127,7 @@ class Snakes(Cog):
@get_command.error
@card_command.error
@video_command.error
- async def command_error(self, ctx, error):
+ async def command_error(self, ctx: Context, error: CommandError) -> None:
"""Local error handler for the Snake Cog."""
embed = Embed()
embed.colour = Colour.red()
diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py
index 88fb2032..7d6caf04 100644
--- a/bot/seasons/evergreen/snakes/utils.py
+++ b/bot/seasons/evergreen/snakes/utils.py
@@ -11,7 +11,9 @@ from typing import List, Tuple
from PIL import Image
from PIL.ImageDraw import ImageDraw
from discord import File, Member, Reaction
-from discord.ext.commands import Context
+from discord.ext.commands import Cog, Context
+
+from bot.constants import Roles
SNAKE_RESOURCES = Path("bot/resources/snakes").absolute()
@@ -116,12 +118,12 @@ def get_resource(file: str) -> List[dict]:
return json.load(snakefile)
-def smoothstep(t):
+def smoothstep(t: float) -> float:
"""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):
+def lerp(t: float, a: float, b: float) -> float:
"""Linear interpolation between a and b, given a fraction t."""
return a + t * (b - a)
@@ -138,7 +140,7 @@ class PerlinNoiseFactory(object):
Licensed under ISC
"""
- def __init__(self, dimension, octaves=1, tile=(), unbias=False):
+ def __init__(self, dimension: int, octaves: int = 1, tile: Tuple[int] = (), unbias: bool = False):
"""
Create a new Perlin noise factory in the given number of dimensions.
@@ -152,7 +154,7 @@ class PerlinNoiseFactory(object):
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
+ 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
@@ -166,7 +168,7 @@ class PerlinNoiseFactory(object):
self.gradient = {}
- def _generate_gradient(self):
+ def _generate_gradient(self) -> Tuple[float, ...]:
"""
Generate a random unit vector at each grid point.
@@ -186,7 +188,7 @@ class PerlinNoiseFactory(object):
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):
+ def get_plain_noise(self, *point) -> float:
"""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(
@@ -234,7 +236,7 @@ class PerlinNoiseFactory(object):
return dots[0] * self.scale_factor
- def __call__(self, *point):
+ def __call__(self, *point) -> float:
"""
Get the value of this Perlin noise function at the given point.
@@ -285,20 +287,8 @@ def create_snek_frame(
"""
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.
+ `perlin_lookup_vertical_shift` represents the Perlin noise shift in the Y-dimension for this frame.
+ If `text` is given, display the given text with the snek.
"""
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])
@@ -379,7 +369,7 @@ GAME_SCREEN_EMOJI = [
class SnakeAndLaddersGame:
"""Snakes and Ladders game Cog."""
- def __init__(self, snakes, context: Context):
+ def __init__(self, snakes: Cog, context: Context):
self.snakes = snakes
self.ctx = context
self.channel = self.ctx.channel
@@ -394,14 +384,13 @@ class SnakeAndLaddersGame:
self.positions = None
self.rolls = []
- async def open_game(self):
+ async def open_game(self) -> None:
"""
Create a new Snakes and Ladders game.
- Listen for reactions until players have joined,
- and the game has been started.
+ Listen for reactions until players have joined, and the game has been started.
"""
- def startup_event_check(reaction_: Reaction, user_: Member):
+ def startup_event_check(reaction_: Reaction, user_: Member) -> bool:
"""Make sure that this reaction is what we want to operate on."""
return (
all((
@@ -424,7 +413,6 @@ class SnakeAndLaddersGame:
"**Snakes and Ladders**: A new game is about to start!",
file=File(
str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"),
- # os.path.join("bot", "resources", "snakes", "snakes_and_ladders", "banner.jpg"),
filename='Snakes and Ladders.jpg'
)
)
@@ -447,8 +435,9 @@ class SnakeAndLaddersGame:
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)
+ if user == self.author or (self._is_moderator(user) and user not in self.players):
+ # Allow game author or non-playing moderation staff to cancel a waiting game
+ await self.cancel_game()
return
else:
await self.player_leave(user)
@@ -463,10 +452,11 @@ class SnakeAndLaddersGame:
except asyncio.TimeoutError:
log.debug("Snakes and Ladders timed out waiting for a reaction")
- self.cancel_game(self.author)
+ await self.cancel_game()
return # We're done, no reactions for the last 5 minutes
- async def _add_player(self, user: Member):
+ async def _add_player(self, user: Member) -> None:
+ """Add player to game."""
self.players.append(user)
self.player_tiles[user.id] = 1
@@ -474,7 +464,7 @@ class SnakeAndLaddersGame:
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):
+ async def player_join(self, user: Member) -> None:
"""
Handle players joining the game.
@@ -500,20 +490,16 @@ class SnakeAndLaddersGame:
delete_after=10
)
- async def player_leave(self, user: Member):
+ async def player_leave(self, user: Member) -> bool:
"""
Handle players leaving the game.
- Leaving is prevented if the user initiated the game or if they weren't part of it in the
- first place.
+ Leaving is prevented if the user wasn't part of the game.
+
+ If the number of players reaches 0, the game is terminated. In this case, a sentinel boolean
+ is returned True to prevent a game from continuing after it's destroyed.
"""
- 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
+ is_surrendered = False # Sentinel value to assist with stopping a surrendered game
for p in self.players:
if user == p:
self.players.remove(p)
@@ -524,47 +510,43 @@ class SnakeAndLaddersGame:
delete_after=10
)
- if self.state != 'waiting' and len(self.players) == 1:
+ if self.state != 'waiting' and len(self.players) == 0:
await self.channel.send("**Snakes and Ladders**: The game has been surrendered!")
+ is_surrendered = True
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):
- """Allow the game author to cancel the running game."""
- if not user == self.author:
- await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10)
- return
+ return is_surrendered
+ else:
+ await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
+ return is_surrendered
+
+ async def cancel_game(self) -> None:
+ """Cancel the running game."""
await self.channel.send("**Snakes and Ladders**: Game has been canceled.")
self._destruct()
- async def start_game(self, user: Member):
+ async def start_game(self, user: Member) -> None:
"""
Allow the game author to begin the game.
- The game cannot be started if there aren't enough players joined or if the game is in a
- waiting state.
+ The game cannot be started if the game is in a waiting state.
"""
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):
+ async def start_round(self) -> None:
"""Begin the round."""
- def game_event_check(reaction_: Reaction, user_: Member):
+ def game_event_check(reaction_: Reaction, user_: Member) -> bool:
"""Make sure that this reaction is what we want to operate on."""
return (
all((
@@ -577,8 +559,6 @@ class SnakeAndLaddersGame:
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", "snakes_and_ladders", "board.jpg"))
board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg"))
player_row_size = math.ceil(MAX_PLAYERS / 2)
@@ -593,9 +573,8 @@ class SnakeAndLaddersGame:
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')
+
+ board_file = File(frame_to_png_bytes(board_img), 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
@@ -625,6 +604,7 @@ class SnakeAndLaddersGame:
for emoji in GAME_SCREEN_EMOJI:
await self.positions.add_reaction(emoji)
+ is_surrendered = False
while True:
try:
reaction, user = await self.ctx.bot.wait_for(
@@ -636,11 +616,12 @@ class SnakeAndLaddersGame:
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)
+ if self._is_moderator(user) and user not in self.players:
+ # Only allow non-playing moderation staff to cancel a running game
+ await self.cancel_game()
return
else:
- await self.player_leave(user)
+ is_surrendered = await self.player_leave(user)
await self.positions.remove_reaction(reaction.emoji, user)
@@ -649,13 +630,16 @@ class SnakeAndLaddersGame:
except asyncio.TimeoutError:
log.debug("Snakes and Ladders timed out waiting for a reaction")
- await self.cancel_game(self.author)
+ await self.cancel_game()
return # We're done, no reactions for the last 5 minutes
# Round completed
- await self._complete_round()
+ # Check to see if the game was surrendered before completing the round, without this
+ # sentinel, the game object would be deleted but the next round still posted into purgatory
+ if not is_surrendered:
+ await self._complete_round()
- async def player_roll(self, user: Member):
+ async def player_roll(self, user: Member) -> None:
"""Handle the player's roll."""
if user.id not in self.player_tiles:
await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
@@ -687,7 +671,8 @@ class SnakeAndLaddersGame:
self.player_tiles[user.id] = min(100, next_tile)
self.round_has_rolled[user.id] = True
- async def _complete_round(self):
+ async def _complete_round(self) -> None:
+ """At the conclusion of a round check to see if there's been a winner."""
self.state = 'post_round'
# check for winner
@@ -702,22 +687,30 @@ class SnakeAndLaddersGame:
self._destruct()
def _check_winner(self) -> Member:
+ """Return a winning member if we're in the post-round state and there's a winner."""
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):
+ def _check_all_rolled(self) -> bool:
+ """Check if all members have made their roll."""
return all(rolled for rolled in self.round_has_rolled.values())
- def _destruct(self):
+ def _destruct(self) -> None:
+ """Clean up the finished game object."""
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
+ def _board_coordinate_from_index(self, index: int) -> Tuple[int, int]:
+ """Convert 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
+
+ @staticmethod
+ def _is_moderator(user: Member) -> bool:
+ """Return True if the user is a Moderator."""
+ return any(Roles.moderator == role.id for role in user.roles)
diff --git a/bot/seasons/evergreen/speedrun.py b/bot/seasons/evergreen/speedrun.py
new file mode 100644
index 00000000..76c5e8d3
--- /dev/null
+++ b/bot/seasons/evergreen/speedrun.py
@@ -0,0 +1,28 @@
+import json
+import logging
+from pathlib import Path
+from random import choice
+
+from discord.ext import commands
+
+log = logging.getLogger(__name__)
+with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file:
+ LINKS = json.load(file)
+
+
+class Speedrun(commands.Cog):
+ """Commands about the video game speedrunning community."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="speedrun")
+ async def get_speedrun(self, ctx: commands.Context) -> None:
+ """Sends a link to a video of a random speedrun."""
+ await ctx.send(choice(LINKS))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Speedrun cog."""
+ bot.add_cog(Speedrun(bot))
+ log.info("Speedrun cog loaded")
diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py
index 92066e0a..6f24f545 100644
--- a/bot/seasons/evergreen/uptime.py
+++ b/bot/seasons/evergreen/uptime.py
@@ -12,11 +12,11 @@ log = logging.getLogger(__name__)
class Uptime(commands.Cog):
"""A cog for posting the bot's uptime."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name="uptime")
- async def uptime(self, ctx):
+ async def uptime(self, ctx: commands.Context) -> None:
"""Responds with the uptime of the bot."""
difference = relativedelta(start_time - arrow.utcnow())
uptime_string = start_time.shift(
@@ -28,7 +28,7 @@ class Uptime(commands.Cog):
await ctx.send(f"I started up {uptime_string}.")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Uptime Cog load."""
bot.add_cog(Uptime(bot))
log.info("Uptime cog loaded")
diff --git a/bot/seasons/halloween/8ball.py b/bot/seasons/halloween/8ball.py
index faf59ca9..2e1c2804 100644
--- a/bot/seasons/halloween/8ball.py
+++ b/bot/seasons/halloween/8ball.py
@@ -15,11 +15,11 @@ with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8")
class SpookyEightBall(commands.Cog):
"""Spooky Eightball answers."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(aliases=('spooky8ball',))
- async def spookyeightball(self, ctx, *, question: str):
+ async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None:
"""Responds with a random response to a question."""
choice = random.choice(responses['responses'])
msg = await ctx.send(choice[0])
@@ -28,7 +28,7 @@ class SpookyEightBall(commands.Cog):
await msg.edit(content=f"{choice[0]} \n{choice[1]}")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Spooky Eight Ball Cog Load."""
bot.add_cog(SpookyEightBall(bot))
log.info("SpookyEightBall cog loaded")
diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py
index d35cbee5..64da7ced 100644
--- a/bot/seasons/halloween/candy_collection.py
+++ b/bot/seasons/halloween/candy_collection.py
@@ -3,6 +3,7 @@ import json
import logging
import os
import random
+from typing import List, Union
import discord
from discord.ext import commands
@@ -23,7 +24,7 @@ ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5%
class CandyCollection(commands.Cog):
"""Candy collection game Cog."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
with open(json_location) as candy:
self.candy_json = json.load(candy)
@@ -34,7 +35,7 @@ class CandyCollection(commands.Cog):
self.get_candyinfo[userid] = userinfo
@commands.Cog.listener()
- async def on_message(self, message):
+ async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
# make sure its a human message
if message.author.bot:
@@ -55,7 +56,7 @@ class CandyCollection(commands.Cog):
return await message.add_reaction('\N{CANDY}')
@commands.Cog.listener()
- async def on_reaction_add(self, reaction, user):
+ async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None:
"""Add/remove candies from a person if the reaction satisfies criteria."""
message = reaction.message
# check to ensure the reactor is human
@@ -101,7 +102,7 @@ class CandyCollection(commands.Cog):
self.candy_json['records'].append(d)
await self.remove_reactions(reaction)
- async def reacted_msg_chance(self, message):
+ async def reacted_msg_chance(self, message: discord.Message) -> None:
"""
Randomly add a skull or candy reaction to a message if there is a reaction there already.
@@ -118,24 +119,25 @@ class CandyCollection(commands.Cog):
self.msg_reacted.append(d)
return await message.add_reaction('\N{CANDY}')
- async def ten_recent_msg(self):
+ async def ten_recent_msg(self) -> List[int]:
"""Get the last 10 messages sent in the channel."""
ten_recent = []
- recent_msg = max(message.id for message
- in self.bot._connection._messages
- if message.channel.id == Channels.seasonalbot_chat)
+ recent_msg_id = max(
+ message.id for message in self.bot._connection._messages
+ if message.channel.id == Channels.seasonalbot_chat
+ )
channel = await self.hacktober_channel()
- ten_recent.append(recent_msg.id)
+ ten_recent.append(recent_msg_id)
for i in range(9):
- o = discord.Object(id=recent_msg.id + i)
+ o = discord.Object(id=recent_msg_id + i)
msg = await next(channel.history(limit=1, before=o))
ten_recent.append(msg.id)
return ten_recent
- async def get_message(self, msg_id):
+ async def get_message(self, msg_id: int) -> Union[discord.Message, None]:
"""Get the message from its ID."""
try:
o = discord.Object(id=msg_id + 1)
@@ -151,11 +153,11 @@ class CandyCollection(commands.Cog):
except Exception:
return None
- async def hacktober_channel(self):
+ async def hacktober_channel(self) -> discord.TextChannel:
"""Get #hacktoberbot channel from its ID."""
return self.bot.get_channel(id=Channels.seasonalbot_chat)
- async def remove_reactions(self, reaction):
+ async def remove_reactions(self, reaction: discord.Reaction) -> None:
"""Remove all candy/skull reactions."""
try:
async for user in reaction.users():
@@ -164,20 +166,20 @@ class CandyCollection(commands.Cog):
except discord.HTTPException:
pass
- async def send_spook_msg(self, author, channel, candies):
+ async def send_spook_msg(self, author: discord.Member, channel: discord.TextChannel, candies: int) -> None:
"""Send a spooky message."""
e = discord.Embed(colour=author.colour)
e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; "
f"I took {candies} candies and quickly took flight.")
await channel.send(embed=e)
- def save_to_json(self):
+ def save_to_json(self) -> None:
"""Save JSON to a local file."""
with open(json_location, 'w') as outfile:
json.dump(self.candy_json, outfile)
@commands.command()
- async def candy(self, ctx):
+ async def candy(self, ctx: commands.Context) -> None:
"""Get the candy leaderboard and save to JSON."""
# Use run_in_executor to prevent blocking
thing = functools.partial(self.save_to_json)
@@ -213,7 +215,7 @@ class CandyCollection(commands.Cog):
await ctx.send(embed=e)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Candy Collection game Cog load."""
bot.add_cog(CandyCollection(bot))
log.info("CandyCollection cog loaded")
diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py
index b6b5a900..20797037 100644
--- a/bot/seasons/halloween/hacktoberstats.py
+++ b/bot/seasons/halloween/hacktoberstats.py
@@ -1,32 +1,33 @@
import json
import logging
import re
-import typing
from collections import Counter
from datetime import datetime
from pathlib import Path
+from typing import List, Tuple
import aiohttp
import discord
from discord.ext import commands
+from bot.utils.persist import make_persistent
+
log = logging.getLogger(__name__)
+CURRENT_YEAR = datetime.now().year # Used to construct GH API query
+PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded
+
class HacktoberStats(commands.Cog):
"""Hacktoberfest statistics Cog."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
- self.link_json = Path("bot/resources/github_links.json")
+ self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json"))
self.linked_accounts = self.load_linked_users()
- @commands.group(
- name='hacktoberstats',
- aliases=('hackstats',),
- invoke_without_command=True
- )
- async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None):
+ @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
+ async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Display an embed for a user's Hacktoberfest contributions.
@@ -43,8 +44,8 @@ class HacktoberStats(commands.Cog):
else:
msg = (
f"{author_mention}, you have not linked a GitHub account\n\n"
- f"You can link your GitHub account using:\n```{ctx.prefix}stats link github_username```\n"
- f"Or query GitHub stats directly using:\n```{ctx.prefix}stats github_username```"
+ f"You can link your GitHub account using:\n```{ctx.prefix}hackstats link github_username```\n"
+ f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```"
)
await ctx.send(msg)
return
@@ -52,7 +53,7 @@ class HacktoberStats(commands.Cog):
await self.get_stats(ctx, github_username)
@hacktoberstats_group.command(name="link")
- async def link_user(self, ctx: commands.Context, github_username: str = None):
+ async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Link the invoking user's Github github_username to their Discord ID.
@@ -85,7 +86,7 @@ class HacktoberStats(commands.Cog):
await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
@hacktoberstats_group.command(name="unlink")
- async def unlink_user(self, ctx: commands.Context):
+ async def unlink_user(self, ctx: commands.Context) -> None:
"""Remove the invoking user's account link from the log."""
author_id, author_mention = HacktoberStats._author_mention_from_context(ctx)
@@ -99,7 +100,7 @@ class HacktoberStats(commands.Cog):
self.save_linked_users()
- def load_linked_users(self) -> typing.Dict:
+ def load_linked_users(self) -> dict:
"""
Load list of linked users from local JSON file.
@@ -122,7 +123,7 @@ class HacktoberStats(commands.Cog):
logging.info(f"Linked account log: '{self.link_json}' does not exist")
return {}
- def save_linked_users(self):
+ def save_linked_users(self) -> None:
"""
Save list of linked users to local JSON file.
@@ -139,7 +140,7 @@ class HacktoberStats(commands.Cog):
json.dump(self.linked_accounts, fID, default=str)
logging.info(f"linked_accounts saved to '{self.link_json}'")
- async def get_stats(self, ctx: commands.Context, github_username: str):
+ async def get_stats(self, ctx: commands.Context, github_username: str) -> None:
"""
Query GitHub's API for PRs created by a GitHub user during the month of October.
@@ -158,18 +159,18 @@ class HacktoberStats(commands.Cog):
else:
await ctx.send(f"No October GitHub contributions found for '{github_username}'")
- def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed:
+ def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed:
"""Return a stats embed built from github_username's PRs."""
logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'")
pr_stats = self._summarize_prs(prs)
n = pr_stats['n_prs']
- if n >= 5:
+ if n >= PRS_FOR_SHIRT:
shirtstr = f"**{github_username} has earned a tshirt!**"
- elif n == 4:
+ elif n == PRS_FOR_SHIRT - 1:
shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
else:
- shirtstr = f"**{github_username} is {5 - n} PRs away from a tshirt!**"
+ shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**"
stats_embed = discord.Embed(
title=f"{github_username}'s Hacktoberfest",
@@ -186,7 +187,7 @@ class HacktoberStats(commands.Cog):
stats_embed.set_author(
name="Hacktoberfest",
url="https://hacktoberfest.digitalocean.com",
- icon_url="https://hacktoberfest.digitalocean.com/assets/logo-hacktoberfest.png"
+ icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png"
)
stats_embed.add_field(
name="Top 5 Repositories:",
@@ -197,7 +198,7 @@ class HacktoberStats(commands.Cog):
return stats_embed
@staticmethod
- async def get_october_prs(github_username: str) -> typing.List[dict]:
+ async def get_october_prs(github_username: str) -> List[dict]:
"""
Query GitHub's API for PRs created during the month of October by github_username.
@@ -219,7 +220,7 @@ class HacktoberStats(commands.Cog):
not_label = "invalid"
action_type = "pr"
is_query = f"public+author:{github_username}"
- date_range = "2018-10-01..2018-10-31"
+ date_range = f"{CURRENT_YEAR}-10-01..{CURRENT_YEAR}-10-31"
per_page = "300"
query_url = (
f"{base_url}"
@@ -274,7 +275,7 @@ class HacktoberStats(commands.Cog):
return re.findall(exp, in_url)[0]
@staticmethod
- def _summarize_prs(prs: typing.List[dict]) -> typing.Dict:
+ def _summarize_prs(prs: List[dict]) -> dict:
"""
Generate statistics from an input list of PR dictionaries, as output by get_october_prs.
@@ -288,7 +289,7 @@ class HacktoberStats(commands.Cog):
return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
@staticmethod
- def _build_top5str(stats: typing.List[tuple]) -> str:
+ def _build_top5str(stats: List[tuple]) -> str:
"""
Build a string from the Top 5 contributions that is compatible with a discord.Embed field.
@@ -316,7 +317,7 @@ class HacktoberStats(commands.Cog):
return "contributions"
@staticmethod
- def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple:
+ def _author_mention_from_context(ctx: commands.Context) -> Tuple:
"""Return stringified Message author ID and mentionable string from commands.Context."""
author_id = str(ctx.message.author.id)
author_mention = ctx.message.author.mention
@@ -324,7 +325,7 @@ class HacktoberStats(commands.Cog):
return author_id, author_mention
-def setup(bot):
+def setup(bot): # Noqa
"""Hacktoberstats Cog load."""
bot.add_cog(HacktoberStats(bot))
log.info("HacktoberStats cog loaded")
diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py
index f09aa4ad..f8610bd3 100644
--- a/bot/seasons/halloween/halloween_facts.py
+++ b/bot/seasons/halloween/halloween_facts.py
@@ -3,6 +3,7 @@ import logging
import random
from datetime import timedelta
from pathlib import Path
+from typing import Tuple
import discord
from discord.ext import commands
@@ -28,7 +29,7 @@ INTERVAL = timedelta(hours=6).total_seconds()
class HalloweenFacts(commands.Cog):
"""A Cog for displaying interesting facts about Halloween."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file:
self.halloween_facts = json.load(file)
@@ -37,31 +38,31 @@ class HalloweenFacts(commands.Cog):
random.shuffle(self.facts)
@commands.Cog.listener()
- async def on_ready(self):
+ async def on_ready(self) -> None:
"""Get event Channel object and initialize fact task loop."""
self.channel = self.bot.get_channel(Channels.seasonalbot_chat)
self.bot.loop.create_task(self._fact_publisher_task())
- def random_fact(self):
+ def random_fact(self) -> Tuple[int, str]:
"""Return a random fact from the loaded facts."""
return random.choice(self.facts)
@commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact")
- async def get_random_fact(self, ctx):
+ async def get_random_fact(self, ctx: commands.Context) -> None:
"""Reply with the most recent Halloween fact."""
index, fact = self.random_fact()
embed = self._build_embed(index, fact)
await ctx.send(embed=embed)
@staticmethod
- def _build_embed(index, fact):
+ def _build_embed(index: int, fact: str) -> discord.Embed:
"""Builds a Discord embed from the given fact and its index."""
emoji = random.choice(SPOOKY_EMOJIS)
title = f"{emoji} Halloween Fact #{index + 1}"
return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Halloween facts Cog load."""
bot.add_cog(HalloweenFacts(bot))
log.info("HalloweenFacts cog loaded")
diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py
index 334781ab..dfcc2b1e 100644
--- a/bot/seasons/halloween/halloweenify.py
+++ b/bot/seasons/halloween/halloweenify.py
@@ -13,12 +13,12 @@ log = logging.getLogger(__name__)
class Halloweenify(commands.Cog):
"""A cog to change a invokers nickname to a spooky one!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.cooldown(1, 300, BucketType.user)
@commands.command()
- async def halloweenify(self, ctx):
+ async def halloweenify(self, ctx: commands.Context) -> None:
"""Change your nickname into a much spookier one!"""
async with ctx.typing():
with open(Path("bot/resources/halloween/halloweenify.json"), "r") as f:
@@ -46,7 +46,7 @@ class Halloweenify(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Halloweenify Cog load."""
bot.add_cog(Halloweenify(bot))
log.info("Halloweenify cog loaded")
diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py
index 4e967cca..12e1d022 100644
--- a/bot/seasons/halloween/monstersurvey.py
+++ b/bot/seasons/halloween/monstersurvey.py
@@ -30,21 +30,17 @@ class MonsterSurvey(Cog):
with open(self.registry_location, 'r') as jason:
self.voter_registry = json.load(jason)
- def json_write(self):
+ def json_write(self) -> None:
"""Write voting results to a local JSON file."""
log.info("Saved Monster Survey Results")
with open(self.registry_location, 'w') as jason:
json.dump(self.voter_registry, jason, indent=2)
- def cast_vote(self, id: int, monster: str):
+ def cast_vote(self, id: int, monster: str) -> None:
"""
Cast a user's vote for the specified monster.
If the user has already voted, their existing vote is removed.
-
- :param id: The id of the person voting
- :param monster: the string key of the json that represents a monster
- :return: None
"""
vr = self.voter_registry
for m in vr.keys():
@@ -54,7 +50,7 @@ class MonsterSurvey(Cog):
if id in vr[m]['votes'] and m != monster:
vr[m]['votes'].remove(id)
- def get_name_by_leaderboard_index(self, n):
+ def get_name_by_leaderboard_index(self, n: int) -> str:
"""Return the monster at the specified leaderboard index."""
n = n - 1
vr = self.voter_registry
@@ -64,9 +60,9 @@ class MonsterSurvey(Cog):
@commands.group(
name='monster',
- aliases=('ms',)
+ aliases=('mon',)
)
- async def monster_group(self, ctx: Context):
+ async def monster_group(self, ctx: Context) -> None:
"""The base voting command. If nothing is called, then it will return an embed."""
if ctx.invoked_subcommand is None:
async with ctx.typing():
@@ -96,7 +92,7 @@ class MonsterSurvey(Cog):
@monster_group.command(
name='vote'
)
- async def monster_vote(self, ctx: Context, name=None):
+ async def monster_vote(self, ctx: Context, name: str = None) -> None:
"""
Cast a vote for a particular monster.
@@ -147,14 +143,8 @@ class MonsterSurvey(Cog):
@monster_group.command(
name='show'
)
- async def monster_show(self, ctx: Context, name=None):
- """
- Shows the named monster. If one is not named, it sends the default voting embed instead.
-
- :param ctx:
- :param name:
- :return:
- """
+ async def monster_show(self, ctx: Context, name: str = None) -> None:
+ """Shows the named monster. If one is not named, it sends the default voting embed instead."""
if name is None:
await ctx.invoke(self.monster_leaderboard)
return
@@ -184,13 +174,8 @@ class MonsterSurvey(Cog):
name='leaderboard',
aliases=('lb',)
)
- async def monster_leaderboard(self, ctx: Context):
- """
- Shows the current standings.
-
- :param ctx:
- :return:
- """
+ async def monster_leaderboard(self, ctx: Context) -> None:
+ """Shows the current standings."""
async with ctx.typing():
vr = self.voter_registry
top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True)
@@ -215,7 +200,7 @@ class MonsterSurvey(Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: Bot) -> None:
"""Monster survey Cog load."""
bot.add_cog(MonsterSurvey(bot))
log.info("MonsterSurvey cog loaded")
diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py
index cd95a3a2..3823a3e4 100644
--- a/bot/seasons/halloween/scarymovie.py
+++ b/bot/seasons/halloween/scarymovie.py
@@ -16,11 +16,11 @@ TMDB_TOKEN = environ.get('TMDB_TOKEN')
class ScaryMovie(commands.Cog):
"""Selects a random scary movie and embeds info into Discord chat."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name='scarymovie', alias=['smovie'])
- async def random_movie(self, ctx):
+ async def random_movie(self, ctx: commands.Context) -> None:
"""Randomly select a scary movie and display information about it."""
async with ctx.typing():
selection = await self.select_movie()
@@ -29,7 +29,7 @@ class ScaryMovie(commands.Cog):
await ctx.send(embed=movie_details)
@staticmethod
- async def select_movie():
+ async def select_movie() -> dict:
"""Selects a random movie and returns a JSON of movie details from TMDb."""
url = 'https://api.themoviedb.org/4/discover/movie'
params = {
@@ -62,7 +62,7 @@ class ScaryMovie(commands.Cog):
return await selection.json()
@staticmethod
- async def format_metadata(movie):
+ async def format_metadata(movie: dict) -> Embed:
"""Formats raw TMDb data to be embedded in Discord chat."""
# Build the relevant URLs.
movie_id = movie.get("id")
@@ -126,7 +126,7 @@ class ScaryMovie(commands.Cog):
return embed
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Scary movie Cog load."""
bot.add_cog(ScaryMovie(bot))
log.info("ScaryMovie cog loaded")
diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py
index 9bdef1a8..268de3fb 100644
--- a/bot/seasons/halloween/spookyavatar.py
+++ b/bot/seasons/halloween/spookyavatar.py
@@ -15,10 +15,10 @@ log = logging.getLogger(__name__)
class SpookyAvatar(commands.Cog):
"""A cog that spookifies an avatar."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
- async def get(self, url):
+ async def get(self, url: str) -> bytes:
"""Returns the contents of the supplied URL."""
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
@@ -26,7 +26,7 @@ class SpookyAvatar(commands.Cog):
@commands.command(name='savatar', aliases=('spookyavatar', 'spookify'),
brief='Spookify an user\'s avatar.')
- async def spooky_avatar(self, ctx, user: discord.Member = None):
+ async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None:
"""A command to print the user's spookified avatar."""
if user is None:
user = ctx.message.author
@@ -47,7 +47,7 @@ class SpookyAvatar(commands.Cog):
os.remove(str(ctx.message.id)+'.png')
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Spooky avatar Cog load."""
bot.add_cog(SpookyAvatar(bot))
log.info("SpookyAvatar cog loaded")
diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py
index ba2ad6e5..818de8cd 100644
--- a/bot/seasons/halloween/spookygif.py
+++ b/bot/seasons/halloween/spookygif.py
@@ -12,11 +12,11 @@ log = logging.getLogger(__name__)
class SpookyGif(commands.Cog):
"""A cog to fetch a random spooky gif from the web!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name="spookygif", aliases=("sgif", "scarygif"))
- async def spookygif(self, ctx):
+ async def spookygif(self, ctx: commands.Context) -> None:
"""Fetches a random gif from the GIPHY API and responds with it."""
async with ctx.typing():
async with aiohttp.ClientSession() as session:
@@ -33,7 +33,7 @@ class SpookyGif(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Spooky GIF Cog load."""
bot.add_cog(SpookyGif(bot))
log.info("SpookyGif cog loaded")
diff --git a/bot/seasons/halloween/spookyrating.py b/bot/seasons/halloween/spookyrating.py
index 08c17a27..7f78f536 100644
--- a/bot/seasons/halloween/spookyrating.py
+++ b/bot/seasons/halloween/spookyrating.py
@@ -17,15 +17,15 @@ with Path("bot/resources/halloween/spooky_rating.json").open() as file:
class SpookyRating(commands.Cog):
- """A cog for calculating one's spooky rating"""
+ """A cog for calculating one's spooky rating."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.local_random = random.Random()
@commands.command()
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
- async def spookyrating(self, ctx, who: discord.Member = None):
+ async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None:
"""
Calculates the spooky rating of someone.
@@ -61,7 +61,7 @@ class SpookyRating(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Spooky Rating Cog load."""
bot.add_cog(SpookyRating(bot))
log.info("SpookyRating cog loaded")
diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py
index 5a086072..90b1254d 100644
--- a/bot/seasons/halloween/spookyreact.py
+++ b/bot/seasons/halloween/spookyreact.py
@@ -2,7 +2,7 @@ import logging
import re
import discord
-from discord.ext.commands import Cog
+from discord.ext.commands import Bot, Cog
log = logging.getLogger(__name__)
@@ -20,11 +20,11 @@ SPOOKY_TRIGGERS = {
class SpookyReact(Cog):
"""A cog that makes the bot react to message triggers."""
- def __init__(self, bot):
+ def __init__(self, bot: Bot):
self.bot = bot
@Cog.listener()
- async def on_message(self, ctx: discord.Message):
+ async def on_message(self, ctx: discord.Message) -> None:
"""
A command to send the seasonalbot github project.
@@ -66,7 +66,7 @@ class SpookyReact(Cog):
return False
-def setup(bot):
+def setup(bot: Bot) -> None:
"""Spooky reaction Cog load."""
bot.add_cog(SpookyReact(bot))
log.info("SpookyReact cog loaded")
diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py
index 44fdd9d6..e0676d0a 100644
--- a/bot/seasons/halloween/spookysound.py
+++ b/bot/seasons/halloween/spookysound.py
@@ -13,14 +13,14 @@ log = logging.getLogger(__name__)
class SpookySound(commands.Cog):
"""A cog that plays a spooky sound in a voice channel on command."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.sound_files = list(Path("bot/resources/halloween/spookysounds").glob("*.mp3"))
self.channel = None
@commands.cooldown(rate=1, per=1)
@commands.command(brief="Play a spooky sound, restricted to once per 2 mins")
- async def spookysound(self, ctx):
+ async def spookysound(self, ctx: commands.Context) -> None:
"""
Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect.
@@ -37,12 +37,12 @@ class SpookySound(commands.Cog):
voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice)))
@staticmethod
- async def disconnect(voice):
+ async def disconnect(voice: discord.VoiceClient) -> None:
"""Helper method to disconnect a given voice client."""
await voice.disconnect()
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Spooky sound Cog load."""
bot.add_cog(SpookySound(bot))
log.info("SpookySound cog loaded")
diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py
index a2b16a6c..77767baa 100644
--- a/bot/seasons/halloween/timeleft.py
+++ b/bot/seasons/halloween/timeleft.py
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
+from typing import Tuple
from discord.ext import commands
@@ -9,16 +10,16 @@ log = logging.getLogger(__name__)
class TimeLeft(commands.Cog):
"""A Cog that tells you how long left until Hacktober is over!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def in_october():
+ def in_october() -> bool:
"""Return True if the current month is October."""
return datetime.utcnow().month == 10
@staticmethod
- def load_date():
+ def load_date() -> Tuple[int, datetime, datetime]:
"""Return of a tuple of the current time and the end and start times of the next October."""
now = datetime.utcnow()
year = now.year
@@ -29,7 +30,7 @@ class TimeLeft(commands.Cog):
return now, end, start
@commands.command()
- async def timeleft(self, ctx):
+ async def timeleft(self, ctx: commands.Context) -> None:
"""
Calculates the time left until the end of Hacktober.
@@ -53,7 +54,7 @@ class TimeLeft(commands.Cog):
)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(TimeLeft(bot))
log.info("TimeLeft cog loaded")
diff --git a/bot/seasons/pride/pride_anthem.py b/bot/seasons/pride/pride_anthem.py
index f226f4bb..b0c6d34e 100644
--- a/bot/seasons/pride/pride_anthem.py
+++ b/bot/seasons/pride/pride_anthem.py
@@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
class PrideAnthem(commands.Cog):
"""Embed a random youtube video for a gay anthem!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.anthems = self.load_vids()
@@ -39,7 +39,7 @@ class PrideAnthem(commands.Cog):
return anthems
@commands.command(name="prideanthem", aliases=["anthem", "pridesong"])
- async def prideanthem(self, ctx, genre: str = None):
+ async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None:
"""
Sends a message with a video of a random pride anthem.
@@ -52,7 +52,7 @@ class PrideAnthem(commands.Cog):
await ctx.send("I couldn't find a video, sorry!")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Cog loader for pride anthem."""
bot.add_cog(PrideAnthem(bot))
log.info("Pride anthems cog loaded!")
diff --git a/bot/seasons/pride/pride_avatar.py b/bot/seasons/pride/pride_avatar.py
index a5b38d20..85e49d5c 100644
--- a/bot/seasons/pride/pride_avatar.py
+++ b/bot/seasons/pride/pride_avatar.py
@@ -56,11 +56,11 @@ OPTIONS = {
class PrideAvatar(commands.Cog):
"""Put an LGBT spin on your avatar!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@staticmethod
- def crop_avatar(avatar):
+ def crop_avatar(avatar: Image) -> Image:
"""This crops the avatar into a circle."""
mask = Image.new("L", avatar.size, 0)
draw = ImageDraw.Draw(mask)
@@ -69,7 +69,7 @@ class PrideAvatar(commands.Cog):
return avatar
@staticmethod
- def crop_ring(ring, px):
+ def crop_ring(ring: Image, px: int) -> Image:
"""This crops the ring into a circle."""
mask = Image.new("L", ring.size, 0)
draw = ImageDraw.Draw(mask)
@@ -79,7 +79,7 @@ class PrideAvatar(commands.Cog):
return ring
@commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True)
- async def prideavatar(self, ctx, option="lgbt", pixels: int = 64):
+ async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None:
"""
This surrounds an avatar with a border of a specified LGBT flag.
@@ -126,7 +126,7 @@ class PrideAvatar(commands.Cog):
await ctx.send(file=file, embed=embed)
@prideavatar.command()
- async def flags(self, ctx):
+ async def flags(self, ctx: commands.Context) -> None:
"""This lists the flags that can be used with the prideavatar command."""
choices = sorted(set(OPTIONS.values()))
options = "• " + "\n• ".join(choices)
@@ -139,7 +139,7 @@ class PrideAvatar(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Cog load."""
bot.add_cog(PrideAvatar(bot))
log.info("PrideAvatar cog loaded")
diff --git a/bot/seasons/season.py b/bot/seasons/season.py
index 71324127..8d8179f6 100644
--- a/bot/seasons/season.py
+++ b/bot/seasons/season.py
@@ -12,7 +12,8 @@ import async_timeout
import discord
from discord.ext import commands
-from bot.constants import Channels, Client, Roles, bot
+from bot.bot import bot
+from bot.constants import Channels, Client, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -263,14 +264,14 @@ class SeasonBase:
return await self.apply_server_icon()
- async def announce_season(self):
+ async def announce_season(self) -> None:
"""
Announces a change in season in the announcement channel.
It will skip the announcement if the current active season is the "evergreen" default season.
"""
# Don't actually announce if reverting to normal season
- if self.name == "evergreen":
+ if self.name in ("evergreen", "wildcard"):
log.debug(f"Season Changed: {self.name}")
return
@@ -302,7 +303,7 @@ class SeasonBase:
cogs.append(cog_name)
if cogs:
- def cog_name(cog):
+ def cog_name(cog: commands.Cog) -> str:
return type(cog).__name__
cog_info = []
@@ -319,7 +320,7 @@ class SeasonBase:
await channel.send(mention, embed=embed)
- async def load(self):
+ async def load(self) -> None:
"""
Loads extensions, bot name and avatar, server icon and announces new season.
@@ -360,7 +361,7 @@ class SeasonBase:
class SeasonManager(commands.Cog):
"""A cog for managing seasons."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.season = get_season(date=datetime.datetime.utcnow())
self.season_task = bot.loop.create_task(self.load_seasons())
@@ -377,7 +378,7 @@ class SeasonManager(commands.Cog):
)
self.sleep_time = (midnight - datetime.datetime.now()).seconds + 60
- async def load_seasons(self):
+ async def load_seasons(self) -> None:
"""Asynchronous timer loop to check for a new season every midnight."""
await self.bot.wait_until_ready()
await self.season.load()
@@ -396,7 +397,7 @@ class SeasonManager(commands.Cog):
@with_role(Roles.moderator, Roles.admin, Roles.owner)
@commands.command(name="season")
- async def change_season(self, ctx, new_season: str):
+ async def change_season(self, ctx: commands.Context, new_season: str) -> None:
"""Changes the currently active season on the bot."""
self.season = get_season(season_name=new_season)
await self.season.load()
@@ -404,10 +405,10 @@ class SeasonManager(commands.Cog):
@with_role(Roles.moderator, Roles.admin, Roles.owner)
@commands.command(name="seasons")
- async def show_seasons(self, ctx):
+ async def show_seasons(self, ctx: commands.Context) -> None:
"""Shows the available seasons and their dates."""
# Sort by start order, followed by lower duration
- def season_key(season_class: Type[SeasonBase]):
+ def season_key(season_class: Type[SeasonBase]) -> Tuple[datetime.datetime, datetime.timedelta]:
return season_class.start(), season_class.end() - datetime.datetime.max
current_season = self.season.name
@@ -447,13 +448,13 @@ class SeasonManager(commands.Cog):
@with_role(Roles.moderator, Roles.admin, Roles.owner)
@commands.group()
- async def refresh(self, ctx):
+ async def refresh(self, ctx: commands.Context) -> None:
"""Refreshes certain seasonal elements without reloading seasons."""
if not ctx.invoked_subcommand:
await ctx.send_help(ctx.command)
@refresh.command(name="avatar")
- async def refresh_avatar(self, ctx):
+ async def refresh_avatar(self, ctx: commands.Context) -> None:
"""Re-applies the bot avatar for the currently loaded season."""
# Attempt the change
is_changed = await self.season.apply_avatar()
@@ -476,7 +477,7 @@ class SeasonManager(commands.Cog):
await ctx.send(embed=embed)
@refresh.command(name="icon")
- async def refresh_server_icon(self, ctx):
+ async def refresh_server_icon(self, ctx: commands.Context) -> None:
"""Re-applies the server icon for the currently loaded season."""
# Attempt the change
is_changed = await self.season.apply_server_icon()
@@ -499,7 +500,7 @@ class SeasonManager(commands.Cog):
await ctx.send(embed=embed)
@refresh.command(name="username", aliases=("name",))
- async def refresh_username(self, ctx):
+ async def refresh_username(self, ctx: commands.Context) -> None:
"""Re-applies the bot username for the currently loaded season."""
old_username = str(bot.user)
old_display_name = ctx.guild.me.display_name
@@ -538,10 +539,10 @@ class SeasonManager(commands.Cog):
@with_role(Roles.moderator, Roles.admin, Roles.owner)
@commands.command()
- async def announce(self, ctx):
+ async def announce(self, ctx: commands.Context) -> None:
"""Announces the currently loaded season."""
await self.season.announce_season()
- def cog_unload(self):
+ def cog_unload(self) -> None:
"""Cancel season-related tasks on cog unload."""
self.season_task.cancel()
diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py
index ac140896..a073e1bd 100644
--- a/bot/seasons/valentines/be_my_valentine.py
+++ b/bot/seasons/valentines/be_my_valentine.py
@@ -1,8 +1,8 @@
import logging
import random
-import typing
from json import load
from pathlib import Path
+from typing import Optional, Tuple
import discord
from discord.ext import commands
@@ -18,12 +18,12 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea
class BeMyValentine(commands.Cog):
"""A cog that sends Valentines to other users!"""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.valentines = self.load_json()
@staticmethod
- def load_json():
+ def load_json() -> dict:
"""Load Valentines messages from the static resources."""
p = Path("bot/resources/valentines/bemyvalentine_valentines.json")
with p.open() as json_data:
@@ -31,7 +31,7 @@ class BeMyValentine(commands.Cog):
return valentines
@commands.group(name="lovefest", invoke_without_command=True)
- async def lovefest_role(self, ctx):
+ async def lovefest_role(self, ctx: commands.Context) -> None:
"""
Subscribe or unsubscribe from the lovefest role.
@@ -43,7 +43,7 @@ class BeMyValentine(commands.Cog):
await ctx.send_help(ctx.command)
@lovefest_role.command(name="sub")
- async def add_role(self, ctx):
+ async def add_role(self, ctx: commands.Context) -> None:
"""Adds the lovefest role."""
user = ctx.author
role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id)
@@ -54,7 +54,7 @@ class BeMyValentine(commands.Cog):
await ctx.send("You already have the role !")
@lovefest_role.command(name="unsub")
- async def remove_role(self, ctx):
+ async def remove_role(self, ctx: commands.Context) -> None:
"""Removes the lovefest role."""
user = ctx.author
role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id)
@@ -66,7 +66,9 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@commands.group(name='bemyvalentine', invoke_without_command=True)
- async def send_valentine(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None):
+ async def send_valentine(
+ self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ ) -> None:
"""
Send a valentine to user, if specified, or to a random user with the lovefest role.
@@ -112,7 +114,9 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@send_valentine.command(name='secret')
- async def anonymous(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None):
+ async def anonymous(
+ self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ ) -> None:
"""
Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role.
@@ -164,7 +168,7 @@ class BeMyValentine(commands.Cog):
else:
await ctx.author.send(f"Your message has been sent to {user}")
- def valentine_check(self, valentine_type):
+ def valentine_check(self, valentine_type: str) -> Tuple[str, str]:
"""Return the appropriate Valentine type & title based on the invoking user's input."""
if valentine_type is None:
valentine, title = self.random_valentine()
@@ -184,14 +188,11 @@ class BeMyValentine(commands.Cog):
return valentine, title
@staticmethod
- def random_user(author, members):
+ def random_user(author: discord.Member, members: discord.Member) -> None:
"""
Picks a random member from the list provided in `members`.
The invoking author is ignored.
-
- :param author: member who invoked the command
- :param members: list of discord.Member objects
"""
if author in members:
members.remove(author)
@@ -199,13 +200,13 @@ class BeMyValentine(commands.Cog):
return random.choice(members) if members else None
@staticmethod
- def random_emoji():
+ def random_emoji() -> Tuple[str, str]:
"""Return two random emoji from the module-defined constants."""
EMOJI_1 = random.choice(HEART_EMOJIS)
EMOJI_2 = random.choice(HEART_EMOJIS)
return EMOJI_1, EMOJI_2
- def random_valentine(self):
+ def random_valentine(self) -> Tuple[str, str]:
"""Grabs a random poem or a compliment (any message)."""
valentine_poem = random.choice(self.valentines['valentine_poems'])
valentine_compliment = random.choice(self.valentines['valentine_compliments'])
@@ -216,18 +217,18 @@ class BeMyValentine(commands.Cog):
title = 'A compliment for '
return random_valentine, title
- def valentine_poem(self):
+ def valentine_poem(self) -> str:
"""Grabs a random poem."""
valentine_poem = random.choice(self.valentines['valentine_poems'])
return valentine_poem
- def valentine_compliment(self):
+ def valentine_compliment(self) -> str:
"""Grabs a random compliment."""
valentine_compliment = random.choice(self.valentines['valentine_compliments'])
return valentine_compliment
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Be my Valentine Cog load."""
bot.add_cog(BeMyValentine(bot))
log.info("BeMyValentine cog loaded")
diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py
index 1d5a028d..03d3d7d5 100644
--- a/bot/seasons/valentines/lovecalculator.py
+++ b/bot/seasons/valentines/lovecalculator.py
@@ -23,12 +23,12 @@ with Path("bot/resources/valentines/love_matches.json").open() as file:
class LoveCalculator(Cog):
"""A cog for calculating the love between two people."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(aliases=('love_calculator', 'love_calc'))
@commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
- async def love(self, ctx, who: Union[Member, str], whom: Union[Member, str] = None):
+ async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None:
"""
Tells you how much the two love each other.
@@ -53,7 +53,7 @@ class LoveCalculator(Cog):
staff = ctx.guild.get_role(Roles.helpers).members
whom = random.choice(staff)
- def normalize(arg):
+ def normalize(arg: Union[Member, str]) -> str:
if isinstance(arg, Member):
# If we are given a member, return name#discrim without any extra changes
arg = str(arg)
@@ -98,7 +98,7 @@ class LoveCalculator(Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Love calculator Cog load."""
bot.add_cog(LoveCalculator(bot))
log.info("LoveCalculator cog loaded")
diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py
index fa5f236a..ce1d7d5b 100644
--- a/bot/seasons/valentines/movie_generator.py
+++ b/bot/seasons/valentines/movie_generator.py
@@ -14,11 +14,11 @@ log = logging.getLogger(__name__)
class RomanceMovieFinder(commands.Cog):
"""A Cog that returns a random romance movie suggestion to a user."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(name="romancemovie")
- async def romance_movie(self, ctx):
+ async def romance_movie(self, ctx: commands.Context) -> None:
"""Randomly selects a romance movie and displays information about it."""
# Selecting a random int to parse it to the page parameter
random_page = random.randint(0, 20)
@@ -57,7 +57,7 @@ class RomanceMovieFinder(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Romance movie Cog load."""
bot.add_cog(RomanceMovieFinder(bot))
log.info("RomanceMovieFinder cog loaded")
diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py
index fad202e3..0256c39a 100644
--- a/bot/seasons/valentines/myvalenstate.py
+++ b/bot/seasons/valentines/myvalenstate.py
@@ -18,10 +18,10 @@ with open(Path("bot/resources/valentines/valenstates.json"), "r") as file:
class MyValenstate(commands.Cog):
"""A Cog to find your most likely Valentine's vacation destination."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
- def levenshtein(self, source, goal):
+ def levenshtein(self, source: str, goal: str) -> int:
"""Calculates the Levenshtein Distance between source and goal."""
if len(source) < len(goal):
return self.levenshtein(goal, source)
@@ -42,7 +42,7 @@ class MyValenstate(commands.Cog):
return pre_row[-1]
@commands.command()
- async def myvalenstate(self, ctx, *, name=None):
+ async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None:
"""Find the vacation spot(s) with the most matching characters to the invoking user."""
eq_chars = collections.defaultdict(int)
if name is None:
@@ -81,7 +81,7 @@ class MyValenstate(commands.Cog):
await ctx.channel.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Valenstate Cog load."""
bot.add_cog(MyValenstate(bot))
log.info("MyValenstate cog loaded")
diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py
index 46772197..8b2c9822 100644
--- a/bot/seasons/valentines/pickuplines.py
+++ b/bot/seasons/valentines/pickuplines.py
@@ -17,11 +17,11 @@ with open(Path("bot/resources/valentines/pickup_lines.json"), "r", encoding="utf
class PickupLine(commands.Cog):
"""A cog that gives random cheesy pickup lines."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command()
- async def pickupline(self, ctx):
+ async def pickupline(self, ctx: commands.Context) -> None:
"""
Gives you a random pickup line.
@@ -39,7 +39,7 @@ class PickupLine(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Pickup lines Cog load."""
bot.add_cog(PickupLine(bot))
log.info('PickupLine cog loaded')
diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py
index 34264183..e0bc3904 100644
--- a/bot/seasons/valentines/savethedate.py
+++ b/bot/seasons/valentines/savethedate.py
@@ -19,11 +19,11 @@ with open(Path("bot/resources/valentines/date_ideas.json"), "r", encoding="utf8"
class SaveTheDate(commands.Cog):
"""A cog that gives random suggestion for a Valentine's date."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command()
- async def savethedate(self, ctx):
+ async def savethedate(self, ctx: commands.Context) -> None:
"""Gives you ideas for what to do on a date with your valentine."""
random_date = random.choice(VALENTINES_DATES['ideas'])
emoji_1 = random.choice(HEART_EMOJIS)
@@ -36,7 +36,7 @@ class SaveTheDate(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Save the date Cog Load."""
bot.add_cog(SaveTheDate(bot))
log.info("SaveTheDate cog loaded")
diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py
index fa849cb2..c8d77e75 100644
--- a/bot/seasons/valentines/valentine_zodiac.py
+++ b/bot/seasons/valentines/valentine_zodiac.py
@@ -17,12 +17,12 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea
class ValentineZodiac(commands.Cog):
"""A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
self.zodiacs = self.load_json()
@staticmethod
- def load_json():
+ def load_json() -> dict:
"""Load zodiac compatibility from static JSON resource."""
p = Path("bot/resources/valentines/zodiac_compatibility.json")
with p.open() as json_data:
@@ -30,7 +30,7 @@ class ValentineZodiac(commands.Cog):
return zodiacs
@commands.command(name="partnerzodiac")
- async def counter_zodiac(self, ctx, zodiac_sign):
+ async def counter_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:
"""Provides a counter compatible zodiac sign to the given user's zodiac sign."""
try:
compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()])
@@ -52,7 +52,7 @@ class ValentineZodiac(commands.Cog):
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Valentine zodiac Cog load."""
bot.add_cog(ValentineZodiac(bot))
log.info("ValentineZodiac cog loaded")
diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py
index d73ccd9b..b8586dca 100644
--- a/bot/seasons/valentines/whoisvalentine.py
+++ b/bot/seasons/valentines/whoisvalentine.py
@@ -17,11 +17,11 @@ with open(Path("bot/resources/valentines/valentine_facts.json"), "r") as file:
class ValentineFacts(commands.Cog):
"""A Cog for displaying facts about Saint Valentine."""
- def __init__(self, bot):
+ def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command(aliases=('whoisvalentine', 'saint_valentine'))
- async def who_is_valentine(self, ctx):
+ async def who_is_valentine(self, ctx: commands.Context) -> None:
"""Displays info about Saint Valentine."""
embed = discord.Embed(
title="Who is Saint Valentine?",
@@ -36,7 +36,7 @@ class ValentineFacts(commands.Cog):
await ctx.channel.send(embed=embed)
@commands.command()
- async def valentine_fact(self, ctx):
+ async def valentine_fact(self, ctx: commands.Context) -> None:
"""Shows a random fact about Valentine's Day."""
embed = discord.Embed(
title=choice(FACTS['titles']),
@@ -47,7 +47,7 @@ class ValentineFacts(commands.Cog):
await ctx.channel.send(embed=embed)
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
"""Who is Valentine Cog load."""
bot.add_cog(ValentineFacts(bot))
log.info("ValentineFacts cog loaded")
diff --git a/bot/seasons/wildcard/__init__.py b/bot/seasons/wildcard/__init__.py
new file mode 100644
index 00000000..354e979d
--- /dev/null
+++ b/bot/seasons/wildcard/__init__.py
@@ -0,0 +1,31 @@
+from bot.seasons import SeasonBase
+
+
+class Wildcard(SeasonBase):
+ """
+ For the month of August, the season is a Wildcard.
+
+ This docstring will not be used for announcements.
+ Instead, we'll do the announcement manually, since
+ it will change every year.
+
+ This class needs slight changes every year,
+ such as the bot_name, bot_icon and icon.
+
+ IMPORTANT: DO NOT ADD ANY FEATURES TO THIS FOLDER.
+ ALL WILDCARD FEATURES SHOULD BE ADDED
+ TO THE EVERGREEN FOLDER!
+ """
+
+ name = "wildcard"
+ bot_name = "RetroBot"
+
+ # Duration of season
+ start_date = "01/08"
+ end_date = "01/09"
+
+ # Season logo
+ bot_icon = "/logos/logo_seasonal/retro_gaming/logo_8bit_indexed_504.png"
+ icon = (
+ "/logos/logo_seasonal/retro_gaming_animated/logo_spin_plain/logo_spin_plain_504.gif",
+ )
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index ef18a1b9..0aa50af6 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,4 +1,6 @@
import asyncio
+import re
+import string
from typing import List
import discord
@@ -9,21 +11,15 @@ 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
-):
+ entries_per_page: int = 20, empty: bool = False, embed: discord.Embed = None
+) -> str:
"""
Has the user choose between multiple entries in case one could not be chosen automatically.
+ Disambiguation will be canceled after `timeout` seconds.
+
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.')
@@ -33,7 +29,7 @@ async def disambiguate(
choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1))
- def check(message):
+ def check(message: discord.Message) -> bool:
return (message.content.isdigit()
and message.author == ctx.author
and message.channel == ctx.channel)
@@ -43,7 +39,7 @@ async def disambiguate(
embed = discord.Embed()
coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout)
- coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page,
+ coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=entries_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
@@ -77,3 +73,57 @@ async def disambiguate(
return entries[index - 1]
except IndexError:
raise BadArgument('Invalid choice.')
+
+
+def replace_many(
+ sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False
+) -> str:
+ """
+ Replaces multiple substrings in a string given a mapping of strings.
+
+ By default replaces long strings before short strings, and lowercase before uppercase.
+ Example:
+ var = replace_many("This is a sentence", {"is": "was", "This": "That"})
+ assert var == "That was a sentence"
+
+ If `ignore_case` is given, does a case insensitive match.
+ Example:
+ var = replace_many("THIS is a sentence", {"IS": "was", "tHiS": "That"}, ignore_case=True)
+ assert var == "That was a sentence"
+
+ If `match_case` is given, matches the case of the replacement with the replaced word.
+ Example:
+ var = replace_many(
+ "This IS a sentence", {"is": "was", "this": "that"}, ignore_case=True, match_case=True
+ )
+ assert var == "That WAS a sentence"
+ """
+ if ignore_case:
+ replacements = dict(
+ (word.lower(), replacement) for word, replacement in replacements.items()
+ )
+
+ words_to_replace = sorted(replacements, key=lambda s: (-len(s), s))
+
+ # Join and compile words to replace into a regex
+ pattern = "|".join(re.escape(word) for word in words_to_replace)
+ regex = re.compile(pattern, re.I if ignore_case else 0)
+
+ def _repl(match: re.Match) -> str:
+ """Returns replacement depending on `ignore_case` and `match_case`."""
+ word = match.group(0)
+ replacement = replacements[word.lower() if ignore_case else word]
+
+ if not match_case:
+ return replacement
+
+ # Clean punctuation from word so string methods work
+ cleaned_word = word.translate(str.maketrans('', '', string.punctuation))
+ if cleaned_word.isupper():
+ return replacement.upper()
+ elif cleaned_word[0].isupper():
+ return replacement.capitalize()
+ else:
+ return replacement.lower()
+
+ return regex.sub(_repl, sentence)
diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py
index 69b49919..11f69850 100644
--- a/bot/utils/halloween/spookifications.py
+++ b/bot/utils/halloween/spookifications.py
@@ -7,7 +7,7 @@ from PIL import ImageOps
log = logging.getLogger()
-def inversion(im):
+def inversion(im: Image) -> Image:
"""
Inverts the image.
@@ -18,7 +18,7 @@ def inversion(im):
return inv
-def pentagram(im):
+def pentagram(im: Image) -> Image:
"""Adds pentagram to the image."""
im = im.convert('RGB')
wt, ht = im.size
@@ -28,7 +28,7 @@ def pentagram(im):
return im
-def bat(im):
+def bat(im: Image) -> Image:
"""
Adds a bat silhoutte to the image.
@@ -50,7 +50,7 @@ def bat(im):
return im
-def get_random_effect(im):
+def get_random_effect(im: Image) -> Image:
"""Randomly selects and applies an effect."""
effects = [inversion, pentagram, bat]
effect = choice(effects)
diff --git a/bot/utils/persist.py b/bot/utils/persist.py
new file mode 100644
index 00000000..a60a1219
--- /dev/null
+++ b/bot/utils/persist.py
@@ -0,0 +1,66 @@
+import sqlite3
+from pathlib import Path
+from shutil import copyfile
+
+from bot.seasons.season import get_seasons
+
+DIRECTORY = Path("data") # directory that has a persistent volume mapped to it
+
+
+def make_persistent(file_path: Path) -> Path:
+ """
+ Copy datafile at the provided file_path to the persistent data directory.
+
+ A persistent data file is needed by some features in order to not lose data
+ after bot rebuilds.
+
+ This function will ensure that a clean data file with default schema,
+ structure or data is copied over to the persistent volume before returning
+ the path to this new persistent version of the file.
+
+ If the persistent file already exists, it won't be overwritten with the
+ clean default file, just returning the Path instead to the existing file.
+
+ Note: Avoid using the same file name as other features in the same seasons
+ as otherwise only one datafile can be persistent and will be returned for
+ both cases.
+
+ Example Usage:
+ >>> import json
+ >>> template_datafile = Path("bot", "resources", "evergreen", "myfile.json")
+ >>> path_to_persistent_file = make_persistent(template_datafile)
+ >>> print(path_to_persistent_file)
+ data/evergreen/myfile.json
+ >>> with path_to_persistent_file.open("w+") as f:
+ >>> data = json.load(f)
+ """
+ # ensure the persistent data directory exists
+ DIRECTORY.mkdir(exist_ok=True)
+
+ if not file_path.is_file():
+ raise OSError(f"File not found at {file_path}.")
+
+ # detect season in datafile path for assigning to subdirectory
+ season = next((s for s in get_seasons() if s in file_path.parts), None)
+
+ if season:
+ # make sure subdirectory exists first
+ subdirectory = Path(DIRECTORY, season)
+ subdirectory.mkdir(exist_ok=True)
+
+ persistent_path = Path(subdirectory, file_path.name)
+
+ else:
+ persistent_path = Path(DIRECTORY, file_path.name)
+
+ # copy base/template datafile to persistent directory
+ if not persistent_path.exists():
+ copyfile(file_path, persistent_path)
+
+ return persistent_path
+
+
+def sqlite(db_path: Path) -> sqlite3.Connection:
+ """Copy sqlite file to the persistent data directory and return an open connection."""
+ persistent_path = make_persistent(db_path)
+ return sqlite3.connect(persistent_path)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..f2f4b056
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,24 @@
+version: "3.7"
+services:
+ seasonalbot:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: seasonalbot
+ init: true
+
+ restart: always
+
+ environment:
+ - SEASONALBOT_TOKEN=yourtokenhere
+ - SEASONALBOT_DEBUG=true
+ # - SEASONALBOT_GUILD=
+ # - SEASONALBOT_ADMIN_ROLE_ID=
+ # - CHANNEL_ANNOUNCEMENTS=
+ # - CHANNEL_DEVLOG=
+ # - CHANNEL_SEASONALBOT_CHAT=
+ # - SEASON_OVERRIDE=
+
+ volumes:
+ - /opt/pythondiscord/seasonalbot/log:/bot/bot/log
+ - /opt/pythondiscord/seasonalbot/data:/bot/data
diff --git a/docker/Dockerfile b/docker/Dockerfile
deleted file mode 100644
index 67767ced..00000000
--- a/docker/Dockerfile
+++ /dev/null
@@ -1,26 +0,0 @@
-FROM python:3.7.2-alpine3.9
-
-ENTRYPOINT ["python"]
-CMD ["-m", "bot"]
-
-ENV PIP_NO_CACHE_DIR="false" \
- PIPENV_DONT_USE_PYENV="1" \
- PIPENV_HIDE_EMOJIS="1" \
- PIPENV_IGNORE_VIRTUALENVS="1" \
- PIPENV_NOSPIN="1"
-
-RUN apk add --no-cache --update \
- build-base \
- git \
- libffi-dev \
- libwebp-dev \
- # Pillow dependencies
- freetype-dev \
- libjpeg-turbo-dev \
- zlib-dev
-RUN pip install pipenv
-
-COPY . /bot
-WORKDIR /bot
-
-RUN pipenv install --deploy --system
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
deleted file mode 100644
index 6e274451..00000000
--- a/docker/docker-compose.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-version: "3.7"
-services:
- dumbo:
- image: pythondiscord/seasonalbot:latest
- container_name: seasonalbot
- init: true
-
- restart: always
-
- environment:
- - SEASONALBOT_TOKEN
-
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
deleted file mode 100755
index ad7bfb26..00000000
--- a/scripts/deploy-azure.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/bash
-
-cd ..
-
-# Build and deploy on master branch, only if not a pull request
-if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
- echo "Building image"
- docker build -t pythondiscord/seasonalbot:latest -f docker/Dockerfile .
-
- echo "Pushing image to Docker Hub"
- docker push pythondiscord/seasonalbot:latest
-else
- echo "Skipping deploy"
-fi
diff --git a/tox.ini b/tox.ini
index 3e5db0a5..ee898b0d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,16 +1,19 @@
[flake8]
max-line-length=120
application_import_names=bot
+docstring-convention=all
ignore=
P102,B311,W503,E226,S311,
# Missing Docstrings
- D100,D104,D107,
+ D100,D104,D105,D107,
# Docstring Whitespace
D203,D212,D214,D215,
# Docstring Quotes
D301,D302,
# Docstring Content
- D400,D401,D402,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414
+ D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417
+ # Type Annotations
+ TYP002,TYP003,TYP101,TYP102,TYP204,TYP206
exclude=
__pycache__,.cache,
venv,.venv,