aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock110
-rw-r--r--bot/__main__.py3
-rw-r--r--bot/constants.py16
-rw-r--r--bot/exts/__init__.py23
-rw-r--r--bot/exts/easter/save_the_planet.py29
-rw-r--r--bot/exts/evergreen/8bitify.py2
-rw-r--r--bot/exts/evergreen/bookmark.py3
-rw-r--r--bot/exts/evergreen/emoji_count.py91
-rw-r--r--bot/exts/evergreen/minesweeper.py12
-rw-r--r--bot/exts/evergreen/snakes/__init__.py2
-rw-r--r--bot/exts/evergreen/snakes/_converter.py (renamed from bot/exts/evergreen/snakes/converter.py)2
-rw-r--r--bot/exts/evergreen/snakes/_snakes_cog.py (renamed from bot/exts/evergreen/snakes/snakes_cog.py)8
-rw-r--r--bot/exts/evergreen/snakes/_utils.py (renamed from bot/exts/evergreen/snakes/utils.py)0
-rw-r--r--bot/exts/evergreen/source.py109
-rw-r--r--bot/exts/halloween/hacktober-issue-finder.py14
-rw-r--r--bot/exts/halloween/hacktoberstats.py39
-rw-r--r--bot/exts/halloween/spookysound.py48
-rw-r--r--bot/exts/halloween/timeleft.py32
-rw-r--r--bot/exts/utils/__init__.py0
-rw-r--r--bot/exts/utils/extensions.py265
-rw-r--r--bot/exts/valentines/valentine_zodiac.py145
-rw-r--r--bot/resources/easter/save_the_planet.json77
-rw-r--r--bot/resources/valentines/zodiac_compatibility.json24
-rw-r--r--bot/resources/valentines/zodiac_explanation.json122
-rw-r--r--bot/utils/checks.py164
-rw-r--r--bot/utils/converters.py16
-rw-r--r--bot/utils/extensions.py34
28 files changed, 1178 insertions, 214 deletions
diff --git a/Pipfile b/Pipfile
index df4e9f07..8d22f6c8 100644
--- a/Pipfile
+++ b/Pipfile
@@ -8,7 +8,7 @@ aiodns = "~=2.0"
arrow = "~=0.14"
beautifulsoup4 = "~=4.8"
fuzzywuzzy = "~=0.17"
-pillow = "~=6.2"
+pillow = "~=7.2"
pytz = "~=2019.2"
sentry-sdk = "~=0.14.2"
PyYAML = "~=5.3.1"
diff --git a/Pipfile.lock b/Pipfile.lock
index 090511f8..6e6a3c2e 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "cef64418a475f0dc0bbc299c276ac10cd27d34154e8e11b77751a1795e268f85"
+ "sha256": "1077d14c4a0456f57062e91e403a107d6321a385ea2bc2e8c833e0b6c22801e4"
},
"pipfile-spec": 6,
"requires": {
@@ -182,39 +182,37 @@
},
"pillow": {
"hashes": [
- "sha256:00e0bbe9923adc5cc38a8da7d87d4ce16cde53b8d3bba8886cb928e84522d963",
- "sha256:03457e439d073770d88afdd90318382084732a5b98b0eb6f49454746dbaae701",
- "sha256:0d5c99f80068f13231ac206bd9b2e80ea357f5cf9ae0fa97fab21e32d5b61065",
- "sha256:1a3bc8e1db5af40a81535a62a591fafdb30a8a1b319798ea8052aa65ef8f06d2",
- "sha256:2b4a94be53dff02af90760c10a2e3634c3c7703410f38c98154d5ce71fe63d20",
- "sha256:3ba7d8f1d962780f86aa747fef0baf3211b80cb13310fff0c375da879c0656d4",
- "sha256:3e81485cec47c24f5fb27acb485a4fc97376b2b332ed633867dc68ac3077998c",
- "sha256:43ef1cff7ee57f9c8c8e6fa02a62eae9fa23a7e34418c7ce88c0e3fe09d1fb38",
- "sha256:4adc3302df4faf77c63ab3a83e1a3e34b94a6a992084f4aa1cb236d1deaf4b39",
- "sha256:535e8e0e02c9f1fc2e307256149d6ee8ad3aa9a6e24144b7b6e6fb6126cb0e99",
- "sha256:5ccfcb0a34ad9b77ad247c231edb781763198f405a5c8dc1b642449af821fb7f",
- "sha256:5dcbbaa3a24d091a64560d3c439a8962866a79a033d40eb1a75f1b3413bfc2bc",
- "sha256:6e2a7e74d1a626b817ecb7a28c433b471a395c010b2a1f511f976e9ea4363e64",
- "sha256:82859575005408af81b3e9171ae326ff56a69af5439d3fc20e8cb76cd51c8246",
- "sha256:834dd023b7f987d6b700ad93dc818098d7eb046bd445e9992b3093c6f9d7a95f",
- "sha256:87ef0eca169f7f0bc050b22f05c7e174a65c36d584428431e802c0165c5856ea",
- "sha256:900de1fdc93764be13f6b39dc0dd0207d9ff441d87ad7c6e97e49b81987dc0f3",
- "sha256:92b83b380f9181cacc994f4c983d95a9c8b00b50bf786c66d235716b526a3332",
- "sha256:aa1b0297e352007ec781a33f026afbb062a9a9895bb103c8f49af434b1666880",
- "sha256:aa4792ab056f51b49e7d59ce5733155e10a918baf8ce50f64405db23d5627fa2",
- "sha256:b72c39585f1837d946bd1a829a4820ccf86e361f28cbf60f5d646f06318b61e2",
- "sha256:bb7861e4618a0c06c40a2e509c1bea207eea5fd4320d486e314e00745a402ca5",
- "sha256:bc149dab804291a18e1186536519e5e122a2ac1316cb80f506e855a500b1cdd4",
- "sha256:c424d35a5259be559b64490d0fd9e03fba81f1ce8e5b66e0a59de97547351d80",
- "sha256:cbd5647097dc55e501f459dbac7f1d0402225636deeb9e0a98a8d2df649fc19d",
- "sha256:ccf16fe444cc43800eeacd4f4769971200982200a71b1368f49410d0eb769543",
- "sha256:d3a98444a00b4643b22b0685dbf9e0ddcaf4ebfd4ea23f84f228adf5a0765bb2",
- "sha256:d6b4dc325170bee04ca8292bbd556c6f5398d52c6149ca881e67daf62215426f",
- "sha256:db9ff0c251ed066d367f53b64827cc9e18ccea001b986d08c265e53625dab950",
- "sha256:e3a797a079ce289e59dbd7eac9ca3bf682d52687f718686857281475b7ca8e6a"
+ "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f",
+ "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8",
+ "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad",
+ "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f",
+ "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae",
+ "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d",
+ "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5",
+ "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b",
+ "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8",
+ "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233",
+ "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6",
+ "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727",
+ "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f",
+ "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38",
+ "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4",
+ "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626",
+ "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d",
+ "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6",
+ "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6",
+ "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63",
+ "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f",
+ "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41",
+ "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1",
+ "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117",
+ "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d",
+ "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9",
+ "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a",
+ "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce"
],
"index": "pypi",
- "version": "==6.2.2"
+ "version": "==7.2.0"
},
"pycares": {
"hashes": [
@@ -289,7 +287,7 @@
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.8.1"
},
"pytz": {
@@ -330,7 +328,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.15.0"
},
"soupsieve": {
@@ -351,26 +349,26 @@
},
"yarl": {
"hashes": [
- "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409",
- "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593",
- "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2",
- "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8",
- "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d",
- "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692",
- "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02",
- "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a",
- "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8",
- "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6",
- "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511",
- "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e",
- "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a",
- "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb",
- "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f",
- "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317",
- "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"
+ "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e",
+ "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5",
+ "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580",
+ "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc",
+ "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b",
+ "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2",
+ "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a",
+ "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921",
+ "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e",
+ "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1",
+ "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d",
+ "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131",
+ "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a",
+ "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1",
+ "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188",
+ "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020",
+ "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"
],
"markers": "python_version >= '3.5'",
- "version": "==1.5.1"
+ "version": "==1.6.0"
}
},
"develop": {
@@ -483,11 +481,11 @@
},
"identify": {
"hashes": [
- "sha256:d7da7de6825568daa4449858ce328ecc0e1ada2554d972a6f4f90e736aaf499a",
- "sha256:e4db4796b3b0cf4f9cb921da51430abffff2d4ba7d7c521184ed5252bd90d461"
+ "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4",
+ "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.4"
+ "version": "==1.5.5"
},
"mccabe": {
"hashes": [
@@ -565,7 +563,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.15.0"
},
"snowballstemmer": {
diff --git a/bot/__main__.py b/bot/__main__.py
index 0ffd6143..cd2d43a9 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -5,8 +5,9 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from bot.bot import bot
from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS
-from bot.exts import walk_extensions
from bot.utils.decorators import in_channel_check
+from bot.utils.extensions import walk_extensions
+
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
diff --git a/bot/constants.py b/bot/constants.py
index 5d4d303f..90c440fd 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -11,7 +11,6 @@ __all__ = (
"Client",
"Colours",
"Emojis",
- "Hacktoberfest",
"Icons",
"Lovefest",
"Month",
@@ -75,7 +74,7 @@ class Channels(NamedTuple):
python_discussion = 267624335836053506
show_your_projects = int(environ.get("CHANNEL_SHOW_YOUR_PROJECTS", 303934982764625920))
show_your_projects_discussion = 360148304664723466
- hacktoberfest_2019 = 628184417646411776
+ hacktoberfest_2020 = 760857070781071431
class Client(NamedTuple):
@@ -84,6 +83,7 @@ class Client(NamedTuple):
token = environ.get("SEASONALBOT_TOKEN")
sentry_dsn = environ.get("SEASONALBOT_SENTRY_DSN")
debug = environ.get("SEASONALBOT_DEBUG", "").lower() == "true"
+ github_bot_repo = "https://github.com/python-discord/seasonalbot"
# Override seasonal locks: 1 (January) to 12 (December)
month_override = int(environ["MONTH_OVERRIDE"]) if "MONTH_OVERRIDE" in environ else None
@@ -139,9 +139,10 @@ class Emojis:
x = "\U0001f1fd"
o = "\U0001f1f4"
-
-class Hacktoberfest(NamedTuple):
- voice_id = 514420006474219521
+ status_online = "<:status_online:470326272351010816>"
+ status_idle = "<:status_idle:470326266625785866>"
+ status_dnd = "<:status_dnd:470326272082313216>"
+ status_offline = "<:status_offline:470326266537705472>"
class Icons:
@@ -194,6 +195,7 @@ class Roles(NamedTuple):
verified = 352427296948486144
helpers = 267630620367257601
rockstars = 458226413825294336
+ core_developers = 587606783669829632
class Tokens(NamedTuple):
@@ -282,3 +284,7 @@ POSITIVE_REPLIES = [
class Wikipedia:
total_chance = 3
+
+class Source:
+ github = "https://github.com/python-discord/seasonalbot"
+ github_avatar_url = "https://avatars1.githubusercontent.com/u/9919"
diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py
index 25deb9af..13f484ac 100644
--- a/bot/exts/__init__.py
+++ b/bot/exts/__init__.py
@@ -1,9 +1,8 @@
import logging
import pkgutil
-from pathlib import Path
from typing import Iterator
-__all__ = ("get_package_names", "walk_extensions")
+__all__ = ("get_package_names",)
log = logging.getLogger(__name__)
@@ -13,23 +12,3 @@ def get_package_names() -> Iterator[str]:
for package in pkgutil.iter_modules(__path__):
if package.ispkg:
yield package.name
-
-
-def walk_extensions() -> Iterator[str]:
- """
- Iterate dot-separated paths to all extensions.
-
- The strings are formatted in a way such that the bot's `load_extension`
- method can take them. Use this to load all available extensions.
-
- This intentionally doesn't make use of pkgutil's `walk_packages`, as we only
- want to build paths to extensions - not recursively all modules. For some
- extensions, the `setup` function is in the package's __init__ file, while
- modules nested under the package are only helpers. Constructing the paths
- ourselves serves our purpose better.
- """
- base_path = Path(__path__[0])
-
- for package in get_package_names():
- for extension in pkgutil.iter_modules([base_path.joinpath(package)]):
- yield f"bot.exts.{package}.{extension.name}"
diff --git a/bot/exts/easter/save_the_planet.py b/bot/exts/easter/save_the_planet.py
new file mode 100644
index 00000000..8f644259
--- /dev/null
+++ b/bot/exts/easter/save_the_planet.py
@@ -0,0 +1,29 @@
+import json
+from pathlib import Path
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.utils.randomization import RandomCycle
+
+
+with Path("bot/resources/easter/save_the_planet.json").open('r', encoding='utf8') as f:
+ EMBED_DATA = RandomCycle(json.load(f))
+
+
+class SaveThePlanet(commands.Cog):
+ """A cog that teaches users how they can help our planet."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ @commands.command(aliases=('savetheearth', 'saveplanet', 'saveearth'))
+ async def savetheplanet(self, ctx: commands.Context) -> None:
+ """Responds with a random tip on how to be eco-friendly and help our planet."""
+ return_embed = Embed.from_dict(next(EMBED_DATA))
+ await ctx.send(embed=return_embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Save the Planet Cog load."""
+ bot.add_cog(SaveThePlanet(bot))
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
index 60062fc1..c048d9bf 100644
--- a/bot/exts/evergreen/8bitify.py
+++ b/bot/exts/evergreen/8bitify.py
@@ -14,7 +14,7 @@ class EightBitify(commands.Cog):
@staticmethod
def pixelate(image: Image) -> Image:
"""Takes an image and pixelates it."""
- return image.resize((32, 32)).resize((1024, 1024))
+ return image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST)
@staticmethod
def quantize(image: Image) -> Image:
diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py
index 73908702..5fa05d2e 100644
--- a/bot/exts/evergreen/bookmark.py
+++ b/bot/exts/evergreen/bookmark.py
@@ -5,6 +5,7 @@ import discord
from discord.ext import commands
from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons
+from bot.utils.converters import WrappedMessageConverter
log = logging.getLogger(__name__)
@@ -19,7 +20,7 @@ class Bookmark(commands.Cog):
async def bookmark(
self,
ctx: commands.Context,
- target_message: discord.Message,
+ target_message: WrappedMessageConverter,
*,
title: str = "Bookmark"
) -> None:
diff --git a/bot/exts/evergreen/emoji_count.py b/bot/exts/evergreen/emoji_count.py
new file mode 100644
index 00000000..ef900199
--- /dev/null
+++ b/bot/exts/evergreen/emoji_count.py
@@ -0,0 +1,91 @@
+import datetime
+import logging
+import random
+from typing import Dict, Optional
+
+import discord
+from discord.ext import commands
+
+from bot.constants import Colours, ERROR_REPLIES
+
+log = logging.getLogger(__name__)
+
+
+class EmojiCount(commands.Cog):
+ """Command that give random emoji based on category."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def embed_builder(self, emoji: dict) -> discord.Embed:
+ """Generates an embed with the emoji names and count."""
+ embed = discord.Embed(
+ color=Colours.orange,
+ title="Emoji Count",
+ timestamp=datetime.datetime.utcnow()
+ )
+
+ if len(emoji) == 1:
+ for key, value in emoji.items():
+ embed.description = f"There are **{len(value)}** emojis in the **{key}** category"
+ embed.set_thumbnail(url=random.choice(value).url)
+ else:
+ msg = ''
+ for key, value in emoji.items():
+ emoji_choice = random.choice(value)
+ emoji_info = f'There are **{len(value)}** emojis in the **{key}** category\n'
+ msg += f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}'
+ embed.description = msg
+ return embed
+
+ @staticmethod
+ def generate_invalid_embed(ctx: commands.Context) -> discord.Embed:
+ """Genrates error embed."""
+ embed = discord.Embed(
+ color=Colours.soft_red,
+ title=random.choice(ERROR_REPLIES)
+ )
+
+ emoji_dict = {}
+ for emoji in ctx.guild.emojis:
+ emoji_dict[emoji.name.split("_")[0]] = []
+
+ error_comp = ', '.join(key for key in emoji_dict.keys())
+ embed.description = f"These are the valid categories\n```{error_comp}```"
+ return embed
+
+ def emoji_list(self, ctx: commands.Context, categories: dict) -> Dict:
+ """Generates an embed with the emoji names and count."""
+ out = {category: [] for category in categories}
+
+ for emoji in ctx.guild.emojis:
+ category = emoji.name.split('_')[0]
+ if category in out:
+ out[category].append(emoji)
+ return out
+
+ @commands.command(name="emoji_count", aliases=["ec"])
+ async def ec(self, ctx: commands.Context, *, emoji: str = None) -> Optional[str]:
+ """Returns embed with emoji category and info given by the user."""
+ emoji_dict = {}
+
+ for a in ctx.guild.emojis:
+ if emoji is None:
+ log.trace("Emoji Category not provided by the user")
+ emoji_dict.update({a.name.split("_")[0]: []})
+ elif a.name.split("_")[0] in emoji:
+ log.trace("Emoji Category provided by the user")
+ emoji_dict.update({a.name.split("_")[0]: []})
+
+ emoji_dict = self.emoji_list(ctx, emoji_dict)
+
+ if len(emoji_dict) == 0:
+ embed = self.generate_invalid_embed(ctx)
+ else:
+ embed = self.embed_builder(emoji_dict)
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Emoji Count Cog load."""
+ bot.add_cog(EmojiCount(bot))
diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py
index 3e40f493..286ac7a5 100644
--- a/bot/exts/evergreen/minesweeper.py
+++ b/bot/exts/evergreen/minesweeper.py
@@ -120,14 +120,14 @@ class Minesweeper(commands.Cog):
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"
+ ":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)
+ new_row += " ".join(MESSAGE_MAPPING[cell] for cell in row)
rows.append(new_row)
discord_msg += "\n".join(rows)
@@ -158,7 +158,7 @@ class Minesweeper(commands.Cog):
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)}")
+ chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}")
else:
chat_msg = None
@@ -176,7 +176,7 @@ class Minesweeper(commands.Cog):
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)}")
+ await game.chat_msg.edit(content=f"Here's their board!\n{self.format_for_discord(game.revealed)}")
@commands.dm_only()
@minesweeper_group.command(name="flag")
diff --git a/bot/exts/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py
index 2eae2751..bc42f0c2 100644
--- a/bot/exts/evergreen/snakes/__init__.py
+++ b/bot/exts/evergreen/snakes/__init__.py
@@ -2,7 +2,7 @@ import logging
from discord.ext import commands
-from bot.exts.evergreen.snakes.snakes_cog import Snakes
+from bot.exts.evergreen.snakes._snakes_cog import Snakes
log = logging.getLogger(__name__)
diff --git a/bot/exts/evergreen/snakes/converter.py b/bot/exts/evergreen/snakes/_converter.py
index 55609b8e..eee248cf 100644
--- a/bot/exts/evergreen/snakes/converter.py
+++ b/bot/exts/evergreen/snakes/_converter.py
@@ -7,7 +7,7 @@ import discord
from discord.ext.commands import Context, Converter
from fuzzywuzzy import fuzz
-from bot.exts.evergreen.snakes.utils import SNAKE_RESOURCES
+from bot.exts.evergreen.snakes._utils import SNAKE_RESOURCES
from bot.utils import disambiguate
log = logging.getLogger(__name__)
diff --git a/bot/exts/evergreen/snakes/snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py
index 9bbad9fe..70bb0e73 100644
--- a/bot/exts/evergreen/snakes/snakes_cog.py
+++ b/bot/exts/evergreen/snakes/_snakes_cog.py
@@ -18,8 +18,8 @@ from discord import Colour, Embed, File, Member, Message, Reaction
from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group
from bot.constants import ERROR_REPLIES, Tokens
-from bot.exts.evergreen.snakes import utils
-from bot.exts.evergreen.snakes.converter import Snake
+from bot.exts.evergreen.snakes import _utils as utils
+from bot.exts.evergreen.snakes._converter import Snake
from bot.utils.decorators import locked
log = logging.getLogger(__name__)
@@ -1083,13 +1083,13 @@ class Snakes(Cog):
url,
params={
"part": "snippet",
- "q": urllib.parse.quote(query),
+ "q": urllib.parse.quote_plus(query),
"type": "video",
"key": Tokens.youtube
}
)
response = await response.json()
- data = response['items']
+ data = response.get("items", [])
# Send the user a video
if len(data) > 0:
diff --git a/bot/exts/evergreen/snakes/utils.py b/bot/exts/evergreen/snakes/_utils.py
index 7d6caf04..7d6caf04 100644
--- a/bot/exts/evergreen/snakes/utils.py
+++ b/bot/exts/evergreen/snakes/_utils.py
diff --git a/bot/exts/evergreen/source.py b/bot/exts/evergreen/source.py
new file mode 100644
index 00000000..0725714f
--- /dev/null
+++ b/bot/exts/evergreen/source.py
@@ -0,0 +1,109 @@
+import inspect
+from pathlib import Path
+from typing import Optional, Tuple, Union
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.constants import Source
+
+SourceType = Union[commands.Command, commands.Cog, str, commands.ExtensionNotLoaded]
+
+
+class SourceConverter(commands.Converter):
+ """Convert an argument into a help command, tag, command, or cog."""
+
+ async def convert(self, ctx: commands.Context, argument: str) -> SourceType:
+ """Convert argument into source object."""
+ cog = ctx.bot.get_cog(argument)
+ if cog:
+ return cog
+
+ cmd = ctx.bot.get_command(argument)
+ if cmd:
+ return cmd
+
+ raise commands.BadArgument(
+ f"Unable to convert `{argument}` to valid command or Cog."
+ )
+
+
+class BotSource(commands.Cog):
+ """Displays information about the bot's source code."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="source", aliases=("src",))
+ async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None:
+ """Display information and a GitHub link to the source code of a command, tag, or cog."""
+ if not source_item:
+ embed = Embed(title="Seasonal Bot's GitHub Repository")
+ embed.add_field(name="Repository", value=f"[Go to GitHub]({Source.github})")
+ embed.set_thumbnail(url=Source.github_avatar_url)
+ await ctx.send(embed=embed)
+ return
+
+ embed = await self.build_embed(source_item)
+ await ctx.send(embed=embed)
+
+ def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]:
+ """
+ Build GitHub link of source item, return this link, file location and first line number.
+
+ Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).
+ """
+ if isinstance(source_item, commands.Command):
+ src = source_item.callback.__code__
+ filename = src.co_filename
+ else:
+ src = type(source_item)
+ try:
+ filename = inspect.getsourcefile(src)
+ except TypeError:
+ raise commands.BadArgument("Cannot get source for a dynamically-created object.")
+
+ if not isinstance(source_item, str):
+ try:
+ lines, first_line_no = inspect.getsourcelines(src)
+ except OSError:
+ raise commands.BadArgument("Cannot get source for a dynamically-created object.")
+
+ lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}"
+ else:
+ first_line_no = None
+ lines_extension = ""
+
+ file_location = Path(filename).relative_to(Path.cwd()).as_posix()
+
+ url = f"{Source.github}/blob/master/{file_location}{lines_extension}"
+
+ return url, file_location, first_line_no or None
+
+ async def build_embed(self, source_object: SourceType) -> Optional[Embed]:
+ """Build embed based on source object."""
+ url, location, first_line = self.get_source_link(source_object)
+
+ if isinstance(source_object, commands.Command):
+ if source_object.cog_name == 'Help':
+ title = "Help Command"
+ description = source_object.__doc__.splitlines()[1]
+ else:
+ description = source_object.short_doc
+ title = f"Command: {source_object.qualified_name}"
+ else:
+ title = f"Cog: {source_object.qualified_name}"
+ description = source_object.description.splitlines()[0]
+
+ embed = Embed(title=title, description=description)
+ embed.set_thumbnail(url=Source.github_avatar_url)
+ embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})")
+ line_text = f":{first_line}" if first_line else ""
+ embed.set_footer(text=f"{location}{line_text}")
+
+ return embed
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the BotSource cog."""
+ bot.add_cog(BotSource(bot))
diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py
index b5ad1c4f..9deadde9 100644
--- a/bot/exts/halloween/hacktober-issue-finder.py
+++ b/bot/exts/halloween/hacktober-issue-finder.py
@@ -7,13 +7,19 @@ import aiohttp
import discord
from discord.ext import commands
-from bot.constants import Month
+from bot.constants import Month, Tokens
from bot.utils.decorators import in_month
log = logging.getLogger(__name__)
URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open"
-HEADERS = {"Accept": "application / vnd.github.v3 + json"}
+
+REQUEST_HEADERS = {
+ "User-Agent": "Python Discord Hacktoberbot",
+ "Accept": "application / vnd.github.v3 + json"
+}
+if GITHUB_TOKEN := Tokens.github:
+ REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
class HacktoberIssues(commands.Cog):
@@ -66,7 +72,7 @@ class HacktoberIssues(commands.Cog):
url += f"&page={page}"
log.debug(f"making api request to url: {url}")
- async with session.get(url, headers=HEADERS) as response:
+ async with session.get(url, headers=REQUEST_HEADERS) as response:
if response.status != 200:
log.error(f"expected 200 status (got {response.status}) from the GitHub api.")
await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.")
@@ -97,7 +103,7 @@ class HacktoberIssues(commands.Cog):
labels = [label["name"] for label in issue["labels"]]
embed = discord.Embed(title=title)
- embed.description = body
+ embed.description = body[:500] + '...' if len(body) > 500 else body
embed.add_field(name="labels", value="\n".join(labels))
embed.url = issue_url
embed.set_footer(text=issue_url)
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index db5e37f2..ed1755e3 100644
--- a/bot/exts/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -10,7 +10,7 @@ import aiohttp
import discord
from discord.ext import commands
-from bot.constants import Channels, Month, WHITELISTED_CHANNELS
+from bot.constants import Channels, Month, Tokens, WHITELISTED_CHANNELS
from bot.utils.decorators import in_month, override_in_channel
from bot.utils.persist import make_persistent
@@ -18,7 +18,16 @@ 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
-HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,)
+HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2020,)
+
+REQUEST_HEADERS = {"User-Agent": "Python Discord Hacktoberbot"}
+if GITHUB_TOKEN := Tokens.github:
+ REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
+
+GITHUB_NONEXISTENT_USER_MESSAGE = (
+ "The listed users cannot be searched either because the users do not exist "
+ "or you do not have permission to view the users."
+)
class HacktoberStats(commands.Cog):
@@ -29,7 +38,7 @@ class HacktoberStats(commands.Cog):
self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json"))
self.linked_accounts = self.load_linked_users()
- @in_month(Month.OCTOBER)
+ @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
@override_in_channel(HACKTOBER_WHITELIST)
async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
@@ -57,7 +66,7 @@ class HacktoberStats(commands.Cog):
await self.get_stats(ctx, github_username)
- @in_month(Month.OCTOBER)
+ @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="link")
@override_in_channel(HACKTOBER_WHITELIST)
async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
@@ -92,7 +101,7 @@ class HacktoberStats(commands.Cog):
logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username")
await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
- @in_month(Month.OCTOBER)
+ @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="unlink")
@override_in_channel(HACKTOBER_WHITELIST)
async def unlink_user(self, ctx: commands.Context) -> None:
@@ -175,11 +184,11 @@ class HacktoberStats(commands.Cog):
n = pr_stats['n_prs']
if n >= PRS_FOR_SHIRT:
- shirtstr = f"**{github_username} has earned a tshirt!**"
+ shirtstr = f"**{github_username} has earned a T-shirt or a tree!**"
elif n == PRS_FOR_SHIRT - 1:
- shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
+ shirtstr = f"**{github_username} is 1 PR away from a T-shirt or a tree!**"
else:
- shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**"
+ shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a T-shirt or a tree!**"
stats_embed = discord.Embed(
title=f"{github_username}'s Hacktoberfest",
@@ -196,7 +205,7 @@ class HacktoberStats(commands.Cog):
stats_embed.set_author(
name="Hacktoberfest",
url="https://hacktoberfest.digitalocean.com",
- icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png"
+ icon_url="https://avatars1.githubusercontent.com/u/35706162?s=200&v=4"
)
stats_embed.add_field(
name="Top 5 Repositories:",
@@ -242,16 +251,22 @@ class HacktoberStats(commands.Cog):
f"&per_page={per_page}"
)
- headers = {"user-agent": "Discord Python Hacktoberbot"}
async with aiohttp.ClientSession() as session:
- async with session.get(query_url, headers=headers) as resp:
+ async with session.get(query_url, headers=REQUEST_HEADERS) as resp:
jsonresp = await resp.json()
if "message" in jsonresp.keys():
# One of the parameters is invalid, short circuit for now
api_message = jsonresp["errors"][0]["message"]
- logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+
+ # Ignore logging non-existent users or users we do not have permission to see
+ if api_message == GITHUB_NONEXISTENT_USER_MESSAGE:
+ logging.debug(f"No GitHub user found named '{github_username}'")
+ else:
+ logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+
return
+
else:
if jsonresp["total_count"] == 0:
# Short circuit if there aren't any PRs
diff --git a/bot/exts/halloween/spookysound.py b/bot/exts/halloween/spookysound.py
deleted file mode 100644
index 569a9153..00000000
--- a/bot/exts/halloween/spookysound.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import logging
-import random
-from pathlib import Path
-
-import discord
-from discord.ext import commands
-
-from bot.bot import SeasonalBot
-from bot.constants import Hacktoberfest
-
-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: SeasonalBot):
- 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: commands.Context) -> None:
- """
- Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect.
-
- Cannot be used more than once in 2 minutes.
- """
- if not self.channel:
- await self.bot.wait_until_guild_available()
- self.channel = self.bot.get_channel(Hacktoberfest.voice_id)
-
- await ctx.send("Initiating spooky sound...")
- file_path = random.choice(self.sound_files)
- src = discord.FFmpegPCMAudio(str(file_path.resolve()))
- voice = await self.channel.connect()
- voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice)))
-
- @staticmethod
- async def disconnect(voice: discord.VoiceClient) -> None:
- """Helper method to disconnect a given voice client."""
- await voice.disconnect()
-
-
-def setup(bot: SeasonalBot) -> None:
- """Spooky sound Cog load."""
- bot.add_cog(SpookySound(bot))
diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py
index 295acc89..47adb09b 100644
--- a/bot/exts/halloween/timeleft.py
+++ b/bot/exts/halloween/timeleft.py
@@ -13,20 +13,23 @@ class TimeLeft(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
- @staticmethod
- def in_october() -> bool:
- """Return True if the current month is October."""
- return datetime.utcnow().month == 10
+ def in_hacktober(self) -> bool:
+ """Return True if the current time is within Hacktoberfest."""
+ _, end, start = self.load_date()
+
+ now = datetime.utcnow()
+
+ return start <= now <= end
@staticmethod
- def load_date() -> Tuple[int, datetime, datetime]:
+ def load_date() -> Tuple[datetime, 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
if now.month > 10:
year += 1
- end = datetime(year, 11, 1, 11, 59, 59)
- start = datetime(year, 10, 1)
+ end = datetime(year, 11, 1, 12) # November 1st 12:00 (UTC-12:00)
+ start = datetime(year, 9, 30, 10) # September 30th 10:00 (UTC+14:00)
return now, end, start
@commands.command()
@@ -35,16 +38,23 @@ class TimeLeft(commands.Cog):
Calculates the time left until the end of Hacktober.
Whilst in October, displays the days, hours and minutes left.
- Only displays the days left until the beginning and end whilst in a different month
+ Only displays the days left until the beginning and end whilst in a different month.
+
+ This factors in that Hacktoberfest starts when it is October anywhere in the world
+ and ends with the same rules. It treats the start as UTC+14:00 and the end as
+ UTC-12.
"""
now, end, start = self.load_date()
diff = end - now
days, seconds = diff.days, diff.seconds
- if self.in_october():
+ if self.in_hacktober():
minutes = seconds // 60
hours, minutes = divmod(minutes, 60)
- await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}"
- "minutes left until the end of Hacktober.")
+
+ await ctx.send(
+ f"There are {days} days, {hours} hours and {minutes}"
+ f" minutes left until the end of Hacktober."
+ )
else:
start_diff = start - now
start_days = start_diff.days
diff --git a/bot/exts/utils/__init__.py b/bot/exts/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/utils/__init__.py
diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py
new file mode 100644
index 00000000..102a0416
--- /dev/null
+++ b/bot/exts/utils/extensions.py
@@ -0,0 +1,265 @@
+import functools
+import logging
+import typing as t
+from enum import Enum
+
+from discord import Colour, Embed
+from discord.ext import commands
+from discord.ext.commands import Context, group
+
+from bot import exts
+from bot.bot import SeasonalBot as Bot
+from bot.constants import Client, Emojis, MODERATION_ROLES, Roles
+from bot.utils.checks import with_role_check
+from bot.utils.extensions import EXTENSIONS, unqualify
+from bot.utils.pagination import LinePaginator
+
+log = logging.getLogger(__name__)
+
+
+UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions"}
+BASE_PATH_LEN = len(exts.__name__.split("."))
+
+
+class Action(Enum):
+ """Represents an action to perform on an extension."""
+
+ # Need to be partial otherwise they are considered to be function definitions.
+ LOAD = functools.partial(Bot.load_extension)
+ UNLOAD = functools.partial(Bot.unload_extension)
+ RELOAD = functools.partial(Bot.reload_extension)
+
+
+class Extension(commands.Converter):
+ """
+ Fully qualify the name of an extension and ensure it exists.
+
+ The * and ** values bypass this when used with the reload command.
+ """
+
+ async def convert(self, ctx: Context, argument: str) -> str:
+ """Fully qualify the name of an extension and ensure it exists."""
+ # Special values to reload all extensions
+ if argument == "*" or argument == "**":
+ return argument
+
+ argument = argument.lower()
+
+ if argument in EXTENSIONS:
+ return argument
+ elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
+ return qualified_arg
+
+ matches = []
+ for ext in EXTENSIONS:
+ if argument == unqualify(ext):
+ matches.append(ext)
+
+ if len(matches) > 1:
+ matches.sort()
+ names = "\n".join(matches)
+ raise commands.BadArgument(
+ f":x: `{argument}` is an ambiguous extension name. "
+ f"Please use one of the following fully-qualified names.```\n{names}```"
+ )
+ elif matches:
+ return matches[0]
+ else:
+ raise commands.BadArgument(f":x: Could not find the extension `{argument}`.")
+
+
+class Extensions(commands.Cog):
+ """Extension management commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
+ async def extensions_group(self, ctx: Context) -> None:
+ """Load, unload, reload, and list loaded extensions."""
+ await ctx.send_help(ctx.command)
+
+ @extensions_group.command(name="load", aliases=("l",))
+ async def load_command(self, ctx: Context, *extensions: Extension) -> None:
+ r"""
+ Load extensions given their fully qualified or unqualified names.
+
+ If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.send_help(ctx.command)
+ return
+
+ if "*" in extensions or "**" in extensions:
+ extensions = set(EXTENSIONS) - set(self.bot.extensions.keys())
+
+ msg = self.batch_manage(Action.LOAD, *extensions)
+ await ctx.send(msg)
+
+ @extensions_group.command(name="unload", aliases=("ul",))
+ async def unload_command(self, ctx: Context, *extensions: Extension) -> None:
+ r"""
+ Unload currently loaded extensions given their fully qualified or unqualified names.
+
+ If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.send_help(ctx.command)
+ return
+
+ blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
+
+ if blacklisted:
+ msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```"
+ else:
+ if "*" in extensions or "**" in extensions:
+ extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST
+
+ msg = self.batch_manage(Action.UNLOAD, *extensions)
+
+ await ctx.send(msg)
+
+ @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))
+ async def reload_command(self, ctx: Context, *extensions: Extension) -> None:
+ r"""
+ Reload extensions given their fully qualified or unqualified names.
+
+ If an extension fails to be reloaded, it will be rolled-back to the prior working state.
+
+ If '\*' is given as the name, all currently loaded extensions will be reloaded.
+ If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.send_help(ctx.command)
+ return
+
+ if "**" in extensions:
+ extensions = EXTENSIONS
+ elif "*" in extensions:
+ extensions = set(self.bot.extensions.keys()) | set(extensions)
+ extensions.remove("*")
+
+ msg = self.batch_manage(Action.RELOAD, *extensions)
+
+ await ctx.send(msg)
+
+ @extensions_group.command(name="list", aliases=("all",))
+ async def list_command(self, ctx: Context) -> None:
+ """
+ Get a list of all extensions, including their loaded status.
+
+ Grey indicates that the extension is unloaded.
+ Green indicates that the extension is currently loaded.
+ """
+ embed = Embed(colour=Colour.blurple())
+ embed.set_author(
+ name="Extensions List",
+ url=Client.github_bot_repo,
+ icon_url=str(self.bot.user.avatar_url)
+ )
+
+ lines = []
+ categories = self.group_extension_statuses()
+ for category, extensions in sorted(categories.items()):
+ # Treat each category as a single line by concatenating everything.
+ # This ensures the paginator will not cut off a page in the middle of a category.
+ category = category.replace("_", " ").title()
+ extensions = "\n".join(sorted(extensions))
+ lines.append(f"**{category}**\n{extensions}\n")
+
+ log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
+ await LinePaginator.paginate(lines, ctx, embed, max_size=1200, empty=False)
+
+ def group_extension_statuses(self) -> t.Mapping[str, str]:
+ """Return a mapping of extension names and statuses to their categories."""
+ categories = {}
+
+ for ext in EXTENSIONS:
+ if ext in self.bot.extensions:
+ status = Emojis.status_online
+ else:
+ status = Emojis.status_offline
+
+ path = ext.split(".")
+ if len(path) > BASE_PATH_LEN + 1:
+ category = " - ".join(path[BASE_PATH_LEN:-1])
+ else:
+ category = "uncategorised"
+
+ categories.setdefault(category, []).append(f"{status} {path[-1]}")
+
+ return categories
+
+ def batch_manage(self, action: Action, *extensions: str) -> str:
+ """
+ Apply an action to multiple extensions and return a message with the results.
+
+ If only one extension is given, it is deferred to `manage()`.
+ """
+ if len(extensions) == 1:
+ msg, _ = self.manage(action, extensions[0])
+ return msg
+
+ verb = action.name.lower()
+ failures = {}
+
+ for extension in extensions:
+ _, error = self.manage(action, extension)
+ if error:
+ failures[extension] = error
+
+ emoji = ":x:" if failures else ":ok_hand:"
+ msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed."
+
+ if failures:
+ failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items())
+ msg += f"\nFailures:```{failures}```"
+
+ log.debug(f"Batch {verb}ed extensions.")
+
+ return msg
+
+ def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]:
+ """Apply an action to an extension and return the status message and any error message."""
+ verb = action.name.lower()
+ error_msg = None
+
+ try:
+ action.value(self.bot, ext)
+ except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded):
+ if action is Action.RELOAD:
+ # When reloading, just load the extension if it was not loaded.
+ return self.manage(Action.LOAD, ext)
+
+ msg = f":x: Extension `{ext}` is already {verb}ed."
+ log.debug(msg[4:])
+ except Exception as e:
+ if hasattr(e, "original"):
+ e = e.original
+
+ log.exception(f"Extension '{ext}' failed to {verb}.")
+
+ error_msg = f"{e.__class__.__name__}: {e}"
+ msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```"
+ else:
+ msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`."
+ log.debug(msg[10:])
+
+ return msg, error_msg
+
+ # This cannot be static (must have a __func__ attribute).
+ def cog_check(self, ctx: Context) -> bool:
+ """Only allow moderators and core developers to invoke the commands in this cog."""
+ return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers)
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Handle BadArgument errors locally to prevent the help command from showing."""
+ if isinstance(error, commands.BadArgument):
+ await ctx.send(str(error))
+ error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Extensions cog."""
+ bot.add_cog(Extensions(bot))
diff --git a/bot/exts/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py
index ef9ddc78..2696999f 100644
--- a/bot/exts/valentines/valentine_zodiac.py
+++ b/bot/exts/valentines/valentine_zodiac.py
@@ -1,7 +1,10 @@
+import calendar
+import json
import logging
import random
-from json import load
+from datetime import datetime
from pathlib import Path
+from typing import Tuple, Union
import discord
from discord.ext import commands
@@ -19,37 +22,123 @@ class ValentineZodiac(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
- self.zodiacs = self.load_json()
+ self.zodiacs, self.zodiac_fact = self.load_comp_json()
@staticmethod
- def load_json() -> dict:
+ def load_comp_json() -> Tuple[dict, dict]:
"""Load zodiac compatibility from static JSON resource."""
- p = Path("bot/resources/valentines/zodiac_compatibility.json")
- with p.open(encoding="utf8") as json_data:
- zodiacs = load(json_data)
- return zodiacs
-
- @commands.command(name="partnerzodiac")
- 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()])
- except KeyError:
- return await ctx.send(zodiac_sign.capitalize() + " zodiac sign does not exist.")
-
- emoji1 = random.choice(HEART_EMOJIS)
- emoji2 = random.choice(HEART_EMOJIS)
- embed = discord.Embed(
- title="Zodic Compatibility",
- description=f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n'
- f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}',
- color=Colours.pink
- )
- embed.add_field(
- name=f'A letter from Dr.Zodiac {LETTER_EMOJI}',
- value=compatible_zodiac['description']
- )
+ explanation_file = Path("bot/resources/valentines/zodiac_explanation.json")
+ compatibility_file = Path("bot/resources/valentines/zodiac_compatibility.json")
+ with explanation_file.open(encoding="utf8") as json_data:
+ zodiac_fact = json.load(json_data)
+ for zodiac_data in zodiac_fact.values():
+ zodiac_data['start_at'] = datetime.fromisoformat(zodiac_data['start_at'])
+ zodiac_data['end_at'] = datetime.fromisoformat(zodiac_data['end_at'])
+
+ with compatibility_file.open(encoding="utf8") as json_data:
+ zodiacs = json.load(json_data)
+
+ return zodiacs, zodiac_fact
+
+ def generate_invalidname_embed(self, zodiac: str) -> discord.Embed:
+ """Returns error embed."""
+ embed = discord.Embed()
+ embed.color = Colours.soft_red
+ error_msg = f"**{zodiac}** is not a valid zodiac sign, here is the list of valid zodiac signs.\n"
+ names = list(self.zodiac_fact)
+ middle_index = len(names) // 2
+ first_half_names = ", ".join(names[:middle_index])
+ second_half_names = ", ".join(names[middle_index:])
+ embed.description = error_msg + first_half_names + ",\n" + second_half_names
+ log.info("Invalid zodiac name provided.")
+ return embed
+
+ def zodiac_build_embed(self, zodiac: str) -> discord.Embed:
+ """Gives informative zodiac embed."""
+ zodiac = zodiac.capitalize()
+ embed = discord.Embed()
+ embed.color = Colours.pink
+ if zodiac in self.zodiac_fact:
+ log.trace("Making zodiac embed.")
+ embed.title = f"__{zodiac}__"
+ embed.description = self.zodiac_fact[zodiac]["About"]
+ embed.add_field(name='__Motto__', value=self.zodiac_fact[zodiac]["Motto"], inline=False)
+ embed.add_field(name='__Strengths__', value=self.zodiac_fact[zodiac]["Strengths"], inline=False)
+ embed.add_field(name='__Weaknesses__', value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False)
+ embed.add_field(name='__Full form__', value=self.zodiac_fact[zodiac]["full_form"], inline=False)
+ embed.set_thumbnail(url=self.zodiac_fact[zodiac]["url"])
+ else:
+ embed = self.generate_invalidname_embed(zodiac)
+ log.trace("Successfully created zodiac information embed.")
+ return embed
+
+ def zodiac_date_verifier(self, query_date: datetime) -> str:
+ """Returns zodiac sign by checking date."""
+ for zodiac_name, zodiac_data in self.zodiac_fact.items():
+ if zodiac_data["start_at"].date() <= query_date.date() <= zodiac_data["end_at"].date():
+ log.trace("Zodiac name sent.")
+ return zodiac_name
+
+ @commands.group(name='zodiac', invoke_without_command=True)
+ async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:
+ """Provides information about zodiac sign by taking zodiac sign name as input."""
+ final_embed = self.zodiac_build_embed(zodiac_sign)
+ await ctx.send(embed=final_embed)
+ log.trace("Embed successfully sent.")
+
+ @zodiac.command(name="date")
+ async def date_and_month(self, ctx: commands.Context, date: int, month: Union[int, str]) -> None:
+ """Provides information about zodiac sign by taking month and date as input."""
+ if isinstance(month, str):
+ month = month.capitalize()
+ try:
+ month = list(calendar.month_abbr).index(month[:3])
+ log.trace('Valid month name entered by user')
+ except ValueError:
+ log.info('Invalid month name entered by user')
+ await ctx.send(f"Sorry, but `{month}` is not a valid month name.")
+ return
+ if (month == 1 and 1 <= date <= 19) or (month == 12 and 22 <= date <= 31):
+ zodiac = "capricorn"
+ final_embed = self.zodiac_build_embed(zodiac)
+ else:
+ try:
+ zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date))
+ log.trace("zodiac sign based on month and date received.")
+ except ValueError as e:
+ final_embed = discord.Embed()
+ final_embed.color = Colours.soft_red
+ final_embed.description = f"Zodiac sign could not be found because.\n```{e}```"
+ log.info(f'Error in "zodiac date" command:\n{e}.')
+ else:
+ final_embed = self.zodiac_build_embed(zodiac_sign_based_on_date)
+
+ await ctx.send(embed=final_embed)
+ log.trace("Embed from date successfully sent.")
+
+ @zodiac.command(name="partnerzodiac", aliases=['partner'])
+ async def partner_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:
+ """Provides a random counter compatible zodiac sign to the given user's zodiac sign."""
+ embed = discord.Embed()
+ embed.color = Colours.pink
+ zodiac_check = self.zodiacs.get(zodiac_sign.capitalize())
+ if zodiac_check:
+ compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.capitalize()])
+ emoji1 = random.choice(HEART_EMOJIS)
+ emoji2 = random.choice(HEART_EMOJIS)
+ embed.title = "Zodiac Compatibility"
+ embed.description = (
+ f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n'
+ f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}'
+ )
+ embed.add_field(
+ name=f'A letter from Dr.Zodiac {LETTER_EMOJI}',
+ value=compatible_zodiac['description']
+ )
+ else:
+ embed = self.generate_invalidname_embed(zodiac_sign)
await ctx.send(embed=embed)
+ log.trace("Embed from date successfully sent.")
def setup(bot: commands.Bot) -> None:
diff --git a/bot/resources/easter/save_the_planet.json b/bot/resources/easter/save_the_planet.json
new file mode 100644
index 00000000..f22261b7
--- /dev/null
+++ b/bot/resources/easter/save_the_planet.json
@@ -0,0 +1,77 @@
+[
+ {
+ "title": "Choose renewable energy",
+ "image": {"url": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2019/07/23/851602-renewable-energy-istock-072419.jpg"},
+ "footer": {"text": "Help out by sharing this information!"},
+ "fields": [
+ {
+ "name": "The problem",
+ "value": "Getting energy from oil or fossil fuels isn't a good idea, because there is only so much of it.",
+ "inline": false
+ },
+
+ {
+ "name": "What you can do",
+ "value": "Use renewable energy, such as wind, solar, and hydro, because it is healthier and is not a finite resource!",
+ "inline": false
+ }
+ ]
+ },
+
+ {
+ "title": "Save the trees!",
+ "image": {"url": "https://www.thecollegesolution.com/wp-content/uploads/2014/07/crumpled-paper-1.jpg"},
+ "footer": {"text": "Help out by sharing this information!"},
+ "fields": [
+ {
+ "name": "The problem",
+ "value": "We often waste trees on making paper, and just getting rid of them for no good reason.",
+ "inline": false
+ },
+
+ {
+ "name": "What you can do",
+ "value": "Make sure you only use paper when absolutely necessary. When you do, make sure to use recycled paper because making new paper causes pollution. Find ways to plant trees (Hacktober Fest!) to combat losing them.",
+ "inline": false
+ }
+ ]
+ },
+
+ {
+ "title": "Less time in the car!",
+ "image": {"url": "https://www.careeraddict.com/uploads/article/55294/businessman-riding-bike.jpg"},
+ "footer": {"text": "Help out by sharing this information!"},
+ "fields": [
+ {
+ "name": "The problem",
+ "value": "Every mile you drive to work produces about a pound of C0₂. That's crazy! What's crazier is how clean the planet could be if we spent less time in the car!",
+ "inline": false
+ },
+
+ {
+ "name": "What you can do",
+ "value": "Instead of using your car, ride your bike if possible! Not only does it save that pound of C0₂, it is also great exercise and is cheaper!",
+ "inline": false
+ }
+ ]
+ },
+
+ {
+ "title":"Paint your roof white!",
+ "image": {"url": "https://modernize.com/wp-content/uploads/2016/10/Cool-roof.jpg"},
+ "footer": {"text":"Help out by sharing this information!"},
+ "fields": [
+ {
+ "name": "The problem",
+ "value": "People with dark roofs often spend 20 to 40% more on their electricity bills because of the extra heat, which means more electricity needs to be made, and a lot of it isn't renewable.",
+ "inline": false
+ },
+
+ {
+ "name":"What you can do",
+ "value": "Having a light colored roof will save you money, and also researchers at the Lawrence Berkeley National Laboratory estimated that if 80 percent of roofs in tropical and temperate climate areas were painted white, it could offset the greenhouse gas emissions of 300 million automobiles around the world.",
+ "inline": false
+ }
+ ]
+ }
+]
diff --git a/bot/resources/valentines/zodiac_compatibility.json b/bot/resources/valentines/zodiac_compatibility.json
index 3971d40d..ea9a7b37 100644
--- a/bot/resources/valentines/zodiac_compatibility.json
+++ b/bot/resources/valentines/zodiac_compatibility.json
@@ -1,5 +1,5 @@
{
- "aries":[
+ "Aries":[
{
"Zodiac" : "Sagittarius",
"description" : "The Archer is one of the most compatible signs Aries should consider when searching out relationships that will bear fruit. Sagittarians share a certain love of freedom with Aries that will help the two of them conquer new territory together.",
@@ -21,7 +21,7 @@
"compatibility_score" : "74%"
}
],
- "taurus":[
+ "Taurus":[
{
"Zodiac" : "Virgo",
"description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.",
@@ -43,7 +43,7 @@
"compatibility_score" : "91%"
}
],
- "gemini":[
+ "Gemini":[
{
"Zodiac" : "Aries",
"description" : "The theorem of astrology says that Aries and Gemini have a zero tolerance for boredom and will at once get rid of anything dull. An Arian will let a Geminian enjoy his personal freedom and the Gemini will respect his individuality.",
@@ -65,7 +65,7 @@
"compatibility_score" : "91%"
}
],
- "cancer":[
+ "Cancer":[
{
"Zodiac" : "Taurus",
"description" : "The Cancer Taurus zodiac relationship compatibility is strong because of their mutual love for safety, stability, and comfort. Their mutual understanding will always be powerful, which will be the pillar of strength of their relationship.",
@@ -82,7 +82,7 @@
"compatibility_score" : "77%"
}
],
- "leo":[
+ "Leo":[
{
"Zodiac" : "Aries",
"description" : "A Leo is generous and an Arian is open to life. Sharing the same likes and dislikes, they both crave for fun, romance and excitement. A Leo respects an Arian's need for freedom because an Arian does not interfere much in the life of a Leo. Aries will love the charisma and ideas of the Leo.",
@@ -104,7 +104,7 @@
"compatibility_score" : "75%"
}
],
- "virgo":[
+ "Virgo":[
{
"Zodiac" : "Taurus",
"description" : "Although these signs have their set of differences, the Virgo Taurus compatibility is usually pretty strong. This is because both the signs want the same thing ultimately and have generally synchronous ways of reaching those points. This helps them complement each other and create a healthy relationship between them.",
@@ -126,7 +126,7 @@
"compatibility_score" : "77%"
}
],
- "libra":[
+ "Libra":[
{
"Zodiac" : "Leo",
"description" : "Libra and Leo love match can work well for both the partners and truly help them learn from each other and grow individually, as well as together. Libra and Leo, when in the right frame of mind, form a formidable couple that attracts admiration and respect everywhere it goes.",
@@ -148,7 +148,7 @@
"compatibility_score" : "71%"
}
],
- "scorpio":[
+ "Scorpio":[
{
"Zodiac" : "Cancer",
"description" : "This union is not unusual, but will take a fair share of work in the start. A strong foundation of clear cut communication is mandatory to make this a loving and stress free relationship!",
@@ -170,7 +170,7 @@
"compatibility_score" : "81%"
}
],
- "sagittarius":[
+ "Sagittarius":[
{
"Zodiac" : "Aries",
"description" : "Sagittarius and Aries can make a very compatible pair. Their relationship will have a lot of passion, enthusiasm, and energy. These are very good traits to make their relationship deeper and stronger. Both Aries and Sagittarius will enjoy each other's company and their energy level rises as the relationship grows. Both will support and help in fighting hardships and failures.",
@@ -192,7 +192,7 @@
"compatibility_score" : "83%"
}
],
- "capricorn":[
+ "Capricorn":[
{
"Zodiac" : "Taurus",
"description" : "This is one of the most grounded and reliable bonds of the zodiac chart. If Capricorn and Taurus do find a way to handle their minor issues, they have a good chance of making it together and that too, in a happy, peaceful, and healthy relationship.",
@@ -214,7 +214,7 @@
"compatibility_score" : "76%"
}
],
- "aquarius":[
+ "Aquarius":[
{
"Zodiac" : "Aries",
"description" : "The relationship of Aries and Aquarius is very exciting, adventurous and interesting. They will enjoy each other's company as both of them love fun and freedom.This is a couple that lacks tenderness. They are not two brutes who let their relationship fade as soon as their passion does.",
@@ -236,7 +236,7 @@
"compatibility_score" : "83%"
}
],
- "pisces":[
+ "Pisces":[
{
"Zodiac" : "Taurus",
"description" : "This relationship will survive the test of time if both parties involved have unbreakable trust in each other and nurture that connection they have painstakingly built over the years. They must remember to be honest and committed to their partner through all times.If natural communication flows between them like clockwork, this will be a beautiful love story with a prominent tag of ‘happily-ever-after’ pinned right to it!",
diff --git a/bot/resources/valentines/zodiac_explanation.json b/bot/resources/valentines/zodiac_explanation.json
new file mode 100644
index 00000000..33864ea5
--- /dev/null
+++ b/bot/resources/valentines/zodiac_explanation.json
@@ -0,0 +1,122 @@
+{
+ "Aries": {
+ "start_at": "2020-03-21",
+ "end_at": "2020-04-19",
+ "About": "Amazing people born between **March 21** to **April 19**. Aries loves to be number one, so it\u2019s no surprise that these audacious rams are the first sign of the zodiac. Bold and ambitious, Aries dives headfirst into even the most challenging situations.",
+ "Motto": "***\u201cWhen you know yourself, you're empowered. When you accept yourself, you're invincible.\u201d***",
+ "Strengths": "Courageous, determined, confident, enthusiastic, optimistic, honest, passionate.",
+ "Weaknesses": "Impatient, moody, short-tempered, impulsive, aggressive.",
+ "full_form": "__**A**__ssertive, __**R**__efreshing, __**I**__ndependent, __**E**__nergetic, __**S**__exy",
+ "url": "https://www.horoscope.com/images-US/signs/profile-aries.png"
+ },
+ "Taurus": {
+ "start_at": "2020-04-20",
+ "end_at": "2020-05-20",
+ "About": "Amazing people born between **April 20** to **May 20**. Taurus is an earth sign represented by the bull. Like their celestial spirit animal, Taureans enjoy relaxing in serene, bucolic environments surrounded by soft sounds, soothing aromas, and succulent flavors",
+ "Motto": "***\u201cNothing worth having comes easy.\u201d***",
+ "Strengths": "Reliable, patient, practical, devoted, responsible, stable.",
+ "Weaknesses": "Stubborn, possessive, uncompromising.",
+ "full_form": "__**T**__railblazing, __**A**__mbitious, __**U**__nwavering, __**R**__eliable, __**U**__nderstanding, __**S**__table",
+ "url": "https://www.horoscope.com/images-US/signs/profile-taurus.png"
+ },
+ "Gemini": {
+ "start_at": "2020-05-21",
+ "end_at": "2020-06-20",
+ "About": "Amazing people born between **May 21** to **June 20**. Have you ever been so busy that you wished you could clone yourself just to get everything done? That\u2019s the Gemini experience in a nutshell. Appropriately symbolized by the celestial twins, this air sign was interested in so many pursuits that it had to double itself.",
+ "Motto": "***\u201cI manifest my reality.\u201d***",
+ "Strengths": "Gentle, affectionate, curious, adaptable, ability to learn quickly and exchange ideas.",
+ "Weaknesses": "Nervous, inconsistent, indecisive.",
+ "full_form": "__**G**__enerous, __**E**__motionally in tune, __**M**__otivated, __**I**__maginative, __**N**__ice, __**I**__ntelligent",
+ "url": "https://www.horoscope.com/images-US/signs/profile-gemini.png"
+ },
+ "Cancer": {
+ "start_at": "2020-06-21",
+ "end_at": "2020-07-22",
+ "About": "Amazing people born between **June 21 ** to **July 22**. Cancer is a cardinal water sign. Represented by the crab, this crustacean seamlessly weaves between the sea and shore representing Cancer\u2019s ability to exist in both emotional and material realms. Cancers are highly intuitive and their psychic abilities manifest in tangible spaces: For instance, Cancers can effortlessly pick up the energies in a room.",
+ "Motto": "***\u201cI feel, therefore I am.\u201d***",
+ "Strengths": "Tenacious, highly imaginative, loyal, emotional, sympathetic, persuasive.",
+ "Weaknesses": "Moody, pessimistic, suspicious, manipulative, insecuremoody, pessimistic, suspicious, manipulative, insecure.",
+ "full_form": "__**C**__aring, __**A**__mbitious, __**N**__ourishing, __**C**__reative, __**E**__motionally intelligent, __**R**__esilient",
+ "url": "https://www.horoscope.com/images-US/signs/profile-cancer.png"
+ },
+ "Leo": {
+ "start_at": "2020-07-23",
+ "end_at": "2020-08-22",
+ "About": "Amazing people born between **July 23** to **August 22**. Roll out the red carpet because Leo has arrived. Leo is represented by the lion and these spirited fire signs are the kings and queens of the celestial jungle. They\u2019re delighted to embrace their royal status: Vivacious, theatrical, and passionate, Leos love to bask in the spotlight and celebrate themselves.",
+ "Motto": "***\u201cIf you know the way, go the way and show the way\u2014you're a leader.\u201d***",
+ "Strengths": "Creative, passionate, generous, warm-hearted, cheerful, humorous.",
+ "Weaknesses": "Arrogant, stubborn, self-centered, lazy, inflexible.",
+ "full_form": "__**L**__eaders, __**E**__nergetic, __**O**__ptimistic",
+ "url": "https://www.horoscope.com/images-US/signs/profile-leo.png"
+ },
+ "Virgo": {
+ "start_at": "2020-08-23",
+ "end_at": "2020-09-22",
+ "About": "Amazing people born between **August 23** to **September 22**. Virgo is an earth sign historically represented by the goddess of wheat and agriculture, an association that speaks to Virgo\u2019s deep-rooted presence in the material world. Virgos are logical, practical, and systematic in their approach to life. This earth sign is a perfectionist at heart and isn\u2019t afraid to improve skills through diligent and consistent practice.",
+ "Motto": "***\u201cMy best can always be better.\u201d***",
+ "Strengths": "Loyal, analytical, kind, hardworking, practical.",
+ "Weaknesses": "Shyness, worry, overly critical of self and others, all work and no play.",
+ "full_form": "__**V**__irtuous, __**I**__ntelligent, __**R**__esponsible, __**G**__enerous, __**O**__ptimistic",
+ "url": "https://www.horoscope.com/images-US/signs/profile-virgo.png"
+ },
+ "Libra": {
+ "start_at": "2020-09-23",
+ "end_at": "2020-10-22",
+ "About": "Amazing people born between **September 23** to **October 22**. Libra is an air sign represented by the scales (interestingly, the only inanimate object of the zodiac), an association that reflects Libra's fixation on balance and harmony. Libra is obsessed with symmetry and strives to create equilibrium in all areas of life.",
+ "Motto": "***\u201cNo person is an island.\u201d***",
+ "Strengths": "Cooperative, diplomatic, gracious, fair-minded, social.",
+ "Weaknesses": "Indecisive, avoids confrontations, will carry a grudge, self-pity.",
+ "full_form": "__**L**__oyal, __**I**__nquisitive, __**B**__alanced, __**R**__esponsible, __**A**__ltruistic",
+ "url": "https://www.horoscope.com/images-US/signs/profile-libra.png"
+ },
+ "Scorpio": {
+ "start_at": "2020-10-23",
+ "end_at": "2020-11-21",
+ "About": "Amazing people born between **October 23** to **November 21**. Scorpio is one of the most misunderstood signs of the zodiac. Because of its incredible passion and power, Scorpio is often mistaken for a fire sign. In fact, Scorpio is a water sign that derives its strength from the psychic, emotional realm.",
+ "Motto": "***\u201cYou never know what you are capable of until you try.\u201d***",
+ "Strengths": "Resourceful, brave, passionate, stubborn, a true friend.",
+ "Weaknesses": "Distrusting, jealous, secretive, violent.",
+ "full_form": "__**S**__eductive, __**C**__erebral, __**O**__riginal, __**R**__eactive, __**P**__assionate, __**I**__ntuitive, __**O**__utstanding",
+ "url": "https://www.horoscope.com/images-US/signs/profile-scorpio.png"
+ },
+ "Sagittarius": {
+ "start_at": "2020-11-22",
+ "end_at": "2020-12-21",
+ "About": "Amazing people born between **November 22** to **December 21**. Represented by the archer, Sagittarians are always on a quest for knowledge. The last fire sign of the zodiac, Sagittarius launches its many pursuits like blazing arrows, chasing after geographical, intellectual, and spiritual adventures.",
+ "Motto": "***\u201cTowering genius disdains a beaten path.\u201d***",
+ "Strengths": "Generous, idealistic, great sense of humor.",
+ "Weaknesses": "Promises more than can deliver, very impatient, will say anything no matter how undiplomatic.",
+ "full_form": "__**S**__eductive, __**A**__dventurous, __**G**__rateful, __**I**__ntelligent, __**T**__railblazing, __**T**__enacious adept, __**A**__dept, __**R**__esponsible, __**I**__dealistic, __**U**__nparalled, __**S**__ophisticated",
+ "url": "https://www.horoscope.com/images-US/signs/profile-sagittarius.png"
+ },
+ "Capricorn": {
+ "start_at": "2020-12-22",
+ "end_at": "2021-01-19",
+ "About": "Amazing people born between **December 22** to **January 19**. The last earth sign of the zodiac, Capricorn is represented by the sea goat, a mythological creature with the body of a goat and tail of a fish. Accordingly, Capricorns are skilled at navigating both the material and emotional realms.",
+ "Motto": "***\u201cI can succeed at anything I put my mind to.\u201d***",
+ "Strengths": "Responsible, disciplined, self-control, good managers.",
+ "Weaknesses": "Know-it-all, unforgiving, condescending, expecting the worst.",
+ "full_form": "__**C**__onfident, __**A**__nalytical, __**P**__ractical, __**R**__esponsible, __**I**__ntelligent, __**C**__aring, __**O**__rganized, __**R**__ealistic, __**N**__eat",
+ "url": "https://www.horoscope.com/images-US/signs/profile-capricorn.png"
+ },
+ "Aquarius": {
+ "start_at": "2020-01-20",
+ "end_at": "2020-02-18",
+ "About": "Amazing people born between **January 20** to **February 18**. Despite the \u201caqua\u201d in its name, Aquarius is actually the last air sign of the zodiac. Aquarius is represented by the water bearer, the mystical healer who bestows water, or life, upon the land. Accordingly, Aquarius is the most humanitarian astrological sign.",
+ "Motto": "***\u201cThere is no me, there is only we.\u201d***",
+ "Strengths": "Progressive, original, independent, humanitarian.",
+ "Weaknesses": "Runs from emotional expression, temperamental, uncompromising, aloof.",
+ "full_form": "__**A**__nalytical, __**Q**__uirky, __**U**__ncompromising, __**A**__ction-focused, __**R**__espectful, __**I**__ntelligent, __**U**__nique, __**S**__incere",
+ "url": "https://www.horoscope.com/images-US/signs/profile-aquarius.png"
+ },
+ "Pisces": {
+ "start_at": "2020-02-19",
+ "end_at": "2020-03-20",
+ "About": "Amazing people born between **February 19** to **March 20**. Pisces, a water sign, is the last constellation of the zodiac. It's symbolized by two fish swimming in opposite directions, representing the constant division of Pisces' attention between fantasy and reality. As the final sign, Pisces has absorbed every lesson \u2014 the joys and the pain, the hopes and the fears \u2014 learned by all of the other signs.",
+ "Motto": "***\u201cI have a lot of love to give, it only takes a little patience and those worth giving it all to.\u201d***",
+ "Strengths": "Compassionate, artistic, intuitive, gentle, wise, musical.",
+ "Weaknesses": "Fearful, overly trusting, sad, desire to escape reality, can be a victim or a martyr.",
+ "full_form": "__**P**__sychic, __**I**__ntelligent, __**S**__urprising, __**C**__reative, __**E**__motionally-driven, __**S**__ensitive",
+ "url": "https://www.horoscope.com/images-US/signs/profile-pisces.png"
+ }
+}
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
new file mode 100644
index 00000000..3031a271
--- /dev/null
+++ b/bot/utils/checks.py
@@ -0,0 +1,164 @@
+import datetime
+import logging
+from typing import Callable, Container, Iterable, Optional
+
+from discord.ext.commands import (
+ BucketType,
+ CheckFailure,
+ Cog,
+ Command,
+ CommandOnCooldown,
+ Context,
+ Cooldown,
+ CooldownMapping,
+)
+
+from bot import constants
+
+log = logging.getLogger(__name__)
+
+
+class InWhitelistCheckFailure(CheckFailure):
+ """Raised when the `in_whitelist` check fails."""
+
+ def __init__(self, redirect_channel: Optional[int]) -> None:
+ self.redirect_channel = redirect_channel
+
+ if redirect_channel:
+ redirect_message = f" here. Please use the <#{redirect_channel}> channel instead"
+ else:
+ redirect_message = ""
+
+ error_message = f"You are not allowed to use that command{redirect_message}."
+
+ super().__init__(error_message)
+
+
+def in_whitelist_check(
+ ctx: Context,
+ channels: Container[int] = (),
+ categories: Container[int] = (),
+ roles: Container[int] = (),
+ redirect: Optional[int] = constants.Channels.seasonalbot_commands,
+ fail_silently: bool = False,
+) -> bool:
+ """
+ Check if a command was issued in a whitelisted context.
+
+ The whitelists that can be provided are:
+
+ - `channels`: a container with channel ids for whitelisted channels
+ - `categories`: a container with category ids for whitelisted categories
+ - `roles`: a container with with role ids for whitelisted roles
+
+ If the command was invoked in a context that was not whitelisted, the member is either
+ redirected to the `redirect` channel that was passed (default: #bot-commands) or simply
+ told that they're not allowed to use this particular command (if `None` was passed).
+ """
+ if redirect and redirect not in channels:
+ # It does not make sense for the channel whitelist to not contain the redirection
+ # channel (if applicable). That's why we add the redirection channel to the `channels`
+ # container if it's not already in it. As we allow any container type to be passed,
+ # we first create a tuple in order to safely add the redirection channel.
+ #
+ # Note: It's possible for the redirect channel to be in a whitelisted category, but
+ # there's no easy way to check that and as a channel can easily be moved in and out of
+ # categories, it's probably not wise to rely on its category in any case.
+ channels = tuple(channels) + (redirect,)
+
+ if channels and ctx.channel.id in channels:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.")
+ return True
+
+ # Only check the category id if we have a category whitelist and the channel has a `category_id`
+ if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.")
+ return True
+
+ # Only check the roles whitelist if we have one and ensure the author's roles attribute returns
+ # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there).
+ if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())):
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.")
+ return True
+
+ log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.")
+
+ # Some commands are secret, and should produce no feedback at all.
+ if not fail_silently:
+ raise InWhitelistCheckFailure(redirect)
+ return False
+
+
+def with_role_check(ctx: Context, *role_ids: int) -> bool:
+ """Returns True if the user has any one of the roles in role_ids."""
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
+ "This command is restricted by the with_role decorator. Rejecting request.")
+ return False
+
+ for role in ctx.author.roles:
+ if role.id in role_ids:
+ log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")
+ return True
+
+ log.trace(f"{ctx.author} does not have the required role to use "
+ f"the '{ctx.command.name}' command, so the request is rejected.")
+ return False
+
+
+def without_role_check(ctx: Context, *role_ids: int) -> bool:
+ """Returns True if the user does not have any of the roles in role_ids."""
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
+ "This command is restricted by the without_role decorator. Rejecting request.")
+ return False
+
+ author_roles = [role.id for role in ctx.author.roles]
+ check = all(role not in author_roles for role in role_ids)
+ log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the without_role check was {check}.")
+ return check
+
+
+def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *,
+ bypass_roles: Iterable[int]) -> Callable:
+ """
+ Applies a cooldown to a command, but allows members with certain roles to be ignored.
+
+ NOTE: this replaces the `Command.before_invoke` callback, which *might* introduce problems in the future.
+ """
+ # Make it a set so lookup is hash based.
+ bypass = set(bypass_roles)
+
+ # This handles the actual cooldown logic.
+ buckets = CooldownMapping(Cooldown(rate, per, type))
+
+ # Will be called after the command has been parse but before it has been invoked, ensures that
+ # the cooldown won't be updated if the user screws up their input to the command.
+ async def predicate(cog: Cog, ctx: Context) -> None:
+ nonlocal bypass, buckets
+
+ if any(role.id in bypass for role in ctx.author.roles):
+ return
+
+ # Cooldown logic, taken from discord.py internals.
+ current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp()
+ bucket = buckets.get_bucket(ctx.message)
+ retry_after = bucket.update_rate_limit(current)
+ if retry_after:
+ raise CommandOnCooldown(bucket, retry_after)
+
+ def wrapper(command: Command) -> Command:
+ # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it
+ # so I just made it raise an error when the decorator is applied before the actual command object exists.
+ #
+ # If the `before_invoke` detail is ever a problem then I can quickly just swap over.
+ if not isinstance(command, Command):
+ raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. '
+ 'This means it has to be above the command decorator in the code.')
+
+ command._before_invoke = predicate
+
+ return command
+
+ return wrapper
diff --git a/bot/utils/converters.py b/bot/utils/converters.py
new file mode 100644
index 00000000..228714c9
--- /dev/null
+++ b/bot/utils/converters.py
@@ -0,0 +1,16 @@
+import discord
+from discord.ext.commands.converter import MessageConverter
+
+
+class WrappedMessageConverter(MessageConverter):
+ """A converter that handles embed-suppressed links like <http://example.com>."""
+
+ async def convert(self, ctx: discord.ext.commands.Context, argument: str) -> discord.Message:
+ """Wrap the commands.MessageConverter to handle <> delimited message links."""
+ # It's possible to wrap a message in [<>] as well, and it's supported because its easy
+ if argument.startswith("[") and argument.endswith("]"):
+ argument = argument[1:-1]
+ if argument.startswith("<") and argument.endswith(">"):
+ argument = argument[1:-1]
+
+ return await super().convert(ctx, argument)
diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py
new file mode 100644
index 00000000..50350ea8
--- /dev/null
+++ b/bot/utils/extensions.py
@@ -0,0 +1,34 @@
+import importlib
+import inspect
+import pkgutil
+from typing import Iterator, NoReturn
+
+from bot import exts
+
+
+def unqualify(name: str) -> str:
+ """Return an unqualified name given a qualified module/package `name`."""
+ return name.rsplit(".", maxsplit=1)[-1]
+
+
+def walk_extensions() -> Iterator[str]:
+ """Yield extension names from the bot.exts subpackage."""
+
+ def on_error(name: str) -> NoReturn:
+ raise ImportError(name=name) # pragma: no cover
+
+ for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error):
+ if unqualify(module.name).startswith("_"):
+ # Ignore module/package names starting with an underscore.
+ continue
+
+ if module.ispkg:
+ imported = importlib.import_module(module.name)
+ if not inspect.isfunction(getattr(imported, "setup", None)):
+ # If it lacks a setup function, it's not an extension.
+ continue
+
+ yield module.name
+
+
+EXTENSIONS = frozenset(walk_extensions())