aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Mark <[email protected]>2019-12-20 08:50:31 -0800
committerGravatar GitHub <[email protected]>2019-12-20 08:50:31 -0800
commite3529c7bbc7590fed089b8197b4e98630ee10253 (patch)
tree7333c48ed164e5819a5e610cb77aeafc7a70bc5a
parentUpdate bot/seasons/evergreen/trivia_quiz.py (diff)
parentMerge pull request #332 from python-discord/errorhandler-refine (diff)
Merge branch 'master' into quiz_fix
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock130
-rw-r--r--bot/constants.py15
-rw-r--r--bot/resources/advent_of_code/about.json2
-rw-r--r--bot/resources/halloween/monster.json41
-rw-r--r--bot/seasons/christmas/__init__.py9
-rw-r--r--bot/seasons/christmas/adventofcode.py67
-rw-r--r--bot/seasons/easter/easter_riddle.py2
-rw-r--r--bot/seasons/easter/egg_decorating.py2
-rw-r--r--bot/seasons/evergreen/__init__.py1
-rw-r--r--bot/seasons/evergreen/bookmark.py55
-rw-r--r--bot/seasons/evergreen/error_handler.py225
-rw-r--r--bot/seasons/halloween/hacktoberstats.py2
-rw-r--r--bot/seasons/halloween/monsterbio.py56
-rw-r--r--bot/seasons/season.py9
-rw-r--r--bot/utils/__init__.py23
16 files changed, 430 insertions, 211 deletions
diff --git a/Pipfile b/Pipfile
index 64c47b2a..c066958e 100644
--- a/Pipfile
+++ b/Pipfile
@@ -9,7 +9,7 @@ arrow = "~=0.14"
beautifulsoup4 = "~=4.8"
discord-py = "~=1.2"
fuzzywuzzy = "~=0.17"
-pillow = "~=6.1"
+pillow = "~=6.2"
pytz = "~=2019.2"
[dev-packages]
diff --git a/Pipfile.lock b/Pipfile.lock
index 05182e99..3252f36f 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "da19ab2567a55706054eae245eb95a2b6f861836a47ef40641b0c6976b509c65"
+ "sha256": "40f23ea08504def8d3d5f56379820221088d93e9bf81d739850dc97ea8a4b7dc"
},
"pipfile-spec": 6,
"requires": {
@@ -151,37 +151,25 @@
},
"multidict": {
"hashes": [
- "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
- "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3",
- "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef",
- "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b",
- "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73",
- "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc",
- "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3",
- "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd",
- "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351",
- "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941",
- "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d",
- "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1",
- "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b",
- "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a",
- "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3",
- "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7",
- "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0",
- "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0",
- "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014",
- "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5",
- "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036",
- "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d",
- "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a",
- "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce",
- "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1",
- "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a",
- "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9",
- "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7",
- "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b"
- ],
- "version": "==4.5.2"
+ "sha256:07f9a6bf75ad675d53956b2c6a2d4ef2fa63132f33ecc99e9c24cf93beb0d10b",
+ "sha256:0ffe4d4d28cbe9801952bfb52a8095dd9ffecebd93f84bdf973c76300de783c5",
+ "sha256:1b605272c558e4c659dbaf0fb32a53bfede44121bcf77b356e6e906867b958b7",
+ "sha256:205a011e636d885af6dd0029e41e3514a46e05bb2a43251a619a6e8348b96fc0",
+ "sha256:250632316295f2311e1ed43e6b26a63b0216b866b45c11441886ac1543ca96e1",
+ "sha256:2bc9c2579312c68a3552ee816311c8da76412e6f6a9cf33b15152e385a572d2a",
+ "sha256:318aadf1cfb6741c555c7dd83d94f746dc95989f4f106b25b8a83dfb547f2756",
+ "sha256:42cdd649741a14b0602bf15985cad0dd4696a380081a3319cd1ead46fd0f0fab",
+ "sha256:5159c4975931a1a78bf6602bbebaa366747fce0a56cb2111f44789d2c45e379f",
+ "sha256:87e26d8b89127c25659e962c61a4c655ec7445d19150daea0759516884ecb8b4",
+ "sha256:891b7e142885e17a894d9d22b0349b92bb2da4769b4e675665d0331c08719be5",
+ "sha256:8d919034420378132d074bf89df148d0193e9780c9fe7c0e495e895b8af4d8a2",
+ "sha256:9c890978e2b37dd0dc1bd952da9a5d9f245d4807bee33e3517e4119c48d66f8c",
+ "sha256:a37433ce8cdb35fc9e6e47e1606fa1bfd6d70440879038dca7d8dd023197eaa9",
+ "sha256:c626029841ada34c030b94a00c573a0c7575fe66489cde148785b6535397d675",
+ "sha256:cfec9d001a83dc73580143f3c77e898cf7ad78b27bb5e64dbe9652668fcafec7",
+ "sha256:efaf1b18ea6c1f577b1371c0159edbe4749558bfe983e13aa24d0a0c01e1ad7b"
+ ],
+ "version": "==4.6.1"
},
"pillow": {
"hashes": [
@@ -300,19 +288,25 @@
},
"yarl": {
"hashes": [
- "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
- "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
- "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
- "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
- "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
- "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
- "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
- "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
- "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
- "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
- "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
- ],
- "version": "==1.3.0"
+ "sha256:031e8f56cf085d3b3df6b6bce756369ea7052b82d35ea07b6045f209c819e0e5",
+ "sha256:074958fe4578ef3a3d0bdaf96bbc25e4c4db82b7ff523594776fcf3d3f16c531",
+ "sha256:2db667ee21f620b446a54a793e467714fc5a446fcc82d93a47e8bde01d69afab",
+ "sha256:326f2dbaaa17b858ae86f261ae73a266fd820a561fc5142cee9d0fc58448fbd7",
+ "sha256:32a3885f542f74d0f4f87057050c6b45529ebd79d0639f56582e741521575bfe",
+ "sha256:56126ef061b913c3eefecace3404ca88917265d0550b8e32bbbeab29e5c830bf",
+ "sha256:589ac1e82add13fbdedc04eb0a83400db728e5f1af2bd273392088ca90de7062",
+ "sha256:6076bce2ecc6ebf6c92919d77762f80f4c9c6ecc9c1fbaa16567ec59ad7d6f1d",
+ "sha256:63be649c535d18ab6230efbc06a07f7779cd4336a687672defe70c025349a47b",
+ "sha256:6642cbc92eaffa586180f669adc772f5c34977e9e849e93f33dc142351e98c9c",
+ "sha256:6fa05a25f2280e78a514041d4609d39962e7d51525f2439db9ad7a2ae7aac163",
+ "sha256:7ed006a220422c33ff0889288be24db56ff0a3008ffe9eaead58a690715ad09b",
+ "sha256:80c9c213803b50899460cc355f47e66778c3c868f448b7b7de5b1f1858c82c2a",
+ "sha256:8bae18e2129850e76969b57869dacc72a66cccdbeebce1a28d7f3d439c21a7a3",
+ "sha256:ab112fba996a8f48f427e26969f2066d50080df0c24007a8cc6d7ae865e19013",
+ "sha256:b1c178ef813940c9a5cbad42ab7b8b76ac08b594b0a6bad91063c968e0466efc",
+ "sha256:d6eff151c3b23a56a5e4f496805619bc3bdf4f749f63a7a95ad50e8267c17475"
+ ],
+ "version": "==1.4.1"
}
},
"develop": {
@@ -416,11 +410,11 @@
},
"importlib-metadata": {
"hashes": [
- "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
- "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
+ "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21",
+ "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742"
],
"markers": "python_version < '3.8'",
- "version": "==0.23"
+ "version": "==1.1.0"
},
"mccabe": {
"hashes": [
@@ -431,10 +425,10 @@
},
"more-itertools": {
"hashes": [
- "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
- "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2",
+ "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45"
],
- "version": "==7.2.0"
+ "version": "==8.0.0"
},
"nodeenv": {
"hashes": [
@@ -473,21 +467,19 @@
},
"pyyaml": {
"hashes": [
- "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"
+ "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
+ "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
+ "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
+ "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
+ "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
+ "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
+ "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
+ "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
+ "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
+ "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
+ "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
+ ],
+ "version": "==5.2"
},
"six": {
"hashes": [
@@ -537,10 +529,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589",
- "sha256:d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136"
+ "sha256:116655188441670978117d0ebb6451eb6a7526f9ae0796cc0dee6bd7356909b0",
+ "sha256:b57776b44f91511866594e477dd10e76a6eb44439cdd7f06dcd30ba4c5bd854f"
],
- "version": "==16.7.7"
+ "version": "==16.7.8"
},
"zipp": {
"hashes": [
diff --git a/bot/constants.py b/bot/constants.py
index aa5c3db3..eca4f67b 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -1,8 +1,10 @@
import logging
from os import environ
from typing import NamedTuple
+from datetime import datetime
__all__ = (
+ "bookmark_icon_url",
"AdventOfCode", "Channels", "Client", "Colours", "Emojis", "Hacktoberfest", "Roles", "Tokens",
"WHITELISTED_CHANNELS", "STAFF_ROLES", "MODERATION_ROLES",
"POSITIVE_REPLIES", "NEGATIVE_REPLIES", "ERROR_REPLIES",
@@ -10,19 +12,24 @@ __all__ = (
log = logging.getLogger(__name__)
+bookmark_icon_url = (
+ "https://images-ext-2.discordapp.net/external/zl4oDwcmxUILY7sD9ZWE2fU5R7n6QcxEmPYSE5eddbg/"
+ "%3Fv%3D1/https/cdn.discordapp.com/emojis/654080405988966419.png?width=20&height=20"
+)
+
class AdventOfCode:
leaderboard_cache_age_threshold_seconds = 3600
- leaderboard_id = 363275
+ leaderboard_id = 631135
leaderboard_join_code = str(environ.get("AOC_JOIN_CODE", None))
leaderboard_max_displayed_members = 10
- year = 2018
+ year = int(environ.get("AOC_YEAR", datetime.utcnow().year))
role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082))
class Channels(NamedTuple):
admins = 365960823622991872
- advent_of_code = 517745814039166986
+ advent_of_code = int(environ.get("AOC_CHANNEL_ID", 517745814039166986))
announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496))
big_brother_logs = 468507907357409333
bot = 267659945086812160
@@ -73,12 +80,14 @@ class Colours:
soft_green = 0x68c290
soft_red = 0xcd6d6d
yellow = 0xf9f586
+ purple = 0xb734eb
class Emojis:
star = "\u2B50"
christmas_tree = "\U0001F384"
check = "\u2611"
+ envelope = "\U0001F4E8"
terning1 = "<:terning1:431249668983488527>"
terning2 = "<:terning2:462339216987127808>"
diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json
index 4abf9145..b1d16a93 100644
--- a/bot/resources/advent_of_code/about.json
+++ b/bot/resources/advent_of_code/about.json
@@ -16,7 +16,7 @@
},
{
"name": "How does scoring work?",
- "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/2018/leaderboard) to see who's leading this year's event!",
+ "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/leaderboard) to see who's leading this year's event!",
"inline": false
},
{
diff --git a/bot/resources/halloween/monster.json b/bot/resources/halloween/monster.json
new file mode 100644
index 00000000..5958dc9c
--- /dev/null
+++ b/bot/resources/halloween/monster.json
@@ -0,0 +1,41 @@
+{
+ "monster_type": [
+ ["El", "Go", "Ma", "Nya", "Wo", "Hom", "Shar", "Gronn", "Grom", "Blar"],
+ ["gaf", "mot", "phi", "zyme", "qur", "tile", "pim"],
+ ["yam", "ja", "rok", "pym", "el"],
+ ["ya", "tor", "tir", "tyre", "pam"]
+ ],
+ "scientist_first_name": ["Ellis", "Elliot", "Rick", "Laurent", "Morgan", "Sophia", "Oak"],
+ "scientist_last_name": ["E. M.", "E. T.", "Smith", "Schimm", "Schiftner", "Smile", "Tomson", "Thompson", "Huffson", "Argor", "Lephtain", "S. M.", "A. R.", "P. G."],
+ "verb": [
+ "discovered", "created", "found"
+ ],
+ "adjective": [
+ "ferocious", "spectacular", "incredible", "terrifying"
+ ],
+ "physical_adjective": [
+ "springy", "rubbery", "bouncy", "tough", "notched", "chipped"
+ ],
+ "color": [
+ "blue", "green", "teal", "black", "pure white", "obsidian black", "purple", "bright red", "bright yellow"
+ ],
+ "attribute": [
+ "horns", "teeth", "shell", "fur", "bones", "exoskeleton", "spikes"
+ ],
+ "ability": [
+ "breathe fire", "devour dreams", "lift thousand-pound weights", "devour metal", "chew up diamonds", "create diamonds", "create gemstones", "breathe icy cold air", "spit poison", "live forever"
+ ],
+ "ingredients": [
+ "witch's eye", "frog legs", "slime", "true love's kiss", "a lock of golden hair", "the skin of a snake", "a never-melting chunk of ice"
+ ],
+ "time": [
+ "dusk", "dawn", "mid-day", "midnight on a full moon", "midnight on Halloween night", "the time of a solar eclipse", "the time of a lunar eclipse."
+ ],
+ "year": [
+ "1996", "1594", "1330", "1700"
+ ],
+ "biography_text": [
+ {"scientist_first_name": 1, "scientist_last_name": 1, "verb": 1, "adjective": 1, "attribute": 1, "ability": 1, "color": 1, "year": 1, "time": 1, "physical_adjective": 1, "text": "Your name is {monster_name}, a member of the {adjective} species {monster_species}. The first {monster_species} was {verb} by {scientist_first_name} {scientist_last_name} in {year} at {time}. The species {monster_species} is known for its {physical_adjective} {color} {attribute}. It is said to even be able to {ability}!"},
+ {"scientist_first_name": 1, "scientist_last_name": 1, "adjective": 1, "attribute": 1, "physical_adjective": 1, "ingredients": 2, "time": 1, "ability": 1, "verb": 1, "color": 1, "year": 1, "text": "The {monster_species} is an {adjective} species, and you, {monster_name}, are no exception. {monster_species} is famed for its {physical_adjective} {attribute}. Whispers say that when brewed with {ingredients[0]} and {ingredients[1]} at {time}, a foul, {color} brew will be produced, granting it's drinker the ability to {ability}! This species was {verb} by {scientist_first_name} {scientist_last_name} in {year}."}
+ ]
+}
diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py
index ae93800e..4287efb7 100644
--- a/bot/seasons/christmas/__init__.py
+++ b/bot/seasons/christmas/__init__.py
@@ -1,3 +1,5 @@
+import datetime
+
from bot.constants import Colours
from bot.seasons import SeasonBase
@@ -22,5 +24,10 @@ class Christmas(SeasonBase):
colour = Colours.dark_green
icon = (
- "/logos/logo_seasonal/christmas/festive.png",
+ "/logos/logo_seasonal/christmas/2019/festive_512.gif",
)
+
+ @classmethod
+ def end(cls) -> datetime.datetime:
+ """Overload the `SeasonBase` method to account for the event ending in the next year."""
+ return datetime.datetime.strptime(f"{cls.end_date}/{cls.current_year() + 1}", cls.date_format)
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py
index 007e4783..f2ec83df 100644
--- a/bot/seasons/christmas/adventofcode.py
+++ b/bot/seasons/christmas/adventofcode.py
@@ -15,6 +15,7 @@ from pytz import timezone
from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens, WHITELISTED_CHANNELS
from bot.decorators import override_in_channel
+from bot.utils import unlocked_role
log = logging.getLogger(__name__)
@@ -85,17 +86,42 @@ async def day_countdown(bot: commands.Bot) -> None:
while is_in_advent():
tomorrow, time_left = time_left_to_aoc_midnight()
- await asyncio.sleep(time_left.seconds)
+ # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding
+ # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles.
+ await asyncio.sleep(time_left.seconds - 4)
- channel = bot.get_channel(Channels.seasonalbot_chat)
+ channel = bot.get_channel(Channels.advent_of_code)
if not channel:
log.error("Could not find the AoC channel to send notification in")
break
- await channel.send(f"<@&{AocConfig.role_id}> Good morning! Day {tomorrow.day} is ready to be attempted. "
- f"View it online now at https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}"
- f" (this link could take a few minutes to start working). Good luck!")
+ aoc_role = channel.guild.get_role(AocConfig.role_id)
+ if not aoc_role:
+ log.error("Could not find the AoC role to announce the daily puzzle")
+ break
+
+ async with unlocked_role(aoc_role, delay=5):
+ puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}"
+
+ # Check if the puzzle is already available to prevent our members from spamming
+ # the puzzle page before it's available by making a small HEAD request.
+ for retry in range(1, 5):
+ log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)")
+ async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp:
+ if resp.status == 200:
+ log.debug("Puzzle is available; let's send an announcement message.")
+ break
+ log.debug(f"The puzzle is not yet available (status={resp.status})")
+ await asyncio.sleep(10)
+ else:
+ log.error("The puzzle does does not appear to be available at this time, canceling announcement")
+ break
+
+ await channel.send(
+ f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. "
+ f"View it online now at {puzzle_url}. Good luck!"
+ )
# Wait a couple minutes so that if our sleep didn't sleep enough
# time we don't end up announcing twice.
@@ -122,10 +148,10 @@ class AdventOfCode(commands.Cog):
self.status_task = None
countdown_coro = day_countdown(self.bot)
- self.countdown_task = asyncio.ensure_future(self.bot.loop.create_task(countdown_coro))
+ self.countdown_task = self.bot.loop.create_task(countdown_coro)
status_coro = countdown_status(self.bot)
- self.status_task = asyncio.ensure_future(self.bot.loop.create_task(status_coro))
+ self.status_task = self.bot.loop.create_task(status_coro)
@commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True)
@override_in_channel(AOC_WHITELIST)
@@ -170,10 +196,21 @@ class AdventOfCode(commands.Cog):
"""Return time left until next day."""
if not is_in_advent():
datetime_now = datetime.now(EST)
- december_first = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST)
- delta = december_first - datetime_now
+
+ # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past
+ this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST)
+ next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST)
+ deltas = (dec_first - datetime_now for dec_first in (this_year, next_year))
+ delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta
+
+ # Add a finer timedelta if there's less than a day left
+ if delta.days == 0:
+ delta_str = f"approximately {delta.seconds // 3600} hours"
+ else:
+ delta_str = f"{delta.days} days"
+
await ctx.send(f"The Advent of Code event is not currently running. "
- f"The next event will start in {delta.days} days.")
+ f"The next event will start in {delta_str}.")
return
tomorrow, time_left = time_left_to_aoc_midnight()
@@ -188,7 +225,7 @@ class AdventOfCode(commands.Cog):
"""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")
+ @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")
@override_in_channel(AOC_WHITELIST)
async def join_leaderboard(self, ctx: commands.Context) -> None:
"""DM the user the information for joining the PyDis AoC private leaderboard."""
@@ -204,6 +241,8 @@ class AdventOfCode(commands.Cog):
except discord.errors.Forbidden:
log.debug(f"{author.name} ({author.id}) has disabled DMs from server members")
await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code")
+ else:
+ await ctx.message.add_reaction(Emojis.envelope)
@adventofcode_group.command(
name="leaderboard",
@@ -407,6 +446,12 @@ class AdventOfCode(commands.Cog):
else:
self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url()
+ def cog_unload(self) -> None:
+ """Cancel season-related tasks on cog unload."""
+ log.debug("Unloading the cog and canceling the background task.")
+ self.countdown_task.cancel()
+ self.status_task.cancel()
+
class AocMember:
"""Object representing the Advent of Code user."""
diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py
index 4b98b204..f5b1aac7 100644
--- a/bot/seasons/easter/easter_riddle.py
+++ b/bot/seasons/easter/easter_riddle.py
@@ -83,7 +83,7 @@ class EasterRiddle(commands.Cog):
self.current_channel = None
@commands.Cog.listener()
- async def on_message(self, message: discord.Messaged) -> None:
+ async def on_message(self, message: discord.Message) -> None:
"""If a non-bot user enters a correct answer, their username gets added to self.winners."""
if self.current_channel != message.channel:
return
diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py
index 51f52264..23df95f1 100644
--- a/bot/seasons/easter/egg_decorating.py
+++ b/bot/seasons/easter/egg_decorating.py
@@ -46,7 +46,7 @@ class EggDecorating(commands.Cog):
@commands.command(aliases=["decorateegg"])
async def eggdecorate(
self, ctx: commands.Context, *colours: Union[discord.Colour, str]
- ) -> Union[Image, discord.Message]:
+ ) -> Union[Image.Image, discord.Message]:
"""
Picks a random egg design and decorates it using the given colours.
diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py
index c2746552..b3d0dc63 100644
--- a/bot/seasons/evergreen/__init__.py
+++ b/bot/seasons/evergreen/__init__.py
@@ -13,4 +13,5 @@ class Evergreen(SeasonBase):
"/logos/logo_animated/jumper/jumper_512.gif",
"/logos/logo_animated/apple/apple_512.gif",
"/logos/logo_animated/blinky/blinky_512.gif",
+ "/logos/logo_animated/runner/runner_512.gif",
)
diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py
new file mode 100644
index 00000000..9962186f
--- /dev/null
+++ b/bot/seasons/evergreen/bookmark.py
@@ -0,0 +1,55 @@
+import logging
+import random
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
+
+log = logging.getLogger(__name__)
+
+
+class Bookmark(commands.Cog):
+ """Creates personal bookmarks by relaying a message link to the user's DMs."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="bookmark", aliases=("bm", "pin"))
+ async def bookmark(
+ self,
+ ctx: commands.Context,
+ target_message: discord.Message,
+ *,
+ title: str = "Bookmark"
+ ) -> None:
+ """Send the author a link to `target_message` via DMs."""
+ log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
+ embed = discord.Embed(
+ title=title,
+ colour=Colours.soft_green,
+ description=(
+ f"{target_message.content}\n\n"
+ f"[Visit original message]({target_message.jump_url})"
+ )
+ )
+ embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
+ embed.set_thumbnail(url=bookmark_icon_url)
+
+ try:
+ await ctx.author.send(embed=embed)
+ except discord.Forbidden:
+ error_embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
+ colour=Colours.soft_red
+ )
+ await ctx.send(embed=error_embed)
+ else:
+ await ctx.message.add_reaction(Emojis.envelope)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Bookmark cog."""
+ bot.add_cog(Bookmark(bot))
+ log.info("Bookmark cog loaded")
diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py
index 120462ee..0d8bb0bb 100644
--- a/bot/seasons/evergreen/error_handler.py
+++ b/bot/seasons/evergreen/error_handler.py
@@ -1,119 +1,106 @@
-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: commands.Bot):
- self.bot = bot
-
- @staticmethod
- 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)
- bucket._tokens = min(bucket.rate, bucket._tokens + 1)
- logging.debug(
- "Cooldown counter reverted as the command was not used correctly."
- )
-
- @commands.Cog.listener()
- 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(
- "A command error occured but the command had it's own error handler."
- )
-
- 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."
- )
-
- if isinstance(error, commands.UserInputError):
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' but entered invalid input!"
- )
-
- self.revert_cooldown_counter(ctx.command, ctx.message)
-
- return await ctx.send(
- ":no_entry: The command you specified failed to run. "
- "This is because the arguments you provided were invalid."
- )
-
- if isinstance(error, commands.CommandOnCooldown):
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' but they were on cooldown!"
- )
- remaining_minutes, remaining_seconds = divmod(error.retry_after, 60)
-
- return await ctx.send(
- "This command is on cooldown, please retry in "
- f"{int(remaining_minutes)} minutes {math.ceil(remaining_seconds)} seconds."
- )
-
- if isinstance(error, commands.DisabledCommand):
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' but the command was disabled!"
- )
- return await ctx.send(":no_entry: This command has been disabled.")
-
- if isinstance(error, commands.NoPrivateMessage):
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "in a private message however the command was guild only!"
- )
- return await ctx.author.send(":no_entry: This command can only be used in the server.")
-
- if isinstance(error, commands.BadArgument):
- self.revert_cooldown_counter(ctx.command, ctx.message)
-
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' but entered a bad argument!"
- )
- return await ctx.send("The argument you provided was invalid.")
-
- if isinstance(error, commands.CheckFailure):
- logging.debug(f"{ctx.author} called the command '{ctx.command}' but the checks failed!")
- return await ctx.send(":no_entry: You are not authorized to use this command.")
-
- print(f"Ignoring exception in command {ctx.command}:", file=sys.stderr)
-
- logging.warning(
- f"{ctx.author} called the command '{ctx.command}' "
- "however the command failed to run with the error:"
- f"-------------\n{error}"
- )
-
- traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
-
-
-def setup(bot: commands.Bot) -> None:
- """Error handler Cog load."""
- bot.add_cog(CommandErrorHandler(bot))
- log.info("CommandErrorHandler cog loaded")
+import logging
+import math
+import random
+from typing import Iterable, Union
+
+from discord import Embed, Message
+from discord.ext import commands
+
+from bot.constants import Colours, ERROR_REPLIES, 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: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ 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)
+ bucket._tokens = min(bucket.rate, bucket._tokens + 1)
+ logging.debug("Cooldown counter reverted as the command was not used correctly.")
+
+ @staticmethod
+ def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed:
+ """Build a basic embed with red colour and either a random error title or a title provided."""
+ embed = Embed(colour=Colours.soft_red)
+ if isinstance(title, str):
+ embed.title = title
+ else:
+ embed.title = random.choice(title)
+ embed.description = message
+ return embed
+
+ @commands.Cog.listener()
+ 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'):
+ logging.debug("A command error occured but the command had it's own error handler.")
+ return
+
+ error = getattr(error, 'original', error)
+ logging.debug(
+ f"Error Encountered: {type(error).__name__} - {str(error)}, "
+ f"Command: {ctx.command}, "
+ f"Author: {ctx.author}, "
+ f"Channel: {ctx.channel}"
+ )
+
+ if isinstance(error, commands.CommandNotFound):
+ return
+
+ if isinstance(error, InChannelCheckFailure):
+ await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5)
+ return
+
+ if isinstance(error, commands.UserInputError):
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+ embed = self.error_embed(
+ f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ )
+ await ctx.send(embed=embed)
+ return
+
+ if isinstance(error, commands.CommandOnCooldown):
+ mins, secs = divmod(math.ceil(error.retry_after), 60)
+ embed = self.error_embed(
+ f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.",
+ NEGATIVE_REPLIES
+ )
+ await ctx.send(embed=embed, delete_after=7.5)
+ return
+
+ if isinstance(error, commands.DisabledCommand):
+ await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES))
+ return
+
+ if isinstance(error, commands.NoPrivateMessage):
+ await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES))
+ return
+
+ if isinstance(error, commands.BadArgument):
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+ embed = self.error_embed(
+ "The argument you provided was invalid: "
+ f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ )
+ await ctx.send(embed=embed)
+ return
+
+ if isinstance(error, commands.CheckFailure):
+ await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES))
+ return
+
+ log.exception(f"Unhandled command error: {str(error)}", exc_info=error)
+
+
+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/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py
index ab8d865c..b7b4122d 100644
--- a/bot/seasons/halloween/hacktoberstats.py
+++ b/bot/seasons/halloween/hacktoberstats.py
@@ -227,6 +227,7 @@ class HacktoberStats(commands.Cog):
not_label = "invalid"
action_type = "pr"
is_query = f"public+author:{github_username}"
+ not_query = "draft"
date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00"
per_page = "300"
query_url = (
@@ -234,6 +235,7 @@ class HacktoberStats(commands.Cog):
f"-label:{not_label}"
f"+type:{action_type}"
f"+is:{is_query}"
+ f"+-is:{not_query}"
f"+created:{date_range}"
f"&per_page={per_page}"
)
diff --git a/bot/seasons/halloween/monsterbio.py b/bot/seasons/halloween/monsterbio.py
new file mode 100644
index 00000000..bfa8a026
--- /dev/null
+++ b/bot/seasons/halloween/monsterbio.py
@@ -0,0 +1,56 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f:
+ TEXT_OPTIONS = json.load(f) # Data for a mad-lib style generation of text
+
+
+class MonsterBio(commands.Cog):
+ """A cog that generates a spooky monster biography."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def generate_name(self, seeded_random: random.Random) -> str:
+ """Generates a name (for either monster species or monster name)."""
+ n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"]))
+ return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings))
+
+ @commands.command(brief="Sends your monster bio!")
+ async def monsterbio(self, ctx: commands.Context) -> None:
+ """Sends a description of a monster."""
+ seeded_random = random.Random(ctx.message.author.id) # Seed a local Random instance rather than the system one
+
+ name = self.generate_name(seeded_random)
+ species = self.generate_name(seeded_random)
+ biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"])
+ words = {"monster_name": name, "monster_species": species}
+ for key, value in biography_text.items():
+ if key == "text":
+ continue
+
+ options = seeded_random.sample(TEXT_OPTIONS[key], value)
+ words[key] = ' '.join(options)
+
+ embed = discord.Embed(
+ title=f"{name}'s Biography",
+ color=seeded_random.choice([Colours.orange, Colours.purple]),
+ description=biography_text["text"].format_map(words),
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Monster bio Cog load."""
+ bot.add_cog(MonsterBio(bot))
+ log.info("MonsterBio cog loaded.")
diff --git a/bot/seasons/season.py b/bot/seasons/season.py
index 3546fda6..e7b7a69c 100644
--- a/bot/seasons/season.py
+++ b/bot/seasons/season.py
@@ -79,6 +79,7 @@ class SeasonBase:
start_date: Optional[str] = None
end_date: Optional[str] = None
+ should_announce: bool = False
colour: Optional[int] = None
icon: Tuple[str, ...] = ("/logos/logo_full/logo_full.png",)
@@ -268,11 +269,11 @@ class SeasonBase:
"""
Announces a change in season in the announcement channel.
- It will skip the announcement if the current active season is the "evergreen" default season.
+ Auto-announcement is configured by the `should_announce` `SeasonBase` attribute
"""
- # Don't actually announce if reverting to normal season
- if self.name in ("evergreen", "wildcard", "halloween"):
- log.debug(f"Season Changed: {self.name}")
+ # Short circuit if the season had disabled automatic announcements
+ if not self.should_announce:
+ log.debug(f"Season changed without announcement: {self.name}")
return
guild = bot.get_guild(Client.guild)
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 0aa50af6..25fd4b96 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,4 +1,5 @@
import asyncio
+import contextlib
import re
import string
from typing import List
@@ -127,3 +128,25 @@ def replace_many(
return replacement.lower()
return regex.sub(_repl, sentence)
+
+
+async def unlocked_role(role: discord.Role, delay: int = 5) -> None:
+ """
+ Create a context in which `role` is unlocked, relocking it automatically after use.
+
+ A configurable `delay` is added before yielding the context and directly after exiting the
+ context to allow the role settings change to properly propagate at Discord's end. This
+ prevents things like role mentions from failing because of synchronization issues.
+
+ Usage:
+ >>> async with unlocked_role(role, delay=5):
+ ... await ctx.send(f"Hey {role.mention}, free pings for everyone!")
+ """
+ await role.edit(mentionable=True)
+ await asyncio.sleep(delay)
+ try:
+ yield
+ finally:
+ await asyncio.sleep(delay)
+ await role.edit(mentionable=False)