aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ks129 <[email protected]>2021-03-02 09:13:51 +0200
committerGravatar GitHub <[email protected]>2021-03-02 09:13:51 +0200
commitc4dc39a32a362b805e8cb60ea71f988711016a19 (patch)
tree7a49b9bec8c7d0029edb69beb12609af1f46f1c0
parentFixed linting errors. (diff)
parentMerge pull request #600 from Shivansh-007/feature/improve-wiki (diff)
Merge branch 'master' into startup-channel-check
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock143
-rw-r--r--bot/__main__.py5
-rw-r--r--bot/constants.py45
-rw-r--r--bot/exts/christmas/advent_of_code/_cog.py20
-rw-r--r--bot/exts/evergreen/cheatsheet.py107
-rw-r--r--bot/exts/evergreen/connect_four.py450
-rw-r--r--bot/exts/evergreen/conversationstarters.py4
-rw-r--r--bot/exts/evergreen/error_handler.py9
-rw-r--r--bot/exts/evergreen/issues.py162
-rw-r--r--bot/exts/evergreen/status_cats.py33
-rw-r--r--bot/exts/evergreen/status_codes.py71
-rw-r--r--bot/exts/evergreen/tic_tac_toe.py323
-rw-r--r--bot/exts/evergreen/wikipedia.py164
-rw-r--r--bot/exts/evergreen/wolfram.py11
-rw-r--r--bot/exts/evergreen/xkcd.py2
-rw-r--r--bot/exts/halloween/hacktoberstats.py8
-rw-r--r--bot/exts/valentines/be_my_valentine.py80
-rw-r--r--bot/exts/valentines/lovecalculator.py11
-rw-r--r--bot/resources/evergreen/py_topics.yaml53
-rw-r--r--bot/resources/evergreen/starter.yaml11
-rw-r--r--bot/utils/decorators.py119
-rw-r--r--docker-compose.yml8
23 files changed, 1454 insertions, 386 deletions
diff --git a/Pipfile b/Pipfile
index c382902f..e7e01a31 100644
--- a/Pipfile
+++ b/Pipfile
@@ -14,6 +14,7 @@ sentry-sdk = "~=0.19"
PyYAML = "~=5.3.1"
"discord.py" = {extras = ["voice"], version = "~=1.5.1"}
async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"}
+emojis = "~=0.6.0"
[dev-packages]
flake8 = "~=3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
index be6f9574..ec801979 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "9be419062bd9db364ac9dddfcd50aef9c932384b45850363e482591fe7d12403"
+ "sha256": "b4aaaacbab13179145e36d7b86c736db512286f6cce8e513cc30c48d68fe3810"
},
"pipfile-spec": 6,
"requires": {
@@ -103,44 +103,45 @@
},
"cffi": {
"hashes": [
- "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e",
- "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d",
- "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a",
- "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec",
- "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362",
- "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668",
- "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c",
- "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b",
- "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06",
- "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698",
- "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2",
- "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c",
- "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7",
- "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
- "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
- "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
- "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
- "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
- "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
- "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26",
- "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b",
- "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01",
- "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb",
- "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293",
- "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd",
- "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d",
- "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3",
- "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d",
- "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e",
- "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca",
- "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d",
- "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775",
- "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375",
- "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b",
- "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b",
- "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"
- ],
- "version": "==1.14.4"
+ "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813",
+ "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06",
+ "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea",
+ "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee",
+ "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396",
+ "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73",
+ "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315",
+ "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1",
+ "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49",
+ "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892",
+ "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482",
+ "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058",
+ "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5",
+ "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53",
+ "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045",
+ "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3",
+ "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5",
+ "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e",
+ "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c",
+ "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369",
+ "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827",
+ "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053",
+ "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa",
+ "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4",
+ "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322",
+ "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132",
+ "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62",
+ "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa",
+ "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0",
+ "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396",
+ "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e",
+ "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991",
+ "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6",
+ "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1",
+ "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406",
+ "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d",
+ "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"
+ ],
+ "version": "==1.14.5"
},
"chardet": {
"hashes": [
@@ -160,6 +161,14 @@
"index": "pypi",
"version": "==1.5.1"
},
+ "emojis": {
+ "hashes": [
+ "sha256:7da34c8a78ae262fd68cef9e2c78a3c1feb59784489eeea0f54ba1d4b7111c7c",
+ "sha256:bf605d1f1a27a81cd37fe82eb65781c904467f569295a541c33710b97e4225ec"
+ ],
+ "index": "pypi",
+ "version": "==0.6.0"
+ },
"fakeredis": {
"hashes": [
"sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a",
@@ -229,11 +238,11 @@
},
"idna": {
"hashes": [
- "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
- "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16",
+ "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==2.10"
+ "markers": "python_version >= '3.4'",
+ "version": "==3.1"
},
"multidict": {
"hashes": [
@@ -405,11 +414,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0",
- "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f"
+ "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237",
+ "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"
],
"index": "pypi",
- "version": "==0.19.5"
+ "version": "==0.20.3"
},
"six": {
"hashes": [
@@ -428,19 +437,19 @@
},
"soupsieve": {
"hashes": [
- "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851",
- "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"
+ "sha256:407fa1e8eb3458d1b5614df51d9651a1180ea5fedf07feb46e45d7e25e6d6cdd",
+ "sha256:d3a5ea5b350423f47d07639f74475afedad48cf41c0ad7a82ca13a3928af34f6"
],
"markers": "python_version >= '3.0'",
- "version": "==2.1"
+ "version": "==2.2"
},
"urllib3": {
"hashes": [
- "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
- "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
+ "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80",
+ "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
- "version": "==1.26.2"
+ "version": "==1.26.3"
},
"yarl": {
"hashes": [
@@ -514,11 +523,11 @@
},
"flake8-annotations": {
"hashes": [
- "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1",
- "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df"
+ "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055",
+ "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"
],
"index": "pypi",
- "version": "==2.4.1"
+ "version": "==2.5.0"
},
"flake8-bugbear": {
"hashes": [
@@ -576,11 +585,11 @@
},
"identify": {
"hashes": [
- "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5",
- "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"
+ "sha256:de7129142a5c86d75a52b96f394d94d96d497881d2aaf8eafe320cdbe8ac4bcc",
+ "sha256:e0dae57c0397629ce13c289f6ddde0204edf518f557bfdb1e56474aa143e77c3"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.10"
+ "version": "==1.5.14"
},
"mccabe": {
"hashes": [
@@ -606,11 +615,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0",
- "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"
+ "sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e",
+ "sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a"
],
"index": "pypi",
- "version": "==2.9.3"
+ "version": "==2.10.1"
},
"pycodestyle": {
"hashes": [
@@ -665,10 +674,10 @@
},
"snowballstemmer": {
"hashes": [
- "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
- "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
+ "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2",
+ "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"
],
- "version": "==2.0.0"
+ "version": "==2.1.0"
},
"toml": {
"hashes": [
@@ -680,11 +689,11 @@
},
"virtualenv": {
"hashes": [
- "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c",
- "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"
+ "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d",
+ "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.2.2"
+ "version": "==20.4.2"
}
}
}
diff --git a/bot/__main__.py b/bot/__main__.py
index e9b14a53..c6e5fa57 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -6,10 +6,9 @@ from sentry_sdk.integrations.redis import RedisIntegration
from bot.bot import bot
from bot.constants import Client, GIT_SHA, STAFF_ROLES, WHITELISTED_CHANNELS
-from bot.utils.decorators import in_channel_check
+from bot.utils.decorators import whitelist_check
from bot.utils.extensions import walk_extensions
-
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
event_level=logging.WARNING
@@ -26,7 +25,7 @@ sentry_sdk.init(
log = logging.getLogger(__name__)
-bot.add_check(in_channel_check(*WHITELISTED_CHANNELS, bypass_roles=STAFF_ROLES))
+bot.add_check(whitelist_check(channels=WHITELISTED_CHANNELS, roles=STAFF_ROLES))
for ext in walk_extensions():
bot.load_extension(ext)
diff --git a/bot/constants.py b/bot/constants.py
index d63a063e..db34b55a 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -9,6 +9,7 @@ __all__ = (
"AdventOfCode",
"Branding",
"Channels",
+ "Categories",
"Client",
"Colours",
"Emojis",
@@ -100,20 +101,17 @@ class Channels(NamedTuple):
big_brother_logs = 468507907357409333
bot = 267659945086812160
checkpoint_test = 422077681434099723
+ organisation = 551789653284356126
devalerts = 460181980097675264
devlog = int(environ.get("CHANNEL_DEVLOG", 622895325144940554))
dev_contrib = 635950537262759947
dev_branding = 753252897059373066
- help_0 = 303906576991780866
- help_1 = 303906556754395136
- help_2 = 303906514266226689
- help_3 = 439702951246692352
- help_4 = 451312046647148554
- help_5 = 454941769734422538
helpers = 385474242440986624
message_log = 467752170159079424
mod_alerts = 473092532147060736
modlog = 282638479504965634
+ mod_meta = 775412552795947058
+ mod_tools = 775413915391098921
off_topic_0 = 291284109232308226
off_topic_1 = 463035241142026251
off_topic_2 = 463035268514185226
@@ -126,6 +124,15 @@ class Channels(NamedTuple):
hacktoberfest_2020 = 760857070781071431
voice_chat_0 = 412357430186344448
voice_chat_1 = 799647045886541885
+ staff_voice = 541638762007101470
+
+
+class Categories(NamedTuple):
+ help_in_use = 696958401460043776
+ development = 411199786025484308
+ devprojects = 787641585624940544
+ media = 799054581991997460
+ staff = 364918151625965579
class Client(NamedTuple):
@@ -160,6 +167,7 @@ class Emojis:
envelope = "\U0001F4E8"
trashcan = "<:trashcan:637136429717389331>"
ok_hand = ":ok_hand:"
+ hand_raised = "\U0001f64b"
dice_1 = "<:dice_1:755891608859443290>"
dice_2 = "<:dice_2:755891608741740635>"
@@ -174,6 +182,25 @@ class Emojis:
pull_request_closed = "<:PRClosed:629695470519713818>"
merge = "<:PRMerged:629695470570176522>"
+ number_emojis = {
+ 1: "\u0031\ufe0f\u20e3",
+ 2: "\u0032\ufe0f\u20e3",
+ 3: "\u0033\ufe0f\u20e3",
+ 4: "\u0034\ufe0f\u20e3",
+ 5: "\u0035\ufe0f\u20e3",
+ 6: "\u0036\ufe0f\u20e3",
+ 7: "\u0037\ufe0f\u20e3",
+ 8: "\u0038\ufe0f\u20e3",
+ 9: "\u0039\ufe0f\u20e3"
+ }
+
+ confirmation = "\u2705"
+ decline = "\u274c"
+ incident_unactioned = "<:incident_unactioned:719645583245180960>"
+
+ x = "\U0001f1fd"
+ o = "\U0001f1f4"
+
status_online = "<:status_online:470326272351010816>"
status_idle = "<:status_idle:470326266625785866>"
status_dnd = "<:status_dnd:470326272082313216>"
@@ -221,7 +248,6 @@ class Roles(NamedTuple):
announcements = 463658397560995840
champion = 430492892331769857
contributor = 295488872404484098
- developer = 352427296948486144
devops = 409416496733880320
jammer = 423054537079783434
moderator = 267629731250176001
@@ -232,6 +258,7 @@ class Roles(NamedTuple):
rockstars = 458226413825294336
core_developers = 587606783669829632
events_lead = 778361735739998228
+ everyone_role = 267624335836053506
class Tokens(NamedTuple):
@@ -259,10 +286,6 @@ class RedisConfig(NamedTuple):
use_fakeredis = environ.get("USE_FAKEREDIS", "false").lower() == "true"
-class Wikipedia:
- total_chance = 3
-
-
class Source:
github = "https://github.com/python-discord/sir-lancebot"
github_avatar_url = "https://avatars1.githubusercontent.com/u/9919"
diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py
index c3b87f96..466edd48 100644
--- a/bot/exts/christmas/advent_of_code/_cog.py
+++ b/bot/exts/christmas/advent_of_code/_cog.py
@@ -11,7 +11,7 @@ from bot.constants import (
AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS,
)
from bot.exts.christmas.advent_of_code import _helpers
-from bot.utils.decorators import InChannelCheckFailure, in_month, override_in_channel, with_role
+from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role
log = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ class AdventOfCode(commands.Cog):
self.status_task.add_done_callback(_helpers.background_task_callback)
@commands.group(name="adventofcode", aliases=("aoc",))
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def adventofcode_group(self, ctx: commands.Context) -> None:
"""All of the Advent of Code commands."""
if not ctx.invoked_subcommand:
@@ -61,7 +61,7 @@ class AdventOfCode(commands.Cog):
aliases=("sub", "notifications", "notify", "notifs"),
brief="Notifications for new days"
)
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_subscribe(self, ctx: commands.Context) -> None:
"""Assign the role for notifications about new days being ready."""
current_year = datetime.now().year
@@ -82,7 +82,7 @@ class AdventOfCode(commands.Cog):
@in_month(Month.DECEMBER)
@adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_unsubscribe(self, ctx: commands.Context) -> None:
"""Remove the role for notifications about new days being ready."""
role = ctx.guild.get_role(AocConfig.role_id)
@@ -94,7 +94,7 @@ class AdventOfCode(commands.Cog):
await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.")
@adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def aoc_countdown(self, ctx: commands.Context) -> None:
"""Return time left until next day."""
if not _helpers.is_in_advent():
@@ -123,13 +123,13 @@ class AdventOfCode(commands.Cog):
await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.")
@adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def about_aoc(self, ctx: commands.Context) -> None:
"""Respond with an explanation of all things Advent of Code."""
await ctx.send("", embed=self.cached_about_aoc)
@adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")
- @override_in_channel(AOC_WHITELIST)
+ @whitelist_override(channels=AOC_WHITELIST)
async def join_leaderboard(self, ctx: commands.Context) -> None:
"""DM the user the information for joining the Python Discord leaderboard."""
current_year = datetime.now().year
@@ -178,7 +178,7 @@ class AdventOfCode(commands.Cog):
aliases=("board", "lb"),
brief="Get a snapshot of the PyDis private AoC leaderboard",
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def aoc_leaderboard(self, ctx: commands.Context) -> None:
"""Get the current top scorers of the Python Discord Leaderboard."""
async with ctx.typing():
@@ -203,7 +203,7 @@ class AdventOfCode(commands.Cog):
aliases=("globalboard", "gb"),
brief="Get a link to the global leaderboard",
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def aoc_global_leaderboard(self, ctx: commands.Context) -> None:
"""Get a link to the global Advent of Code leaderboard."""
url = self.global_leaderboard_url
@@ -219,7 +219,7 @@ class AdventOfCode(commands.Cog):
aliases=("dailystats", "ds"),
brief="Get daily statistics for the Python Discord leaderboard"
)
- @override_in_channel(AOC_WHITELIST_RESTRICTED)
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
"""Send an embed with daily completion statistics for the Python Discord leaderboard."""
try:
diff --git a/bot/exts/evergreen/cheatsheet.py b/bot/exts/evergreen/cheatsheet.py
new file mode 100644
index 00000000..3fe709d5
--- /dev/null
+++ b/bot/exts/evergreen/cheatsheet.py
@@ -0,0 +1,107 @@
+import random
+import re
+import typing as t
+from urllib.parse import quote_plus
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import BucketType, Context
+
+from bot import constants
+from bot.constants import Categories, Channels, Colours, ERROR_REPLIES
+from bot.utils.decorators import whitelist_override
+
+ERROR_MESSAGE = f"""
+Unknown cheat sheet. Please try to reformulate your query.
+
+**Examples**:
+```md
+{constants.Client.prefix}cht read json
+{constants.Client.prefix}cht hello world
+{constants.Client.prefix}cht lambda
+```
+If the problem persists send a message in <#{Channels.dev_contrib}>
+"""
+
+URL = 'https://cheat.sh/python/{search}'
+ESCAPE_TT = str.maketrans({"`": "\\`"})
+ANSI_RE = re.compile(r"\x1b\[.*?m")
+# We need to pass headers as curl otherwise it would default to aiohttp which would return raw html.
+HEADERS = {'User-Agent': 'curl/7.68.0'}
+
+
+class CheatSheet(commands.Cog):
+ """Commands that sends a result of a cht.sh search in code blocks."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @staticmethod
+ def fmt_error_embed() -> Embed:
+ """
+ Format the Error Embed.
+
+ If the cht.sh search returned 404, overwrite it to send a custom error embed.
+ link -> https://github.com/chubin/cheat.sh/issues/198
+ """
+ embed = Embed(
+ title=random.choice(ERROR_REPLIES),
+ description=ERROR_MESSAGE,
+ colour=Colours.soft_red
+ )
+ return embed
+
+ def result_fmt(self, url: str, body_text: str) -> t.Tuple[bool, t.Union[str, Embed]]:
+ """Format Result."""
+ if body_text.startswith("# 404 NOT FOUND"):
+ embed = self.fmt_error_embed()
+ return True, embed
+
+ body_space = min(1986 - len(url), 1000)
+
+ if len(body_text) > body_space:
+ description = (f"**Result Of cht.sh**\n"
+ f"```python\n{body_text[:body_space]}\n"
+ f"... (truncated - too many lines)```\n"
+ f"Full results: {url} ")
+ else:
+ description = (f"**Result Of cht.sh**\n"
+ f"```python\n{body_text}```\n"
+ f"{url}")
+ return False, description
+
+ @commands.command(
+ name="cheat",
+ aliases=("cht.sh", "cheatsheet", "cheat-sheet", "cht"),
+ )
+ @commands.cooldown(1, 10, BucketType.user)
+ @whitelist_override(categories=[Categories.help_in_use])
+ async def cheat_sheet(self, ctx: Context, *search_terms: str) -> None:
+ """
+ Search cheat.sh.
+
+ Gets a post from https://cheat.sh/python/ by default.
+ Usage:
+ --> .cht read json
+ """
+ async with ctx.typing():
+ search_string = quote_plus(" ".join(search_terms))
+
+ async with self.bot.http_session.get(
+ URL.format(search=search_string), headers=HEADERS
+ ) as response:
+ result = ANSI_RE.sub("", await response.text()).translate(ESCAPE_TT)
+
+ is_embed, description = self.result_fmt(
+ URL.format(search=search_string),
+ result
+ )
+ if is_embed:
+ await ctx.send(embed=description)
+ else:
+ await ctx.send(content=description)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the CheatSheet cog."""
+ bot.add_cog(CheatSheet(bot))
diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py
new file mode 100644
index 00000000..7e3ec42b
--- /dev/null
+++ b/bot/exts/evergreen/connect_four.py
@@ -0,0 +1,450 @@
+import asyncio
+import random
+import typing
+from functools import partial
+
+import discord
+import emojis
+from discord.ext import commands
+from discord.ext.commands import guild_only
+
+from bot.constants import Emojis
+
+NUMBERS = list(Emojis.number_emojis.values())
+CROSS_EMOJI = Emojis.incident_unactioned
+
+Coordinate = typing.Optional[typing.Tuple[int, int]]
+EMOJI_CHECK = typing.Union[discord.Emoji, str]
+
+
+class Game:
+ """A Connect 4 Game."""
+
+ def __init__(
+ self,
+ bot: commands.Bot,
+ channel: discord.TextChannel,
+ player1: discord.Member,
+ player2: typing.Optional[discord.Member],
+ tokens: typing.List[str],
+ size: int = 7
+ ) -> None:
+
+ self.bot = bot
+ self.channel = channel
+ self.player1 = player1
+ self.player2 = player2 or AI(self.bot, game=self)
+ self.tokens = tokens
+
+ self.grid = self.generate_board(size)
+ self.grid_size = size
+
+ self.unicode_numbers = NUMBERS[:self.grid_size]
+
+ self.message = None
+
+ self.player_active = None
+ self.player_inactive = None
+
+ @staticmethod
+ def generate_board(size: int) -> typing.List[typing.List[int]]:
+ """Generate the connect 4 board."""
+ return [[0 for _ in range(size)] for _ in range(size)]
+
+ async def print_grid(self) -> None:
+ """Formats and outputs the Connect Four grid to the channel."""
+ title = (
+ f'Connect 4: {self.player1.display_name}'
+ f' VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}'
+ )
+
+ rows = [" ".join(self.tokens[s] for s in row) for row in self.grid]
+ first_row = " ".join(x for x in NUMBERS[:self.grid_size])
+ formatted_grid = "\n".join([first_row] + rows)
+ embed = discord.Embed(title=title, description=formatted_grid)
+
+ if self.message:
+ await self.message.edit(embed=embed)
+ else:
+ self.message = await self.channel.send(content='Loading...')
+ for emoji in self.unicode_numbers:
+ await self.message.add_reaction(emoji)
+ await self.message.add_reaction(CROSS_EMOJI)
+ await self.message.edit(content=None, embed=embed)
+
+ async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None:
+ """Announces to public chat."""
+ if action == "win":
+ await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}")
+ elif action == "draw":
+ await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:")
+ elif action == "quit":
+ await self.channel.send(f"{self.player1.mention} surrendered. Game over!")
+ await self.print_grid()
+
+ async def start_game(self) -> None:
+ """Begins the game."""
+ self.player_active, self.player_inactive = self.player1, self.player2
+
+ while True:
+ await self.print_grid()
+
+ if isinstance(self.player_active, AI):
+ coords = self.player_active.play()
+ if not coords:
+ await self.game_over(
+ "draw",
+ self.bot.user if isinstance(self.player_active, AI) else self.player_active,
+ self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive,
+ )
+ else:
+ coords = await self.player_turn()
+
+ if not coords:
+ return
+
+ if self.check_win(coords, 1 if self.player_active == self.player1 else 2):
+ await self.game_over(
+ "win",
+ self.bot.user if isinstance(self.player_active, AI) else self.player_active,
+ self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive,
+ )
+ return
+
+ self.player_active, self.player_inactive = self.player_inactive, self.player_active
+
+ def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool:
+ """The predicate to check for the player's reaction."""
+ return (
+ reaction.message.id == self.message.id
+ and user.id == self.player_active.id
+ and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI)
+ )
+
+ async def player_turn(self) -> Coordinate:
+ """Initiate the player's turn."""
+ message = await self.channel.send(
+ f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in."
+ )
+ player_num = 1 if self.player_active == self.player1 else 2
+ while True:
+ try:
+ reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0)
+ except asyncio.TimeoutError:
+ await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!")
+ return
+ else:
+ await message.delete()
+ if str(reaction.emoji) == CROSS_EMOJI:
+ await self.game_over("quit", self.player_active, self.player_inactive)
+ return
+
+ await self.message.remove_reaction(reaction, user)
+
+ column_num = self.unicode_numbers.index(str(reaction.emoji))
+ column = [row[column_num] for row in self.grid]
+
+ for row_num, square in reversed(list(enumerate(column))):
+ if not square:
+ self.grid[row_num][column_num] = player_num
+ return row_num, column_num
+ message = await self.channel.send(f"Column {column_num + 1} is full. Try again")
+
+ def check_win(self, coords: Coordinate, player_num: int) -> bool:
+ """Check that placing a counter here would cause the player to win."""
+ vertical = [(-1, 0), (1, 0)]
+ horizontal = [(0, 1), (0, -1)]
+ forward_diag = [(-1, 1), (1, -1)]
+ backward_diag = [(-1, -1), (1, 1)]
+ axes = [vertical, horizontal, forward_diag, backward_diag]
+
+ for axis in axes:
+ counters_in_a_row = 1 # The initial counter that is compared to
+ for (row_incr, column_incr) in axis:
+ row, column = coords
+ row += row_incr
+ column += column_incr
+
+ while 0 <= row < self.grid_size and 0 <= column < self.grid_size:
+ if self.grid[row][column] == player_num:
+ counters_in_a_row += 1
+ row += row_incr
+ column += column_incr
+ else:
+ break
+ if counters_in_a_row >= 4:
+ return True
+ return False
+
+
+class AI:
+ """The Computer Player for Single-Player games."""
+
+ def __init__(self, bot: commands.Bot, game: Game) -> None:
+ self.game = game
+ self.mention = bot.user.mention
+
+ def get_possible_places(self) -> typing.List[Coordinate]:
+ """Gets all the coordinates where the AI could possibly place a counter."""
+ possible_coords = []
+ for column_num in range(self.game.grid_size):
+ column = [row[column_num] for row in self.game.grid]
+ for row_num, square in reversed(list(enumerate(column))):
+ if not square:
+ possible_coords.append((row_num, column_num))
+ break
+ return possible_coords
+
+ def check_ai_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]:
+ """
+ Check AI win.
+
+ Check if placing a counter in any possible coordinate would cause the AI to win
+ with 10% chance of not winning and returning None
+ """
+ if random.randint(1, 10) == 1:
+ return
+ for coords in coord_list:
+ if self.game.check_win(coords, 2):
+ return coords
+
+ def check_player_win(self, coord_list: typing.List[Coordinate]) -> typing.Optional[Coordinate]:
+ """
+ Check Player win.
+
+ Check if placing a counter in possible coordinates would stop the player
+ from winning with 25% of not blocking them and returning None.
+ """
+ if random.randint(1, 4) == 1:
+ return
+ for coords in coord_list:
+ if self.game.check_win(coords, 1):
+ return coords
+
+ @staticmethod
+ def random_coords(coord_list: typing.List[Coordinate]) -> Coordinate:
+ """Picks a random coordinate from the possible ones."""
+ return random.choice(coord_list)
+
+ def play(self) -> typing.Union[Coordinate, bool]:
+ """
+ Plays for the AI.
+
+ Gets all possible coords, and determins the move:
+ 1. coords where it can win.
+ 2. coords where the player can win.
+ 3. Random coord
+ The first possible value is choosen.
+ """
+ possible_coords = self.get_possible_places()
+
+ if not possible_coords:
+ return False
+
+ coords = (
+ self.check_ai_win(possible_coords)
+ or self.check_player_win(possible_coords)
+ or self.random_coords(possible_coords)
+ )
+
+ row, column = coords
+ self.game.grid[row][column] = 2
+ return coords
+
+
+class ConnectFour(commands.Cog):
+ """Connect Four. The Classic Vertical Four-in-a-row Game!"""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+ self.games: typing.List[Game] = []
+ self.waiting: typing.List[discord.Member] = []
+
+ self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"]
+
+ self.max_board_size = 9
+ self.min_board_size = 5
+
+ async def check_author(self, ctx: commands.Context, board_size: int) -> bool:
+ """Check if the requester is free and the board size is correct."""
+ if self.already_playing(ctx.author):
+ await ctx.send("You're already playing a game!")
+ return False
+
+ if ctx.author in self.waiting:
+ await ctx.send("You've already sent out a request for a player 2")
+ return False
+
+ if not self.min_board_size <= board_size <= self.max_board_size:
+ await ctx.send(f"{board_size} is not a valid board size. A valid board size is "
+ f"between `{self.min_board_size}` and `{self.max_board_size}`.")
+ return False
+
+ return True
+
+ def get_player(
+ self,
+ ctx: commands.Context,
+ announcement: discord.Message,
+ reaction: discord.Reaction,
+ user: discord.Member
+ ) -> bool:
+ """Predicate checking the criteria for the announcement message."""
+ if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2
+ return True # Is dealt with later on
+
+ if (
+ user.id not in (ctx.me.id, ctx.author.id)
+ and str(reaction.emoji) == Emojis.hand_raised
+ and reaction.message.id == announcement.id
+ ):
+ if self.already_playing(user):
+ self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!"))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ if user in self.waiting:
+ self.bot.loop.create_task(ctx.send(
+ f"{user.mention} Please cancel your game first before joining another one."
+ ))
+ self.bot.loop.create_task(announcement.remove_reaction(reaction, user))
+ return False
+
+ return True
+
+ if (
+ user.id == ctx.author.id
+ and str(reaction.emoji) == CROSS_EMOJI
+ and reaction.message.id == announcement.id
+ ):
+ return True
+ return False
+
+ def already_playing(self, player: discord.Member) -> bool:
+ """Check if someone is already in a game."""
+ return any(player in (game.player1, game.player2) for game in self.games)
+
+ @staticmethod
+ def check_emojis(
+ e1: EMOJI_CHECK, e2: EMOJI_CHECK
+ ) -> typing.Tuple[bool, typing.Optional[str]]:
+ """Validate the emojis, the user put."""
+ if isinstance(e1, str) and emojis.count(e1) != 1:
+ return False, e1
+ if isinstance(e2, str) and emojis.count(e2) != 1:
+ return False, e2
+ return True, None
+
+ async def _play_game(
+ self,
+ ctx: commands.Context,
+ user: typing.Optional[discord.Member],
+ board_size: int,
+ emoji1: str,
+ emoji2: str
+ ) -> None:
+ """Helper for playing a game of connect four."""
+ self.tokens = [":white_circle:", str(emoji1), str(emoji2)]
+ game = None # if game fails to intialize in try...except
+
+ try:
+ game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size)
+ self.games.append(game)
+ await game.start_game()
+ self.games.remove(game)
+ except Exception:
+ # End the game in the event of an unforeseen error so the players aren't stuck in a game
+ await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed")
+ if game in self.games:
+ self.games.remove(game)
+ raise
+
+ @guild_only()
+ @commands.group(
+ invoke_without_command=True,
+ aliases=["4inarow", "connect4", "connectfour", "c4"],
+ case_insensitive=True
+ )
+ async def connect_four(
+ self,
+ ctx: commands.Context,
+ board_size: int = 7,
+ emoji1: EMOJI_CHECK = "\U0001f535",
+ emoji2: EMOJI_CHECK = "\U0001f534"
+ ) -> None:
+ """
+ Play the classic game of Connect Four with someone!
+
+ Sets up a message waiting for someone else to react and play along.
+ The game will start once someone has reacted.
+ All inputs will be through reactions.
+ """
+ check, emoji = self.check_emojis(emoji1, emoji2)
+ if not check:
+ raise commands.EmojiNotFound(emoji)
+
+ check_author_result = await self.check_author(ctx, board_size)
+ if not check_author_result:
+ return
+
+ announcement = await ctx.send(
+ "**Connect Four**: A new game is about to start!\n"
+ f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n"
+ f"(Cancel the game with {CROSS_EMOJI}.)"
+ )
+ self.waiting.append(ctx.author)
+ await announcement.add_reaction(Emojis.hand_raised)
+ await announcement.add_reaction(CROSS_EMOJI)
+
+ try:
+ reaction, user = await self.bot.wait_for(
+ "reaction_add",
+ check=partial(self.get_player, ctx, announcement),
+ timeout=60.0
+ )
+ except asyncio.TimeoutError:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ await ctx.send(
+ f"{ctx.author.mention} Seems like there's no one here to play. "
+ f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer."
+ )
+ return
+
+ if str(reaction.emoji) == CROSS_EMOJI:
+ self.waiting.remove(ctx.author)
+ await announcement.delete()
+ await ctx.send(f"{ctx.author.mention} Game cancelled.")
+ return
+
+ await announcement.delete()
+ self.waiting.remove(ctx.author)
+ if self.already_playing(ctx.author):
+ return
+
+ await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2))
+
+ @guild_only()
+ @connect_four.command(aliases=["bot", "computer", "cpu"])
+ async def ai(
+ self,
+ ctx: commands.Context,
+ board_size: int = 7,
+ emoji1: EMOJI_CHECK = "\U0001f535",
+ emoji2: EMOJI_CHECK = "\U0001f534"
+ ) -> None:
+ """Play Connect Four against a computer player."""
+ check, emoji = self.check_emojis(emoji1, emoji2)
+ if not check:
+ raise commands.EmojiNotFound(emoji)
+
+ check_author_result = await self.check_author(ctx, board_size)
+ if not check_author_result:
+ return
+
+ await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load ConnectFour Cog."""
+ bot.add_cog(ConnectFour(bot))
diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py
index 576b8d76..e7058961 100644
--- a/bot/exts/evergreen/conversationstarters.py
+++ b/bot/exts/evergreen/conversationstarters.py
@@ -5,7 +5,7 @@ from discord import Color, Embed
from discord.ext import commands
from bot.constants import WHITELISTED_CHANNELS
-from bot.utils.decorators import override_in_channel
+from bot.utils.decorators import whitelist_override
from bot.utils.randomization import RandomCycle
SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9'
@@ -38,7 +38,7 @@ class ConvoStarters(commands.Cog):
self.bot = bot
@commands.command()
- @override_in_channel(ALL_ALLOWED_CHANNELS)
+ @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
async def topic(self, ctx: commands.Context) -> None:
"""
Responds with a random topic to start a conversation.
diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
index 99af1519..28902503 100644
--- a/bot/exts/evergreen/error_handler.py
+++ b/bot/exts/evergreen/error_handler.py
@@ -7,7 +7,7 @@ from discord import Embed, Message
from discord.ext import commands
from sentry_sdk import push_scope
-from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES
+from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES
from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure
from bot.utils.exceptions import UserNotPlayingError
@@ -83,7 +83,12 @@ class CommandErrorHandler(commands.Cog):
return
if isinstance(error, commands.NoPrivateMessage):
- await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES))
+ await ctx.send(
+ embed=self.error_embed(
+ f"This command can only be used in the server. Go to <#{Channels.community_bot_commands}> instead!",
+ NEGATIVE_REPLIES
+ )
+ )
return
if isinstance(error, commands.BadArgument):
diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py
index e419a6f5..bbcbf611 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -1,11 +1,13 @@
import logging
import random
+import re
+import typing as t
+from enum import Enum
import discord
-from discord.ext import commands
+from discord.ext import commands, tasks
-from bot.constants import Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
-from bot.utils.decorators import override_in_channel
+from bot.constants import Categories, Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
log = logging.getLogger(__name__)
@@ -15,55 +17,88 @@ BAD_RESPONSE = {
}
MAX_REQUESTS = 10
-
REQUEST_HEADERS = dict()
+
+REPOS_API = "https://api.github.com/orgs/{org}/repos"
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
+WHITELISTED_CATEGORIES = (
+ Categories.development, Categories.devprojects, Categories.media, Categories.staff
+)
+WHITELISTED_CHANNELS_ON_MESSAGE = (
+ Channels.organisation, Channels.mod_meta, Channels.mod_tools, Channels.staff_voice
+)
+
+CODE_BLOCK_RE = re.compile(
+ r"^`([^`\n]+)`" # Inline codeblock
+ r"|```(.+?)```", # Multiline codeblock
+ re.DOTALL | re.MULTILINE
+)
+
+
+class FetchIssueErrors(Enum):
+ """Errors returned in fetch issues."""
+
+ value_error = "Numbers not found."
+ max_requests = "Max requests hit."
+
class Issues(commands.Cog):
"""Cog that allows users to retrieve issues from GitHub."""
def __init__(self, bot: commands.Bot):
self.bot = bot
-
- @commands.command(aliases=("pr",))
- @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib, Channels.dev_branding))
- async def issue(
- self,
- ctx: commands.Context,
- numbers: commands.Greedy[int],
- repository: str = "sir-lancebot",
- user: str = "python-discord"
- ) -> None:
- """Command to retrieve issue(s) from a GitHub repository."""
+ self.repos = []
+ self.get_pydis_repos.start()
+
+ @tasks.loop(minutes=30)
+ async def get_pydis_repos(self) -> None:
+ """Get all python-discord repositories on github."""
+ async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ for repo in data:
+ self.repos.append(repo["full_name"].split("/")[1])
+ self.repo_regex = "|".join(self.repos)
+ else:
+ log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}")
+
+ @staticmethod
+ def check_in_block(message: discord.Message, repo_issue: str) -> bool:
+ """Check whether the <repo>#<issue> is in codeblocks."""
+ block = re.findall(CODE_BLOCK_RE, message.content)
+
+ if not block:
+ return False
+ elif "#".join(repo_issue.split(" ")) in "".join([*block[0]]):
+ return True
+ return False
+
+ async def fetch_issues(
+ self,
+ numbers: set,
+ repository: str,
+ user: str
+ ) -> t.Union[FetchIssueErrors, str, list]:
+ """Retrieve issue(s) from a GitHub repository."""
links = []
- numbers = set(numbers) # Convert from list to set to remove duplicates, if any
-
if not numbers:
- await ctx.invoke(self.bot.get_command('help'), 'issue')
- return
+ return FetchIssueErrors.value_error
if len(numbers) > MAX_REQUESTS:
- embed = discord.Embed(
- title=random.choice(ERROR_REPLIES),
- color=Colours.soft_red,
- description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
- )
- await ctx.send(embed=embed)
- return
+ return FetchIssueErrors.max_requests
for number in numbers:
url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
-
log.trace(f"Querying GH issues API: {url}")
async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
json_data = await r.json()
if r.status in BAD_RESPONSE:
log.warning(f"Received response {r.status} from: {url}")
- return await ctx.send(f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}")
+ return f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}"
# The initial API request is made to the issues API endpoint, which will return information
# if the issue or PR is present. However, the scope of information returned for PRs differs
@@ -92,15 +127,80 @@ class Issues(commands.Cog):
issue_url = json_data.get("html_url")
links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
- # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
- description_list = ["{0} [{1}]({2})".format(*link) for link in links]
+ return links
+
+ @staticmethod
+ def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed:
+ """Get Response Embed."""
+ description_list = ["{0} [{1}]({2})".format(*link) for link in result]
resp = discord.Embed(
colour=Colours.bright_green,
description='\n'.join(description_list)
)
resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
- await ctx.send(embed=resp)
+ return resp
+
+ @commands.command(aliases=("pr",))
+ async def issue(
+ self,
+ ctx: commands.Context,
+ numbers: commands.Greedy[int],
+ repository: str = "sir-lancebot",
+ user: str = "python-discord"
+ ) -> None:
+ """Command to retrieve issue(s) from a GitHub repository."""
+ if not(
+ ctx.channel.category.id in WHITELISTED_CATEGORIES
+ or ctx.channel.id in WHITELISTED_CHANNELS
+ ):
+ return
+
+ result = await self.fetch_issues(set(numbers), repository, user)
+
+ if result == FetchIssueErrors.value_error:
+ await ctx.invoke(self.bot.get_command('help'), 'issue')
+
+ elif result == FetchIssueErrors.max_requests:
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
+ )
+ await ctx.send(embed=embed)
+
+ elif isinstance(result, list):
+ # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
+ resp = self.get_embed(result, user, repository)
+ await ctx.send(embed=resp)
+
+ elif isinstance(result, str):
+ await ctx.send(result)
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>."""
+ if not(
+ message.channel.category.id in WHITELISTED_CATEGORIES
+ or message.channel.id in WHITELISTED_CHANNELS_ON_MESSAGE
+ ):
+ return
+
+ message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content)
+ links = []
+
+ if message_repo_issue_map:
+ for repo_issue in message_repo_issue_map:
+ if not self.check_in_block(message, " ".join(repo_issue)):
+ result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord")
+ if isinstance(result, list):
+ links.extend(result)
+
+ if not links:
+ return
+
+ resp = self.get_embed(links, "python-discord")
+ await message.channel.send(embed=resp)
def setup(bot: commands.Bot) -> None:
diff --git a/bot/exts/evergreen/status_cats.py b/bot/exts/evergreen/status_cats.py
deleted file mode 100644
index 586b8378..00000000
--- a/bot/exts/evergreen/status_cats.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from http import HTTPStatus
-
-import discord
-from discord.ext import commands
-
-
-class StatusCats(commands.Cog):
- """Commands that give HTTP statuses described and visualized by cats."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command(aliases=['statuscat'])
- async def http_cat(self, ctx: commands.Context, code: int) -> None:
- """Sends an embed with an image of a cat, potraying the status code."""
- embed = discord.Embed(title=f'**Status: {code}**')
-
- try:
- HTTPStatus(code)
-
- except ValueError:
- embed.set_footer(text='Inputted status code does not exist.')
-
- else:
- embed.set_image(url=f'https://http.cat/{code}.jpg')
-
- finally:
- await ctx.send(embed=embed)
-
-
-def setup(bot: commands.Bot) -> None:
- """Load the StatusCats cog."""
- bot.add_cog(StatusCats(bot))
diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py
new file mode 100644
index 00000000..874c87eb
--- /dev/null
+++ b/bot/exts/evergreen/status_codes.py
@@ -0,0 +1,71 @@
+from http import HTTPStatus
+
+import discord
+from discord.ext import commands
+
+HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg"
+HTTP_CAT_URL = "https://http.cat/{code}.jpg"
+
+
+class HTTPStatusCodes(commands.Cog):
+ """Commands that give HTTP statuses described and visualized by cats and dogs."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.group(name="http_status", aliases=("status", "httpstatus"))
+ async def http_status_group(self, ctx: commands.Context) -> None:
+ """Group containing dog and cat http status code commands."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @http_status_group.command(name='cat')
+ async def http_cat(self, ctx: commands.Context, code: int) -> None:
+ """Sends an embed with an image of a cat, portraying the status code."""
+ embed = discord.Embed(title=f'**Status: {code}**')
+ url = HTTP_CAT_URL.format(code=code)
+
+ try:
+ HTTPStatus(code)
+ async with self.bot.http_session.get(url, allow_redirects=False) as response:
+ if response.status != 404:
+ embed.set_image(url=url)
+ else:
+ raise NotImplementedError
+
+ except ValueError:
+ embed.set_footer(text='Inputted status code does not exist.')
+
+ except NotImplementedError:
+ embed.set_footer(text='Inputted status code is not implemented by http.cat yet.')
+
+ finally:
+ await ctx.send(embed=embed)
+
+ @http_status_group.command(name='dog')
+ async def http_dog(self, ctx: commands.Context, code: int) -> None:
+ """Sends an embed with an image of a dog, portraying the status code."""
+ embed = discord.Embed(title=f'**Status: {code}**')
+ url = HTTP_DOG_URL.format(code=code)
+
+ try:
+ HTTPStatus(code)
+ async with self.bot.http_session.get(url, allow_redirects=False) as response:
+ if response.status != 302:
+ embed.set_image(url=url)
+ else:
+ raise NotImplementedError
+
+ except ValueError:
+ embed.set_footer(text='Inputted status code does not exist.')
+
+ except NotImplementedError:
+ embed.set_footer(text='Inputted status code is not implemented by httpstatusdogs.com yet.')
+
+ finally:
+ await ctx.send(embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the HTTPStatusCodes cog."""
+ bot.add_cog(HTTPStatusCodes(bot))
diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py
new file mode 100644
index 00000000..e1190502
--- /dev/null
+++ b/bot/exts/evergreen/tic_tac_toe.py
@@ -0,0 +1,323 @@
+import asyncio
+import random
+import typing as t
+
+import discord
+from discord.ext.commands import Cog, Context, check, group, guild_only
+
+from bot.bot import Bot
+from bot.constants import Emojis
+from bot.utils.pagination import LinePaginator
+
+CONFIRMATION_MESSAGE = (
+ "{opponent}, {requester} wants to play Tic-Tac-Toe against you. React to this message with "
+ f"{Emojis.confirmation} to accept or with {Emojis.decline} to decline."
+)
+
+
+def check_win(board: t.Dict[int, str]) -> bool:
+ """Check from board, is any player won game."""
+ return any(
+ (
+ # Horizontal
+ board[1] == board[2] == board[3],
+ board[4] == board[5] == board[6],
+ board[7] == board[8] == board[9],
+ # Vertical
+ board[1] == board[4] == board[7],
+ board[2] == board[5] == board[8],
+ board[3] == board[6] == board[9],
+ # Diagonal
+ board[1] == board[5] == board[9],
+ board[3] == board[5] == board[7],
+ )
+ )
+
+
+class Player:
+ """Class that contains information about player and functions that interact with player."""
+
+ def __init__(self, user: discord.User, ctx: Context, symbol: str):
+ self.user = user
+ self.ctx = ctx
+ self.symbol = symbol
+
+ async def get_move(self, board: t.Dict[int, str], msg: discord.Message) -> t.Tuple[bool, t.Optional[int]]:
+ """
+ Get move from user.
+
+ Return is timeout reached and position of field what user will fill when timeout don't reach.
+ """
+ def check_for_move(r: discord.Reaction, u: discord.User) -> bool:
+ """Check does user who reacted is user who we want, message is board and emoji is in board values."""
+ return (
+ u.id == self.user.id
+ and msg.id == r.message.id
+ and r.emoji in board.values()
+ and r.emoji in Emojis.number_emojis.values()
+ )
+
+ try:
+ react, _ = await self.ctx.bot.wait_for('reaction_add', timeout=30.0, check=check_for_move)
+ except asyncio.TimeoutError:
+ return True, None
+ else:
+ return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)]
+
+ def __str__(self) -> str:
+ """Return mention of user."""
+ return self.user.mention
+
+
+class AI:
+ """Tic Tac Toe AI class for against computer gaming."""
+
+ def __init__(self, symbol: str):
+ self.symbol = symbol
+
+ async def get_move(self, board: t.Dict[int, str], _: discord.Message) -> t.Tuple[bool, int]:
+ """Get move from AI. AI use Minimax strategy."""
+ possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())]
+
+ for symbol in (Emojis.o, Emojis.x):
+ for move in possible_moves:
+ board_copy = board.copy()
+ board_copy[move] = symbol
+ if check_win(board_copy):
+ return False, move
+
+ open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)]
+ if len(open_corners) > 0:
+ return False, random.choice(open_corners)
+
+ if 5 in possible_moves:
+ return False, 5
+
+ open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)]
+ return False, random.choice(open_edges)
+
+ def __str__(self) -> str:
+ """Return `AI` as user name."""
+ return "AI"
+
+
+class Game:
+ """Class that contains information and functions about Tic Tac Toe game."""
+
+ def __init__(self, players: t.List[t.Union[Player, AI]], ctx: Context):
+ self.players = players
+ self.ctx = ctx
+ self.board = {
+ 1: Emojis.number_emojis[1],
+ 2: Emojis.number_emojis[2],
+ 3: Emojis.number_emojis[3],
+ 4: Emojis.number_emojis[4],
+ 5: Emojis.number_emojis[5],
+ 6: Emojis.number_emojis[6],
+ 7: Emojis.number_emojis[7],
+ 8: Emojis.number_emojis[8],
+ 9: Emojis.number_emojis[9]
+ }
+
+ self.current = self.players[0]
+ self.next = self.players[1]
+
+ self.winner: t.Optional[t.Union[Player, AI]] = None
+ self.loser: t.Optional[t.Union[Player, AI]] = None
+ self.over = False
+ self.canceled = False
+ self.draw = False
+
+ async def get_confirmation(self) -> t.Tuple[bool, t.Optional[str]]:
+ """
+ Ask does user want to play TicTacToe against requester. First player is always requester.
+
+ This return tuple that have:
+ - first element boolean (is game accepted?)
+ - (optional, only when first element is False, otherwise None) reason for declining.
+ """
+ confirm_message = await self.ctx.send(
+ CONFIRMATION_MESSAGE.format(
+ opponent=self.players[1].user.mention,
+ requester=self.players[0].user.mention
+ )
+ )
+ await confirm_message.add_reaction(Emojis.confirmation)
+ await confirm_message.add_reaction(Emojis.decline)
+
+ def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool:
+ """Check is user who reacted from who this was requested, message is confirmation and emoji is valid."""
+ return (
+ reaction.emoji in (Emojis.confirmation, Emojis.decline)
+ and reaction.message.id == confirm_message.id
+ and user == self.players[1].user
+ )
+
+ try:
+ reaction, user = await self.ctx.bot.wait_for(
+ "reaction_add",
+ timeout=60.0,
+ check=confirm_check
+ )
+ except asyncio.TimeoutError:
+ self.over = True
+ self.canceled = True
+ await confirm_message.delete()
+ return False, "Running out of time... Cancelled game."
+
+ await confirm_message.delete()
+ if reaction.emoji == Emojis.confirmation:
+ return True, None
+ else:
+ self.over = True
+ self.canceled = True
+ return False, "User declined"
+
+ async def add_reactions(self, msg: discord.Message) -> None:
+ """Add number emojis to message."""
+ for nr in Emojis.number_emojis.values():
+ await msg.add_reaction(nr)
+
+ def format_board(self) -> str:
+ """Get formatted tic-tac-toe board for message."""
+ board = list(self.board.values())
+ return "\n".join(
+ (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3))
+ )
+
+ async def play(self) -> None:
+ """Start and handle game."""
+ await self.ctx.send("It's time for the game! Let's begin.")
+ board = await self.ctx.send(
+ embed=discord.Embed(description=self.format_board())
+ )
+ await self.add_reactions(board)
+
+ for _ in range(9):
+ if isinstance(self.current, Player):
+ announce = await self.ctx.send(
+ f"{self.current.user.mention}, it's your turn! "
+ "React with an emoji to take your go."
+ )
+ timeout, pos = await self.current.get_move(self.board, board)
+ if isinstance(self.current, Player):
+ await announce.delete()
+ if timeout:
+ await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.")
+ self.over = True
+ self.canceled = True
+ return
+ self.board[pos] = self.current.symbol
+ await board.edit(
+ embed=discord.Embed(description=self.format_board())
+ )
+ await board.clear_reaction(Emojis.number_emojis[pos])
+ if check_win(self.board):
+ self.winner = self.current
+ self.loser = self.next
+ await self.ctx.send(
+ f":tada: {self.current} won this game! :tada:"
+ )
+ await board.clear_reactions()
+ break
+ self.current, self.next = self.next, self.current
+ if not self.winner:
+ self.draw = True
+ await self.ctx.send("It's a DRAW!")
+ self.over = True
+
+
+def is_channel_free() -> t.Callable:
+ """Check is channel where command will be invoked free."""
+ async def predicate(ctx: Context) -> bool:
+ return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over)
+ return check(predicate)
+
+
+def is_requester_free() -> t.Callable:
+ """Check is requester not already in any game."""
+ async def predicate(ctx: Context) -> bool:
+ return all(
+ ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over
+ )
+ return check(predicate)
+
+
+class TicTacToe(Cog):
+ """TicTacToe cog contains tic-tac-toe game commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.games: t.List[Game] = []
+
+ @guild_only()
+ @is_channel_free()
+ @is_requester_free()
+ @group(name="tictactoe", aliases=("ttt",), invoke_without_command=True)
+ async def tic_tac_toe(self, ctx: Context, opponent: t.Optional[discord.User]) -> None:
+ """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field."""
+ if opponent == ctx.author:
+ await ctx.send("You can't play against yourself.")
+ return
+ if opponent is not None and not all(
+ opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over
+ ):
+ await ctx.send("Opponent is already in game.")
+ return
+ if opponent is None:
+ game = Game(
+ [Player(ctx.author, ctx, Emojis.x), AI(Emojis.o)],
+ ctx
+ )
+ else:
+ game = Game(
+ [Player(ctx.author, ctx, Emojis.x), Player(opponent, ctx, Emojis.o)],
+ ctx
+ )
+ self.games.append(game)
+ if opponent is not None:
+ confirmed, msg = await game.get_confirmation()
+
+ if not confirmed:
+ if msg:
+ await ctx.send(msg)
+ return
+ await game.play()
+
+ @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True)
+ async def tic_tac_toe_logs(self, ctx: Context) -> None:
+ """Show most recent tic-tac-toe games."""
+ if len(self.games) < 1:
+ await ctx.send("No recent games.")
+ return
+ log_games = []
+ for i, game in enumerate(self.games):
+ if game.over and not game.canceled:
+ if game.draw:
+ log_games.append(
+ f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)"
+ )
+ else:
+ log_games.append(
+ f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}"
+ )
+ await LinePaginator.paginate(
+ log_games,
+ ctx,
+ discord.Embed(title="Most recent Tic Tac Toe games")
+ )
+
+ @tic_tac_toe_logs.command(name="show", aliases=("s",))
+ async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None:
+ """View game board by ID (ID is possible to get by `.tictactoe history`)."""
+ if len(self.games) < game_id:
+ await ctx.send("Game don't exist.")
+ return
+ game = self.games[game_id - 1]
+ await ctx.send(f"{game.winner} :trophy: vs {game.loser}")
+ await ctx.send(game.format_board())
+
+
+def setup(bot: Bot) -> None:
+ """Load TicTacToe Cog."""
+ bot.add_cog(TicTacToe(bot))
diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py
index be36e2c4..068c4f43 100644
--- a/bot/exts/evergreen/wikipedia.py
+++ b/bot/exts/evergreen/wikipedia.py
@@ -1,114 +1,94 @@
-import asyncio
-import datetime
import logging
+import re
+from datetime import datetime
+from html import unescape
from typing import List, Optional
-from aiohttp import client_exceptions
-from discord import Color, Embed, Message
+from discord import Color, Embed, TextChannel
from discord.ext import commands
-from bot.constants import Wikipedia
+from bot.bot import Bot
+from bot.utils import LinePaginator
log = logging.getLogger(__name__)
-SEARCH_API = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={search_term}&format=json"
-WIKIPEDIA_URL = "https://en.wikipedia.org/wiki/{title}"
+SEARCH_API = (
+ "https://en.wikipedia.org/w/api.php?action=query&list=search&prop=info&inprop=url&utf8=&"
+ "format=json&origin=*&srlimit={number_of_results}&srsearch={string}"
+)
+WIKI_THUMBNAIL = (
+ "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg"
+ "/330px-Wikipedia-logo-v2.svg.png"
+)
+WIKI_SNIPPET_REGEX = r'(<!--.*?-->|<[^>]*>)'
+WIKI_SEARCH_RESULT = (
+ "**[{name}]({url})**\n"
+ "{description}\n"
+)
class WikipediaSearch(commands.Cog):
"""Get info from wikipedia."""
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.bot = bot
- self.http_session = bot.http_session
- @staticmethod
- def formatted_wiki_url(index: int, title: str) -> str:
- """Formating wikipedia link with index and title."""
- return f'`{index}` [{title}]({WIKIPEDIA_URL.format(title=title.replace(" ", "_"))})'
+ async def wiki_request(self, channel: TextChannel, search: str) -> Optional[List[str]]:
+ """Search wikipedia search string and return formatted first 10 pages found."""
+ url = SEARCH_API.format(number_of_results=10, string=search)
+ async with self.bot.http_session.get(url=url) as resp:
+ if resp.status == 200:
+ raw_data = await resp.json()
+ number_of_results = raw_data['query']['searchinfo']['totalhits']
+
+ if number_of_results:
+ results = raw_data['query']['search']
+ lines = []
+
+ for article in results:
+ line = WIKI_SEARCH_RESULT.format(
+ name=article['title'],
+ description=unescape(
+ re.sub(
+ WIKI_SNIPPET_REGEX, '', article['snippet']
+ )
+ ),
+ url=f"https://en.wikipedia.org/?curid={article['pageid']}"
+ )
+ lines.append(line)
+
+ return lines
- async def search_wikipedia(self, search_term: str) -> Optional[List[str]]:
- """Search wikipedia and return the first 10 pages found."""
- pages = []
- async with self.http_session.get(SEARCH_API.format(search_term=search_term)) as response:
- try:
- data = await response.json()
-
- search_results = data["query"]["search"]
-
- # Ignore pages with "may refer to"
- for search_result in search_results:
- log.info("trying to append titles")
- if "may refer to" not in search_result["snippet"]:
- pages.append(search_result["title"])
- except client_exceptions.ContentTypeError:
- pages = None
-
- log.info("Finished appending titles")
- return pages
+ else:
+ await channel.send(
+ "Sorry, we could not find a wikipedia article using that search term."
+ )
+ return
+ else:
+ log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`")
+ await channel.send(
+ "Whoops, the Wikipedia API is having some issues right now. Try again later."
+ )
+ return
@commands.cooldown(1, 10, commands.BucketType.user)
@commands.command(name="wikipedia", aliases=["wiki"])
async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None:
- """Return list of results containing your search query from wikipedia."""
- titles = await self.search_wikipedia(search)
-
- def check(message: Message) -> bool:
- return message.author.id == ctx.author.id and message.channel == ctx.channel
-
- if not titles:
- await ctx.send("Sorry, we could not find a wikipedia article using that search term")
- return
-
- async with ctx.typing():
- log.info("Finished appending titles to titles_no_underscore list")
-
- s_desc = "\n".join(self.formatted_wiki_url(index, title) for index, title in enumerate(titles, start=1))
- embed = Embed(colour=Color.blue(), title=f"Wikipedia results for `{search}`", description=s_desc)
- embed.timestamp = datetime.datetime.utcnow()
- await ctx.send(embed=embed)
- embed = Embed(colour=Color.green(), description="Enter number to choose")
- msg = await ctx.send(embed=embed)
- titles_len = len(titles) # getting length of list
-
- for retry_count in range(1, Wikipedia.total_chance + 1):
- retries_left = Wikipedia.total_chance - retry_count
- if retry_count < Wikipedia.total_chance:
- error_msg = f"You have `{retries_left}/{Wikipedia.total_chance}` chances left"
- else:
- error_msg = 'Please try again by using `.wiki` command'
- try:
- message = await ctx.bot.wait_for('message', timeout=60.0, check=check)
- response_from_user = await self.bot.get_context(message)
-
- if response_from_user.command:
- return
-
- response = int(message.content)
- if response < 0:
- await ctx.send(f"Sorry, but you can't give negative index, {error_msg}")
- elif response == 0:
- await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}")
- else:
- await ctx.send(WIKIPEDIA_URL.format(title=titles[response - 1].replace(" ", "_")))
- break
-
- except asyncio.TimeoutError:
- embed = Embed(colour=Color.red(), description=f"Time's up {ctx.author.mention}")
- await msg.edit(embed=embed)
- break
-
- except ValueError:
- await ctx.send(f"Sorry, but you cannot do that, I will only accept an positive integer, {error_msg}")
-
- except IndexError:
- await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}")
-
- except Exception as e:
- log.info(f"Caught exception {e}, breaking out of retry loop")
- break
-
-
-def setup(bot: commands.Bot) -> None:
+ """Sends paginated top 10 results of Wikipedia search.."""
+ contents = await self.wiki_request(ctx.channel, search)
+
+ if contents:
+ embed = Embed(
+ title="Wikipedia Search Results",
+ colour=Color.blurple()
+ )
+ embed.set_thumbnail(url=WIKI_THUMBNAIL)
+ embed.timestamp = datetime.utcnow()
+ await LinePaginator.paginate(
+ contents, ctx, embed
+ )
+
+
+def setup(bot: Bot) -> None:
"""Wikipedia Cog load."""
bot.add_cog(WikipediaSearch(bot))
diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py
index 898e8d2a..437d9e1a 100644
--- a/bot/exts/evergreen/wolfram.py
+++ b/bot/exts/evergreen/wolfram.py
@@ -108,7 +108,10 @@ async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional
"input": query,
"appid": APPID,
"output": DEFAULT_OUTPUT_FORMAT,
- "format": "image,plaintext"
+ "format": "image,plaintext",
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
request_url = QUERY.format(request="query", data=url_str)
@@ -168,6 +171,9 @@ class Wolfram(Cog):
url_str = parse.urlencode({
"i": query,
"appid": APPID,
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
query = QUERY.format(request="simple", data=url_str)
@@ -248,6 +254,9 @@ class Wolfram(Cog):
url_str = parse.urlencode({
"i": query,
"appid": APPID,
+ "location": "the moon",
+ "latlong": "0.0,0.0",
+ "ip": "1.1.1.1"
})
query = QUERY.format(request="result", data=url_str)
diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py
index d3224bfe..1ff98ca2 100644
--- a/bot/exts/evergreen/xkcd.py
+++ b/bot/exts/evergreen/xkcd.py
@@ -69,6 +69,8 @@ class XKCD(Cog):
return
embed.title = f"XKCD comic #{info['num']}"
+ embed.description = info['alt']
+ embed.url = f"{BASE_URL}/{info['num']}"
if info["img"][-3:] in ("jpg", "png", "gif"):
embed.set_image(url=info["img"])
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index a1c55922..d9fc0e8a 100644
--- a/bot/exts/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -11,7 +11,7 @@ from async_rediscache import RedisCache
from discord.ext import commands
from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS
-from bot.utils.decorators import in_month, override_in_channel
+from bot.utils.decorators import in_month, whitelist_override
log = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True)
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Display an embed for a user's Hacktoberfest contributions.
@@ -72,7 +72,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="link")
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def link_user(self, ctx: commands.Context, github_username: str = None) -> None:
"""
Link the invoking user's Github github_username to their Discord ID.
@@ -96,7 +96,7 @@ class HacktoberStats(commands.Cog):
@in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER)
@hacktoberstats_group.command(name="unlink")
- @override_in_channel(HACKTOBER_WHITELIST)
+ @whitelist_override(channels=HACKTOBER_WHITELIST)
async def unlink_user(self, ctx: commands.Context) -> None:
"""Remove the invoking user's account link from the log."""
author_id, author_mention = self._author_mention_from_context(ctx)
diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py
index 4db4d191..f3392bcb 100644
--- a/bot/exts/valentines/be_my_valentine.py
+++ b/bot/exts/valentines/be_my_valentine.py
@@ -2,13 +2,13 @@ import logging
import random
from json import load
from pathlib import Path
-from typing import Optional, Tuple
+from typing import Tuple
import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import Channels, Client, Colours, Lovefest, Month
+from bot.constants import Channels, Colours, Lovefest, Month
from bot.utils.decorators import in_month
log = logging.getLogger(__name__)
@@ -70,44 +70,35 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@commands.group(name='bemyvalentine', invoke_without_command=True)
async def send_valentine(
- self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
) -> None:
"""
- Send a valentine to user, if specified, or to a random user with the lovefest role.
+ Send a valentine to a specified user with the lovefest role.
- syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message]
+ syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message]
(optional)
- example: .bemyvalentine (sends valentine as a poem or a compliment to a random user)
example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman)
example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman)
NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command.
"""
if ctx.guild is None:
# This command should only be used in the server
- msg = "You are supposed to use this command in the server."
- return await ctx.send(msg)
+ raise commands.UserInputError("You are supposed to use this command in the server.")
- if user:
- if Lovefest.role_id not in [role.id for role in user.roles]:
- message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!"
- return await ctx.send(message)
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
if user == ctx.author:
# Well a user can't valentine himself/herself.
- return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:")
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
emoji_1, emoji_2 = self.random_emoji()
- lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id)
channel = self.bot.get_channel(Channels.community_bot_commands)
valentine, title = self.valentine_check(valentine_type)
- if user is None:
- author = ctx.author
- user = self.random_user(author, lovefest_role.members)
- if user is None:
- return await ctx.send("There are no users avilable to whome your valentine can be sent.")
-
embed = discord.Embed(
title=f'{emoji_1} {title} {user.display_name} {emoji_2}',
description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**',
@@ -118,56 +109,41 @@ class BeMyValentine(commands.Cog):
@commands.cooldown(1, 1800, BucketType.user)
@send_valentine.command(name='secret')
async def anonymous(
- self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
) -> None:
"""
- Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role.
-
- **This command should be DMed to the bot.**
+ Send an anonymous Valentine via DM to to a specified user with the lovefest role.
- syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message]
+ syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message]
(optional)
- example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you
- anonymous)
example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous)
example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to
Iceman in DM making you anonymous)
"""
- if ctx.guild is not None:
- # This command is only DM specific
- msg = "You are not supposed to use this command in the server, DM the command to the bot."
- return await ctx.send(msg)
-
- if user:
- if Lovefest.role_id not in [role.id for role in user.roles]:
- message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!"
- return await ctx.send(message)
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ await ctx.message.delete()
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
if user == ctx.author:
# Well a user cant valentine himself/herself.
- return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:')
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
- guild = self.bot.get_guild(id=Client.guild)
emoji_1, emoji_2 = self.random_emoji()
- lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id)
valentine, title = self.valentine_check(valentine_type)
- if user is None:
- author = ctx.author
- user = self.random_user(author, lovefest_role.members)
- if user is None:
- return await ctx.send("There are no users avilable to whome your valentine can be sent.")
-
embed = discord.Embed(
title=f'{emoji_1}{title} {user.display_name}{emoji_2}',
description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**',
color=Colours.pink
)
+ await ctx.message.delete()
try:
await user.send(embed=embed)
except discord.Forbidden:
- await ctx.author.send(f"{user} has DMs disabled, so I couldn't send the message. Sorry!")
+ raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!")
else:
await ctx.author.send(f"Your message has been sent to {user}")
@@ -191,18 +167,6 @@ class BeMyValentine(commands.Cog):
return valentine, title
@staticmethod
- def random_user(author: discord.Member, members: discord.Member) -> None:
- """
- Picks a random member from the list provided in `members`.
-
- The invoking author is ignored.
- """
- if author in members:
- members.remove(author)
-
- return random.choice(members) if members else None
-
- @staticmethod
def random_emoji() -> Tuple[str, str]:
"""Return two random emoji from the module-defined constants."""
emoji_1 = random.choice(HEART_EMOJIS)
diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py
index c75ea6cf..966acc82 100644
--- a/bot/exts/valentines/lovecalculator.py
+++ b/bot/exts/valentines/lovecalculator.py
@@ -4,15 +4,13 @@ import json
import logging
import random
from pathlib import Path
-from typing import Union
+from typing import Coroutine, Union
import discord
from discord import Member
from discord.ext import commands
from discord.ext.commands import BadArgument, Cog, clean_content
-from bot.constants import Roles
-
log = logging.getLogger(__name__)
with Path("bot/resources/valentines/love_matches.json").open(encoding="utf8") as file:
@@ -46,14 +44,11 @@ class LoveCalculator(Cog):
If you want to use multiple words for one argument, you must include quotes.
.love "Zes Vappa" "morning coffee"
-
- If only one argument is provided, the subject will become one of the helpers at random.
"""
if whom is None:
- staff = ctx.guild.get_role(Roles.helpers).members
- whom = random.choice(staff)
+ whom = ctx.author
- def normalize(arg: Union[Member, str]) -> str:
+ def normalize(arg: Union[Member, str]) -> Coroutine:
if isinstance(arg, Member):
# If we are given a member, return name#discrim without any extra changes
arg = str(arg)
diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml
index 1e53429a..f3b2eaa3 100644
--- a/bot/resources/evergreen/py_topics.yaml
+++ b/bot/resources/evergreen/py_topics.yaml
@@ -3,8 +3,6 @@
# python-general
267624335836053506:
- What's your favorite PEP?
- - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python?
- - What functionality is your text editor/IDE missing for programming Python?
- What parts of your life has Python automated, if any?
- Which Python project are you the most proud of making?
- What made you want to learn Python?
@@ -16,23 +14,34 @@
- What feature do you think should be added to Python?
- Has Python helped you in school? If so, how?
- What was the first thing you created with Python?
+ - What is your favorite Python package?
+ - What standard library module is really underrated?
+ - Have you published any packages on PyPi? If so, what are they?
+ - What are you currently working on in Python?
+ - What's your favorite script and how has it helped you in day to day activities?
+ - When you were first learning, what is something that stumped you?
+ - When you were first learning, what is a resource you wish you had?
+ - What is something you know now, that you wish you knew when starting out?
+ - What is something simple that you still error on today?
+
+# algos-and-data-structs
+650401909852864553:
+ -
# async
630504881542791169:
- Are there any frameworks you wish were async?
- How have coroutines changed the way you write Python?
+ - What is your favorite async library?
# c-extensions
728390945384431688:
-
-# computer-science
-650401909852864553:
- -
-
# databases
342318764227821568:
- Where do you get your best data?
+ - What is your preferred database and for what use?
# data-science
366673247892275221:
@@ -45,11 +54,18 @@
- What feature would you be the most interested in making?
- What feature would you like to see added to the library? what feature in the library do you think is redundant?
- Do you think there's a way in which Discord could handle bots better?
+ - What's one feature you wish more developers had in their bots?
+
+# editors-ides
+813178633006350366:
+ - What's your current text editor/IDE, and what functionality do you like about it the most when programming in Python?
+ - What functionality is your text editor/IDE missing for programming Python?
# esoteric-python
470884583684964352:
- What's a common part of programming we can make harder?
- What are the pros and cons of messing with __magic__()?
+ - What's your favorite Python hack?
# game-development
660625198390837248:
@@ -57,7 +73,7 @@
# microcontrollers
545603026732318730:
- -
+ - What is your favorite version of the Raspberry Pi?
# networking
716325106619777044:
@@ -67,23 +83,40 @@
366674035876167691:
- If you could wish for a library involving net-sec, what would it be?
-# software-testing
-463035728335732738:
+# software-design
+782713858615017503:
-
# tools-and-devops
463035462760792066:
- What editor would you recommend to a beginner? Why?
- What editor would you recommend to be the most efficient? Why?
+ - How often do you use GitHub Actions and workflows to automate your repositories?
+ - What's your favorite app on GitHub?
+
+# unit-testing
+463035728335732738:
+ -
# unix
491523972836360192:
- -
+ - What's your favorite Bash command?
+ - What's your most used Bash command?
+ - How often do you update your Unix machine?
+ - How often do you upgrade on production?
# user-interfaces
338993628049571840:
- What's the most impressive Desktop Application you've made with Python so far?
+ - Have you ever made your own GUI? If so, how?
+ - Do you perfer Command Line Interfaces (CLI) or Graphic User Interfaces (GUI)?
+ - What's your favorite CLI (Command Line Interface) or TUI (Terminal Line Interface)?
+ - What's your best GUI project?
# web-development
366673702533988363:
- How has Python helped you in web development?
+ - What tools do you use for web development?
+ - What is your favorite API library?
+ - What do you use for your frontend?
+ - What does your stack look like?
diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml
index 53c89364..949220f9 100644
--- a/bot/resources/evergreen/starter.yaml
+++ b/bot/resources/evergreen/starter.yaml
@@ -20,3 +20,14 @@
- If you had $100 bill in your Easter Basket, what would you do with it?
- What would you do if you know you could succeed at anything you chose to do?
- If you could take only three things from your house, what would they be?
+- What's the best pastry?
+- What's your favourite kind of soup?
+- What is the most useless talent that you have?
+- Would you rather fight 100 duck sized horses or one horse sized duck?
+- What is your favourite color?
+- What's your favourite type of weather?
+- Tea or coffee? What about milk?
+- Do you speak a language other than English?
+- What is your favorite TV show?
+- What is your favorite media genre?
+- How many years have you spent coding?
diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py
index 9cdaad3f..c12a15ff 100644
--- a/bot/utils/decorators.py
+++ b/bot/utils/decorators.py
@@ -13,6 +13,7 @@ from discord.ext.commands import CheckFailure, Command, Context
from bot.constants import ERROR_REPLIES, Month
from bot.utils import human_months, resolve_current_month
+from bot.utils.checks import in_whitelist_check
ONE_DAY = 24 * 60 * 60
@@ -186,82 +187,104 @@ def without_role(*role_ids: int) -> t.Callable:
return commands.check(predicate)
-def in_channel_check(*channels: int, bypass_roles: t.Container[int] = None) -> t.Callable[[Context], bool]:
+def whitelist_check(**default_kwargs: t.Container[int]) -> t.Callable[[Context], bool]:
"""
- Checks that the message is in a whitelisted channel or optionally has a bypass role.
+ Checks if a message is sent in a whitelisted context.
- If `in_channel_override` is present, check if it contains channels
- and use them in place of the global whitelist.
+ All arguments from `in_whitelist_check` are supported, with the exception of "fail_silently".
+ If `whitelist_override` is present, it is added to the global whitelist.
"""
def predicate(ctx: Context) -> bool:
+ # Skip DM invocations
if not ctx.guild:
log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.")
return True
- if ctx.channel.id in channels:
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command "
- f"and the command was used in a whitelisted channel."
- )
- return True
- if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles):
- log.debug(
- f"{ctx.author} called the '{ctx.command.name}' command and "
- f"had a role to bypass the in_channel check."
- )
- return True
+ kwargs = default_kwargs.copy()
- if hasattr(ctx.command.callback, "in_channel_override"):
- override = ctx.command.callback.in_channel_override
- if override is None:
+ # Update kwargs based on override
+ if hasattr(ctx.command.callback, "override"):
+ # Remove default kwargs if reset is True
+ if ctx.command.callback.override_reset:
+ kwargs = {}
log.debug(
- f"{ctx.author} called the '{ctx.command.name}' command "
- f"and the command was whitelisted to bypass the in_channel check."
+ f"{ctx.author} called the '{ctx.command.name}' command and "
+ f"overrode default checks."
)
- return True
- else:
- if ctx.channel.id in override:
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command "
- f"and the command was used in an overridden whitelisted channel."
- )
- return True
- log.debug(
- f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The overridden in_channel check failed."
- )
- channels_str = ', '.join(f"<#{c_id}>" for c_id in override)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
+ # Merge overwrites and defaults
+ for arg in ctx.command.callback.override:
+ default_value = kwargs.get(arg)
+ new_value = ctx.command.callback.override[arg]
+
+ # Skip values that don't need merging, or can't be merged
+ if default_value is None or isinstance(arg, int):
+ kwargs[arg] = new_value
+
+ # Merge containers
+ elif isinstance(default_value, t.Container):
+ if isinstance(new_value, t.Container):
+ kwargs[arg] = (*default_value, *new_value)
+ else:
+ kwargs[arg] = new_value
+
+ log.debug(
+ f"Updated default check arguments for '{ctx.command.name}' "
+ f"invoked by {ctx.author}."
+ )
+
+ log.trace(f"Calling whitelist check for {ctx.author} for command {ctx.command.name}.")
+ result = in_whitelist_check(ctx, fail_silently=True, **kwargs)
+
+ # Return if check passed
+ if result:
+ log.debug(
+ f"{ctx.author} tried to call the '{ctx.command.name}' command "
+ f"and the command was used in an overridden context."
+ )
+ return result
log.debug(
f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The in_channel check failed."
+ f"The whitelist check failed."
)
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
+ # Raise error if the check did not pass
+ channels = set(kwargs.get("channels") or {})
+ categories = kwargs.get("categories")
- return predicate
+ # Add all whitelisted category channels
+ if categories:
+ for category_id in categories:
+ category = ctx.guild.get_channel(category_id)
+ if category is None:
+ continue
+ [channels.add(channel.id) for channel in category.text_channels]
-in_channel = commands.check(in_channel_check)
+ if channels:
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ message = f"Sorry, but you may only use this command within {channels_str}."
+ else:
+ message = "Sorry, but you may not use this command."
+
+ raise InChannelCheckFailure(message)
+
+ return predicate
-def override_in_channel(channels: t.Tuple[int] = None) -> t.Callable:
+def whitelist_override(bypass_defaults: bool = False, **kwargs: t.Container[int]) -> t.Callable:
"""
- Set command callback attribute for detection in `in_channel_check`.
+ Override global whitelist context, with the kwargs specified.
- Override global whitelist if channels are specified.
+ All arguments from `in_whitelist_check` are supported, with the exception of `fail_silently`.
+ Set `bypass_defaults` to True if you want to completely bypass global checks.
This decorator has to go before (below) below the `command` decorator.
"""
def inner(func: t.Callable) -> t.Callable:
- func.in_channel_override = channels
+ func.override = kwargs
+ func.override_reset = bypass_defaults
return func
return inner
diff --git a/docker-compose.yml b/docker-compose.yml
index bb6ad6ac..a18534a5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,13 +12,9 @@ services:
- redis
environment:
- - BOT_TOKEN
- - BOT_DEBUG
- - BOT_GUILD
- - BOT_ADMIN_ROLE_ID
- - CHANNEL_DEVLOG
- - CHANNEL_COMMUNITY_BOT_COMMANDS
- REDIS_HOST=redis
+ env_file:
+ - .env
volumes:
- .:/bot