aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml2
-rw-r--r--CONTRIBUTING.md1
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock315
-rw-r--r--bot/__init__.py218
-rw-r--r--bot/__main__.py19
-rw-r--r--bot/cogs/bigbrother.py186
-rw-r--r--bot/cogs/bot.py35
-rw-r--r--bot/cogs/cogs.py28
-rw-r--r--bot/cogs/defcon.py137
-rw-r--r--bot/cogs/deployment.py18
-rw-r--r--bot/cogs/doc.py41
-rw-r--r--bot/cogs/eval.py8
-rw-r--r--bot/cogs/events.py4
-rw-r--r--bot/cogs/hiphopify.py6
-rw-r--r--bot/cogs/modlog.py658
-rw-r--r--bot/cogs/off_topic_names.py37
-rw-r--r--bot/cogs/snakes.py80
-rw-r--r--bot/cogs/snekbox.py100
-rw-r--r--bot/cogs/tags.py48
-rw-r--r--bot/cogs/utils.py4
-rw-r--r--bot/cogs/verification.py30
-rw-r--r--bot/constants.py98
-rw-r--r--bot/formatter.py152
-rw-r--r--bot/utils/time.py59
-rw-r--r--config-default.yml156
26 files changed, 1638 insertions, 803 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index ea398d508..88ab5d927 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,7 +11,7 @@ test:
stage: test
script:
- - pipenv sync --dev
+ - pipenv install --dev --deploy
- pipenv run lint
build:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f8deaee04..36152fc5d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -39,4 +39,3 @@ requests or changes by contributors, where you believe you have something valuab
## Footnotes
This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md).
-
diff --git a/Pipfile b/Pipfile
index 9b0e530ec..d94234b22 100644
--- a/Pipfile
+++ b/Pipfile
@@ -22,6 +22,7 @@ python-levenshtein = "*"
pillow = "*"
aio-pika = "*"
python-dateutil = "*"
+deepdiff = "*"
[dev-packages]
"flake8" = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 9f4c3a1d4..864eb574a 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "2475ab039e5ce97636b9a255dd7e21b67e7b66d832f341b4065876f7535b2d88"
+ "sha256": "c7d1bad1549c322484f6751447115ded9299df039cb6321bcc1a1fa1359481dc"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:2e44d888956bf64e3d1edd624c02279a19faed3dc0cc3ff2d5af03f4fe15b18a",
- "sha256:c453f233fb734daeb15c7191d7e5aa20a5c5c317d6d2c26607cb419c807fffd8"
+ "sha256:c19c38155f4972f6a9f3f0f1095ce261bfb4e8b89553ead240486593aafd9431",
+ "sha256:d41748994e2f809c440a04a1eb809aaae00691caa8e2dab7376d640131754aa4"
],
"index": "pypi",
- "version": "==2.8.3"
+ "version": "==3.0.1"
},
"aiodns": {
"hashes": [
@@ -53,10 +53,10 @@
},
"alabaster": {
"hashes": [
- "sha256:2eef172f44e8d301d25aff8068fddd65f767a3f04b5f15b0f4922f113aa1c732",
- "sha256:37cdcb9e9954ed60912ebc1ca12a9d12178c26637abdf124e3cde2341c257fe0"
+ "sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456",
+ "sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7"
],
- "version": "==0.7.10"
+ "version": "==0.7.11"
},
"async-timeout": {
"hashes": [
@@ -94,6 +94,15 @@
],
"version": "==3.0.4"
},
+ "deepdiff": {
+ "hashes": [
+ "sha256:152b29dd9cd97cc78403121fb394925ec47377d4a410751e56547c3930ba2b39",
+ "sha256:b4150052e610b231885c4c0be3eea86e4c029df91550ec51b9fc14dd209a5055",
+ "sha256:ecad8e16a96ffd27e8f40c9801a6ab16ec6a7e7e6e6859a7710ba4695f22702c"
+ ],
+ "index": "pypi",
+ "version": "==3.3.0"
+ },
"discord": {
"egg": "discord.py[voice]",
"file": "https://github.com/Rapptz/discord.py/archive/rewrite.zip"
@@ -108,10 +117,10 @@
},
"dulwich": {
"hashes": [
- "sha256:c51e10c260543240e0806052af046e1a78b98cbe1ac1ef3880a78d2269e09da4"
+ "sha256:34f99e575fe1f1e89cca92cec1ddd50b4991199cb00609203b28df9eb83ce259"
],
"index": "pypi",
- "version": "==0.19.2"
+ "version": "==0.19.5"
},
"fuzzywuzzy": {
"hashes": [
@@ -123,10 +132,10 @@
},
"idna": {
"hashes": [
- "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
- "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
+ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
+ "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
- "version": "==2.6"
+ "version": "==2.7"
},
"imagesize": {
"hashes": [
@@ -142,6 +151,12 @@
],
"version": "==2.10"
},
+ "jsonpickle": {
+ "hashes": [
+ "sha256:545b3bee0d65e1abb4baa1818edcc9ec239aa9f2ffbfde8084d71c056180054f"
+ ],
+ "version": "==0.9.6"
+ },
"logmatic-python": {
"hashes": [
"sha256:0c15ac9f5faa6a60059b28910db642c3dc7722948c3cc940923f8c9039604342"
@@ -151,37 +166,35 @@
},
"lxml": {
"hashes": [
- "sha256:01c45df6d90497c20aa2a07789a41941f9a1029faa30bf725fc7f6d515b1afe9",
- "sha256:0c9fef4f8d444e337df96c54544aeb85b7215b2ed7483bb6c35de97ac99f1bcd",
- "sha256:0e3cd94c95d30ba9ca3cff40e9b2a14e1a10a4fd8131105b86c6b61648f57e4b",
- "sha256:0e7996e9b46b4d8b4ac1c329a00e2d10edcd8380b95d2a676fccabf4c1dd0512",
- "sha256:1858b1933d483ec5727549d3fe166eeb54229fbd6a9d3d7ea26d2c8a28048058",
- "sha256:1b164bba1320b14905dcff77da10d5ce9c411ac4acc4fb4ed9a2a4d10fae38c9",
- "sha256:1b46f37927fa6cd1f3fe34b54f1a23bd5bea1d905657289e08e1297069a1a597",
- "sha256:231047b05907315ae9a9b6925751f9fd2c479cf7b100fff62485a25e382ca0d4",
- "sha256:28f0c6652c1b130f1e576b60532f84b19379485eb8da6185c29bd8c9c9bc97bf",
- "sha256:34d49d0f72dd82b9530322c48b70ac78cca0911275da741c3b1d2f3603c5f295",
- "sha256:3682a17fbf72d56d7e46db2e80ca23850b79c28cfe75dcd9b82f58808f730909",
- "sha256:3cf2830b9a6ad7f6e965fa53a768d4d2372a7856f20ffa6ce43d2fe9c0d34b19",
- "sha256:5b653c9379ce29ce271fbe1010c5396670f018e78b643e21beefbb3dc6d291de",
- "sha256:65a272821d5d8194358d6b46f3ca727fa56a6b63981606eac737c86d27309cdd",
- "sha256:691f2cd97cf026c611df1ea5055755eec7f878f2d4f4330dc8686583de6fc5fd",
- "sha256:6b6379495d3baacf7ed755ac68547c8dff6ce5d37bf370f0b7678888dc1283f9",
- "sha256:75322a531504d4f383264391d89993a42e286da8821ddc5ac315e57305cb84f0",
- "sha256:7f457cbda964257f443bac861d3a36732dcba8183149e7818ee2fb7c86901b94",
- "sha256:7ff1fc76d8804e0f870c343a72007ff587090c218b0f92d8ee784ac2b6eaf5b9",
- "sha256:8523fbde9c2216f3f2b950cb01ebe52e785eaa8a07ffeb456dd3576ca1b4fb9b",
- "sha256:8f37627f16e026523fca326f1b5c9a43534862fede6c3e99c2ba6a776d75c1ab",
- "sha256:a7182ea298cc3555ea56ffbb0748fe0d5e0d81451e2bc16d7f4645cd01b1ca70",
- "sha256:abbd2fb4a5a04c11b5e04eb146659a0cf67bb237dd3d7ca3b9994d3a9f826e55",
- "sha256:accc9f6b77bed0a6f267b4fae120f6008a951193d548cdbe9b61fc98a08b1cf8",
- "sha256:bd88c8ce0d1504fdfd96a35911dd4f3edfb2e560d7cfdb5a3d09aa571ae5fbae",
- "sha256:c557ad647facb3c0027a9d0af58853f905e85a0a2f04dcb73f8e665272fcdc3a",
- "sha256:defabb7fbb99f9f7b3e0b24b286a46855caef4776495211b066e9e6592d12b04",
- "sha256:e2629cdbcad82b83922a3488937632a4983ecc0fed3e5cfbf430d069382eeb9b"
+ "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d",
+ "sha256:0b18890aa5730f9d847bc5469e8820f782d72af9985a15a7552109a86b01c113",
+ "sha256:21f427945f612ac75576632b1bb8c21233393c961f2da890d7be3927a4b6085f",
+ "sha256:24cf6f622a4d49851afcf63ac4f0f3419754d4e98a7a548ab48dd03c635d9bd3",
+ "sha256:2dc6705486b8abee1af9e2a3761e30a3cb19e8276f20ca7e137ee6611b93707c",
+ "sha256:2e43b2e5b7d2b9abe6e0301eef2c2c122ab45152b968910eae68bdee2c4cfae0",
+ "sha256:329a6d8b6d36f7d6f8b6c6a1db3b2c40f7e30a19d3caf62023c9d6a677c1b5e1",
+ "sha256:423cde55430a348bda6f1021faad7235c2a95a6bdb749e34824e5758f755817a",
+ "sha256:4651ea05939374cfb5fe87aab5271ed38c31ea47997e17ec3834b75b94bd9f15",
+ "sha256:4be3bbfb2968d7da6e5c2cd4104fc5ec1caf9c0794f6cae724da5a53b4d9f5a3",
+ "sha256:622f7e40faef13d232fb52003661f2764ce6cdef3edb0a59af7c1559e4cc36d1",
+ "sha256:664dfd4384d886b239ef0d7ee5cff2b463831079d250528b10e394a322f141f9",
+ "sha256:697c0f58ac637b11991a1bc92e07c34da4a72e2eda34d317d2c1c47e2f24c1b3",
+ "sha256:6ec908b4c8a4faa7fe1a0080768e2ce733f268b287dfefb723273fb34141475f",
+ "sha256:7ec3fe795582b75bb49bb1685ffc462dbe38d74312dac07ce386671a28b5316b",
+ "sha256:8c39babd923c431dcf1e5874c0f778d3a5c745a62c3a9b6bd755efd489ee8a1d",
+ "sha256:949ca5bc56d6cb73d956f4862ba06ad3c5d2808eac76304284f53ae0c8b2334a",
+ "sha256:9f0daddeefb0791a600e6195441910bdf01eac470be596b9467e6122b51239a6",
+ "sha256:a359893b01c30e949eae0e8a85671a593364c9f0b8162afe0cb97317af0953bf",
+ "sha256:ad5d5d8efed59e6b1d4c50c1eac59fb6ecec91b2073676af1e15fc4d43e9b6c5",
+ "sha256:bc1a36f95a6b3667c09b34995fc3a46a82e4cf0dc3e7ab281e4c77b15bd7af05",
+ "sha256:be37b3f55b6d7d923f43bf74c356fc1878eb36e28505f38e198cb432c19c7b1a",
+ "sha256:c45bca5e544eb75f7500ffd730df72922eb878a2f0213b0dc5a5f357ded3a85d",
+ "sha256:ccee7ebbb4735ebc341d347fca9ee09f2fa6c0580528c1414bc4e1d31372835c",
+ "sha256:dc62c0840b2fc7753550b40405532a3e125c0d3761f34af948873393aa688160",
+ "sha256:f7d9d5aa1c7e54167f1a3cba36b5c52c7c540f30952c9bd7d9302a1eda318424"
],
"index": "pypi",
- "version": "==4.2.1"
+ "version": "==4.2.3"
},
"markdownify": {
"hashes": [
@@ -228,61 +241,41 @@
],
"version": "==17.1"
},
- "pika": {
- "hashes": [
- "sha256:63131aaeec48a6c8f1db1fe657e1e74cf384c3927eb7d1725e31edae4220dea4",
- "sha256:7277b4d12a99efa4058782614d84138983f9f89d690bdfcea66290d810806459"
- ],
- "version": "==0.10.0"
- },
"pillow": {
"hashes": [
- "sha256:00633bc2ec40313f4daf351855e506d296ec3c553f21b66720d0f1225ca84c6f",
- "sha256:03514478db61b034fc5d38b9bf060f994e5916776e93f02e59732a8270069c61",
- "sha256:040144ba422216aecf7577484865ade90e1a475f867301c48bf9fbd7579efd76",
- "sha256:16246261ff22368e5e32ad74d5ef40403ab6895171a7fc6d34f6c17cfc0f1943",
- "sha256:1cb38df69362af35c14d4a50123b63c7ff18ec9a6d4d5da629a6f19d05e16ba8",
- "sha256:2400e122f7b21d9801798207e424cbe1f716cee7314cd0c8963fdb6fc564b5fb",
- "sha256:2ee6364b270b56a49e8b8a51488e847ab130adc1220c171bed6818c0d4742455",
- "sha256:3b4560c3891b05022c464b09121bd507c477505a4e19d703e1027a3a7c68d896",
- "sha256:41374a6afb3f44794410dab54a0d7175e6209a5a02d407119c81083f1a4c1841",
- "sha256:438a3faf5f702c8d0f80b9f9f9b8382cfa048ca6a0d64ef71b86b563b0ee0359",
- "sha256:472a124c640bde4d5468f6991c9fa7e30b723d84ac4195a77c6ab6aea30f2b9c",
- "sha256:4d32c8e3623a61d6e29ccd024066cd1ba556555abfb4cd714155020e00107e3f",
- "sha256:4d8077fd649ac40a5c4165f2c22fa2a4ad18c668e271ecb2f9d849d1017a9313",
- "sha256:62ec7ae98357fcd46002c110bb7cad15fce532776f0cbe7ca1d44c49b837d49d",
- "sha256:6c7cab6a05351cf61e469937c49dbf3cdf5ffb3eeac71f8d22dc9be3507598d8",
- "sha256:6eca36905444c4b91fe61f1b9933a47a30480738a1dd26501ff67d94fc2bc112",
- "sha256:74e2ebfd19c16c28ad43b8a28ff73b904ed382ea4875188838541751986e8c9a",
- "sha256:7673e7473a13107059377c96c563aa36f73184c29d2926882e0a0210b779a1e7",
- "sha256:81762cf5fca9a82b53b7b2d0e6b420e0f3b06167b97678c81d00470daa622d58",
- "sha256:8554bbeb4218d9cfb1917c69e6f2d2ad0be9b18a775d2162547edf992e1f5f1f",
- "sha256:9b66e968da9c4393f5795285528bc862c7b97b91251f31a08004a3c626d18114",
- "sha256:a00edb2dec0035e98ac3ec768086f0b06dfabb4ad308592ede364ef573692f55",
- "sha256:b48401752496757e95304a46213c3155bc911ac884bed2e9b275ce1c1df3e293",
- "sha256:b6cf18f9e653a8077522bb3aa753a776b117e3e0cc872c25811cfdf1459491c2",
- "sha256:bb8adab1877e9213385cbb1adc297ed8337e01872c42a30cfaa66ff8c422779c",
- "sha256:c8a4b39ba380b57a31a4b5449a9d257b1302d8bc4799767e645dcee25725efe1",
- "sha256:cee9bc75bff455d317b6947081df0824a8f118de2786dc3d74a3503fd631f4ef",
- "sha256:d0dc1313dff48af64517cbbd85e046d6b477fbe5e9d69712801f024dcb08c62b",
- "sha256:d5bf527ed83617edd1855a5c923eeeaf68bcb9ac0ceb28e3f19b575b3a424984",
- "sha256:df5863a21f91de5ecdf7d32a32f406dd9867ebb35d41033b8bd9607a21887599",
- "sha256:e39142332541ed2884c257495504858b22c078a5d781059b07aba4c3a80d7551",
- "sha256:e52e8f675ba0b2b417fa98579e7286a41a8e23871f17f4793772f5aa884fea79",
- "sha256:e6dd55d5d94b9e36929325dd0c9ab85bfde84a5fc35947c334c32af1af668944",
- "sha256:e87cc1acbebf263f308a8494272c2d42016aa33c32bf14d209c81e1f65e11868",
- "sha256:ea0091cd4100519cedfeea2c659f52291f535ac6725e2368bcf59e874f270efa",
- "sha256:eeb247f4f4d962942b3b555530b0c63b77473c7bfe475e51c6b75b7344b49ce3",
- "sha256:f0d4433adce6075efd24fc0285135248b0b50f5a58129c7e552030e04fe45c7f",
- "sha256:f1f3bd92f8e12dc22884935a73c9f94c4d9bd0d34410c456540713d6b7832b8c",
- "sha256:f42a87cbf50e905f49f053c0b1fb86c911c730624022bf44c8857244fc4cdaca",
- "sha256:f5f302db65e2e0ae96e26670818157640d3ca83a3054c290eff3631598dcf819",
- "sha256:f7634d534662bbb08976db801ba27a112aee23e597eeaf09267b4575341e45bf",
- "sha256:fdd374c02e8bb2d6468a85be50ea66e1c4ef9e809974c30d8576728473a6ed03",
- "sha256:fe6931db24716a0845bd8c8915bd096b77c2a7043e6fc59ae9ca364fe816f08b"
+ "sha256:00def5b638994f888d1058e4d17c86dec8e1113c3741a0a8a659039aec59a83a",
+ "sha256:026449b64e559226cdb8e6d8c931b5965d8fc90ec18ebbb0baa04c5b36503c72",
+ "sha256:03dbb224ee196ef30ed2156d41b579143e1efeb422974719a5392fc035e4f574",
+ "sha256:03eb0e04f929c102ae24bc436bf1c0c60a4e63b07ebd388e84d8b219df3e6acd",
+ "sha256:1be66b9a89e367e7d20d6cae419794997921fe105090fafd86ef39e20a3baab2",
+ "sha256:1e977a3ed998a599bda5021fb2c2889060617627d3ae228297a529a082a3cd5c",
+ "sha256:22cf3406d135cfcc13ec6228ade774c8461e125c940e80455f500638429be273",
+ "sha256:24adccf1e834f82718c7fc8e3ec1093738da95144b8b1e44c99d5fc7d3e9c554",
+ "sha256:2a3e362c97a5e6a259ee9cd66553292a1f8928a5bdfa3622fdb1501570834612",
+ "sha256:3832e26ecbc9d8a500821e3a1d3765bda99d04ae29ffbb2efba49f5f788dc934",
+ "sha256:4fd1f0c2dc02aaec729d91c92cd85a2df0289d88e9f68d1e8faba750bb9c4786",
+ "sha256:4fda62030f2c515b6e2e673c57caa55cb04026a81968f3128aae10fc28e5cc27",
+ "sha256:5044d75a68b49ce36a813c82d8201384207112d5d81643937fc758c05302f05b",
+ "sha256:522184556921512ec484cb93bd84e0bab915d0ac5a372d49571c241a7f73db62",
+ "sha256:5914cff11f3e920626da48e564be6818831713a3087586302444b9c70e8552d9",
+ "sha256:6661a7908d68c4a133e03dac8178287aa20a99f841ea90beeb98a233ae3fd710",
+ "sha256:79258a8df3e309a54c7ef2ef4a59bb8e28f7e4a8992a3ad17c24b1889ced44f3",
+ "sha256:7d74c20b8f1c3e99d3f781d3b8ff5abfefdd7363d61e23bdeba9992ff32cc4b4",
+ "sha256:81918afeafc16ba5d9d0d4e9445905f21aac969a4ebb6f2bff4b9886da100f4b",
+ "sha256:8194d913ca1f459377c8a4ed8f9b7ad750068b8e0e3f3f9c6963fcc87a84515f",
+ "sha256:84d5d31200b11b3c76fab853b89ac898bf2d05c8b3da07c1fcc23feb06359d6e",
+ "sha256:989981db57abffb52026b114c9a1f114c7142860a6d30a352d28f8cbf186500b",
+ "sha256:a3d7511d3fad1618a82299aab71a5fceee5c015653a77ffea75ced9ef917e71a",
+ "sha256:b3ef168d4d6fd4fa6685aef7c91400f59f7ab1c0da734541f7031699741fb23f",
+ "sha256:c1c5792b6e74bbf2af0f8e892272c2a6c48efa895903211f11b8342e03129fea",
+ "sha256:c5dcb5a56aebb8a8f2585042b2f5c496d7624f0bcfe248f0cc33ceb2fd8d39e7",
+ "sha256:e2bed4a04e2ca1050bb5f00865cf2f83c0b92fd62454d9244f690fcd842e27a4",
+ "sha256:e87a527c06319428007e8c30511e1f0ce035cb7f14bb4793b003ed532c3b9333",
+ "sha256:f63e420180cbe22ff6e32558b612e75f50616fc111c5e095a4631946c782e109",
+ "sha256:f8b3d413c5a8f84b12cd4c5df1d8e211777c9852c6be3ee9c094b626644d3eab"
],
"index": "pypi",
- "version": "==5.1.0"
+ "version": "==5.2.0"
},
"pycares": {
"hashes": [
@@ -320,11 +313,6 @@
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
- "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
- "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
- "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
- "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
- "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
],
"version": "==2.2.0"
@@ -339,10 +327,11 @@
},
"python-json-logger": {
"hashes": [
- "sha256:30999d1d742ecf6645991a2ce9273188505e98b713ad63be06aabff47dd1b3c4",
- "sha256:8205cfe7061715de5cd1b37e3565d5b97d0ac13b30ff3ee612554abb6093d640"
+ "sha256:a292e22c5e03105a05a746ade6209d43db1c4c763b91c75c8486e81d10904d85",
+ "sha256:e3636824d35ba6a15fc39f573588cba63cf46322a5dc86fb2f280229077e9fbe"
],
- "version": "==0.1.8"
+ "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'",
+ "version": "==0.1.9"
},
"python-levenshtein": {
"hashes": [
@@ -353,37 +342,34 @@
},
"pytz": {
"hashes": [
- "sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555",
- "sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749"
+ "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
+ "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
],
- "version": "==2018.4"
+ "version": "==2018.5"
},
"pyyaml": {
"hashes": [
- "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
- "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
- "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
- "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
- "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
- "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
- "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
- "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
- "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
- "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
- "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
- "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
- "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
- "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
+ "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b",
+ "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf",
+ "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a",
+ "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3",
+ "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1",
+ "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1",
+ "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613",
+ "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04",
+ "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f",
+ "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537",
+ "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"
],
"index": "pypi",
- "version": "==3.12"
+ "version": "==3.13"
},
"requests": {
"hashes": [
- "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
- "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
+ "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
+ "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
],
- "version": "==2.18.4"
+ "version": "==2.19.1"
},
"shortuuid": {
"hashes": [
@@ -407,32 +393,34 @@
},
"sphinx": {
"hashes": [
- "sha256:85f7e32c8ef07f4ba5aeca728e0f7717bef0789fba8458b8d9c5c294cad134f3",
- "sha256:d45480a229edf70d84ca9fae3784162b1bc75ee47e480ffe04a4b7f21a95d76d"
+ "sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc",
+ "sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896"
],
"index": "pypi",
- "version": "==1.7.5"
+ "version": "==1.7.6"
},
"sphinxcontrib-websupport": {
"hashes": [
- "sha256:7a85961326aa3a400cd4ad3c816d70ed6f7c740acd7ce5d78cd0a67825072eb9",
- "sha256:f4932e95869599b89bf4f80fc3989132d83c9faa5bf633e7b5e0c25dffb75da2"
+ "sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
+ "sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
],
- "version": "==1.0.1"
+ "markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'",
+ "version": "==1.1.0"
},
"sympy": {
"hashes": [
- "sha256:ac5b57691bc43919dcc21167660a57cc51797c28a4301a6144eff07b751216a4"
+ "sha256:286ca070d72e250861dea7a21ab44f541cb2341e8268c70264cf8642dbd9225f"
],
"index": "pypi",
- "version": "==1.1.1"
+ "version": "==1.2"
},
"urllib3": {
"hashes": [
- "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
- "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
+ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
+ "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
],
- "version": "==1.22"
+ "markers": "python_version != '3.0.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*'",
+ "version": "==1.23"
},
"websockets": {
"hashes": [
@@ -537,11 +525,11 @@
},
"flake8-import-order": {
"hashes": [
- "sha256:40d2a39ed91e080f3285f4c16256b252d7c31070e7f11b7854415bb9f924ea81",
- "sha256:68d430781a9ef15c85a0121500cf8462f1a4bc7672acb2a32bfdbcab044ae0b7"
+ "sha256:9be5ca10d791d458eaa833dd6890ab2db37be80384707b0f76286ddd13c16cbf",
+ "sha256:feca2fd0a17611b33b7fa84449939196c2c82764e262486d5c3e143ed77d387b"
],
"index": "pypi",
- "version": "==0.17.1"
+ "version": "==0.18"
},
"flake8-string-format": {
"hashes": [
@@ -568,10 +556,10 @@
},
"idna": {
"hashes": [
- "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
- "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
+ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
+ "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
- "version": "==2.6"
+ "version": "==2.7"
},
"mccabe": {
"hashes": [
@@ -604,49 +592,41 @@
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
- "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
- "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
- "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
- "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
- "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
],
"version": "==2.2.0"
},
"pyyaml": {
"hashes": [
- "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
- "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
- "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
- "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
- "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
- "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
- "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
- "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
- "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
- "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
- "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
- "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
- "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
- "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
+ "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b",
+ "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf",
+ "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a",
+ "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3",
+ "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1",
+ "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1",
+ "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613",
+ "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04",
+ "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f",
+ "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537",
+ "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"
],
"index": "pypi",
- "version": "==3.12"
+ "version": "==3.13"
},
"requests": {
"hashes": [
- "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
- "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
+ "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
+ "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
],
- "version": "==2.18.4"
+ "version": "==2.19.1"
},
"safety": {
"hashes": [
- "sha256:0bd2a26b872668767c6db8efecfc8869b547463bedff5e7cd7b52f037aa6f200",
- "sha256:fc3fc55656f1c909d65311b49a38211c42c937f57a05393289fb3f17cadfa4a1"
+ "sha256:32d41b8bbd736db749aa2162de6c0bb11c2113c7bc0357476491f96cd5d58299",
+ "sha256:34227360409ffb1bc2657e5b6ff3472a32d72b917617cd3d2914ddf078c263b9"
],
"index": "pypi",
- "version": "==1.8.1"
+ "version": "==1.8.2"
},
"six": {
"hashes": [
@@ -657,10 +637,11 @@
},
"urllib3": {
"hashes": [
- "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
- "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
+ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
+ "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
],
- "version": "==1.22"
+ "markers": "python_version != '3.0.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*'",
+ "version": "==1.23"
}
}
}
diff --git a/bot/__init__.py b/bot/__init__.py
index d446897b1..a87d31541 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,11 +1,8 @@
-import ast
import logging
import os
-import re
import sys
from logging import Logger, StreamHandler, handlers
-import discord.ext.commands.view
from logmatic import JsonFormatter
logging.TRACE = 5
@@ -97,218 +94,3 @@ logging.getLogger("discord.gateway").setLevel(logging.ERROR)
logging.getLogger("discord.state").setLevel(logging.ERROR)
logging.getLogger("discord.http").setLevel(logging.ERROR)
logging.getLogger("websockets.protocol").setLevel(logging.ERROR)
-
-
-def _skip_string(self, string: str) -> bool:
- """
- Our version of the skip_string method from
- discord.ext.commands.view; used to find
- the prefix in a message, but allowing prefix
- to ignore case sensitivity
- """
-
- strlen = len(string)
- if self.buffer.lower()[self.index:self.index + strlen] == string:
- self.previous = self.index
- self.index += strlen
- return True
- return False
-
-
-def _get_word(self) -> str:
- """
- Invokes the get_word method from
- discord.ext.commands.view used to find
- the bot command part of a message, but
- allows the command to ignore case sensitivity,
- and allows commands to have Python syntax.
- """
-
- def parse_python(buffer_pos):
- """
- Takes the instance of the view and parses the buffer, if it contains valid python syntax.
- This may fail spectacularly with a SyntaxError, which must be caught by the caller.
-
- Example of valid Python syntax calls:
- ------------------------------
- bot.tags.set("test", 'a dark, dark night')
- bot.help(tags.delete)
- bot.hELP(tags.delete)
- bot.tags['internet']
- bot.tags['internet'] = "A series of tubes"
-
- :return: the parsed command
- """
-
- # Check what's after the '(' or '['
- next_char = None
- if len(self.buffer) - 1 != self.index:
- next_char = self.buffer[self.index + 1]
-
- # Catch raw channel, member or role mentions and wrap them in quotes.
- tempbuffer = self.buffer
- tempbuffer = re.sub(r"(<(?:@|@!|[#&])\d+>)",
- r'"\1"',
- tempbuffer)
-
- # Let's parse!
- log.debug("A python-style command was used. Attempting to parse. "
- f"Buffer is '{self.buffer}'. Tempbuffer is '{tempbuffer}'. "
- "A step-by-step can be found in the trace log.")
-
- if current == "(" and next_char == ")":
- # Move the cursor to capture the ()'s
- log.debug("User called command without providing arguments.")
- buffer_pos += 2
- parsed_result = self.buffer[self.previous:self.index + (buffer_pos+2)]
- self.index += 2
- return parsed_result
-
- elif current == "(" and next_char:
-
- # Parse the args
- log.trace(f"Parsing command with ast.literal_eval. args are {tempbuffer[self.index:]}")
- args = tempbuffer[self.index:]
- args = ast.literal_eval(args)
-
- # Return what we'd return for a non-python syntax call
- log.trace(f"Returning {self.buffer[self.previous:self.index]}")
- parsed_result = self.buffer[self.previous:self.index]
-
- elif current == "(" or current == "[" and not next_char:
-
- # Just remove the start bracket
- log.debug("User called command with a single bracket. Removing bracket.")
- parsed_result = self.buffer[self.previous:self.index]
- args = None
-
- # Check if a command in the form of `bot.tags['ask']`
- # or alternatively `bot.tags['ask'] = 'whatever'` was used.
- elif current == "[":
-
- # Syntax is `bot.tags['ask']` => mimic `getattr`
- log.trace(f"Got a command candidate for getitem / setitem parsing: {self.buffer}")
- if self.buffer.endswith("]"):
-
- # Key: The first argument, specified `bot.tags[here]`
- key = tempbuffer[self.index + 1:tempbuffer.rfind("]")]
- log.trace(f"Command mimicks getitem. Key: {key!r}")
- args = ast.literal_eval(key)
-
- # Use the cogs `.get` method.
- parsed_result = self.buffer[self.previous:self.index] + ".get"
-
- # Syntax is `bot.tags['ask'] = 'whatever'` => mimic `setattr`
- elif "=" in self.buffer and not self.buffer.endswith("="):
- equals_pos = tempbuffer.find("=")
- closing_bracket_pos = tempbuffer.rfind("]", 0, equals_pos)
-
- # Key: The first argument, specified `bot.tags[here]`
- key_contents = tempbuffer[self.index + 1:closing_bracket_pos]
- key = ast.literal_eval(key_contents)
-
- # Value: The second argument, specified after the `=`
- right_hand = tempbuffer.split("=", maxsplit=1)[1].strip()
- value = ast.literal_eval(right_hand)
-
- # If the value is a falsy value - mimick `bot.tags.delete(key)`
- if not value:
- log.trace(f"Command mimicks delitem. Key: {key!r}.")
- parsed_result = self.buffer[self.previous:self.index] + ".delete"
- args = key
-
- # Otherwise, assume assignment, for example `bot.tags['this'] = 'that'`
- else:
- log.trace(f"Command mimicks setitem. Key: {key!r}, value: {value!r}.")
- parsed_result = self.buffer[self.previous:self.index] + ".set"
- args = (key, value)
-
- # Syntax is god knows what, pass it along
- else:
- parsed_result = self.buffer
- args = ''
- log.trace(f"Command is of unknown syntax: {self.buffer}")
-
- # Args handling
- new_args = []
-
- if args:
- # Force args into container
- if not isinstance(args, tuple):
- args = (args,)
-
- # Type validate and format
- for arg in args:
-
- # Other types get converted to strings
- if not isinstance(arg, str):
- log.trace(f"{arg} is not a str, casting to str.")
- arg = str(arg)
-
- # Allow using double quotes within triple double quotes
- arg = arg.replace('"', '\\"')
-
- # Adding double quotes to every argument
- log.trace("Wrapping all args in double quotes.")
- new_args.append(f'"{arg}"')
-
- # Reconstruct valid discord.py syntax
- prefix = self.buffer[:self.previous]
- self.buffer = f"{prefix}{parsed_result}"
-
- if new_args:
- self.buffer += (" " + " ".join(new_args))
-
- self.index = len(f"{prefix}{parsed_result}")
- self.end = len(self.buffer)
- log.trace(f"Modified the buffer. New buffer is now '{self.buffer}'")
-
- return parsed_result
-
- # Iterate through the buffer and determine
- pos = 0
- current = None
- while not self.eof:
- try:
- current = self.buffer[self.index + pos]
- if current.isspace() or current == "(" or current == "[":
- break
- pos += 1
- except IndexError:
- break
-
- self.previous = self.index
- result = self.buffer[self.index:self.index + pos]
- self.index += pos
-
- # If the command looks like a python syntax command, try to parse it.
- if current == "(" or current == "[":
- try:
- result = parse_python(pos)
-
- except SyntaxError:
- log.debug(
- "A SyntaxError was encountered while parsing a python-syntaxed command:"
- "\nTraceback (most recent call last):\n"
- ' File "<stdin>", line 1, in <module>\n'
- f" {self.buffer}\n"
- f" {' ' * self.index}^\n"
- "SyntaxError: invalid syntax"
- )
- return
-
- except ValueError:
- log.debug(
- "A ValueError was encountered while parsing a python-syntaxed command:"
- "\nTraceback (most recent call last):\n"
- ' File "<stdin>", line 1, in <module>\n'
- f"ValueError: could not ast.literal_eval the following: '{self.buffer}'"
- )
- return
-
- return result
-
-
-# Monkey patch the methods
-discord.ext.commands.view.StringView.skip_string = _skip_string
-discord.ext.commands.view.StringView.get_word = _get_word
diff --git a/bot/__main__.py b/bot/__main__.py
index f470a42d6..4429c2a0d 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -6,25 +6,16 @@ from discord import Game
from discord.ext.commands import Bot, when_mentioned_or
from bot.constants import Bot as BotConfig # , ClickUp
-from bot.formatter import Formatter
from bot.utils.service_discovery import wait_for_rmq
log = logging.getLogger(__name__)
bot = Bot(
- command_prefix=when_mentioned_or(
- "self.", "bot."
- ),
- activity=Game(
- name="Help: bot.help()"
- ),
- help_attrs={
- "name": "help()",
- "aliases": ["help"]
- },
- formatter=Formatter(),
- case_insensitive=True
+ command_prefix=when_mentioned_or("!"),
+ activity=Game(name="Commands: !help"),
+ case_insensitive=True,
+ max_messages=10_000
)
# Global aiohttp session for all cogs
@@ -47,10 +38,12 @@ else:
# Internal/debug
bot.load_extension("bot.cogs.logging")
+bot.load_extension("bot.cogs.modlog")
bot.load_extension("bot.cogs.security")
bot.load_extension("bot.cogs.events")
# Commands, etc
+bot.load_extension("bot.cogs.bigbrother")
bot.load_extension("bot.cogs.bot")
bot.load_extension("bot.cogs.cogs")
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
new file mode 100644
index 000000000..523e85f1d
--- /dev/null
+++ b/bot/cogs/bigbrother.py
@@ -0,0 +1,186 @@
+import logging
+from typing import List, Union
+
+from discord import Color, Embed, Guild, Member, Message, TextChannel, User
+from discord.ext.commands import Bot, Context, group
+
+from bot.constants import Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs
+from bot.decorators import with_role
+from bot.pagination import LinePaginator
+
+
+log = logging.getLogger(__name__)
+
+
+class BigBrother:
+ """User monitoring to assist with moderation."""
+
+ HEADERS = {'X-API-Key': Keys.site_api}
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.watched_users = {}
+
+ def update_cache(self, api_response: List[dict]):
+ """
+ Updates the internal cache of watched users from the given `api_response`.
+ This function will only add (or update) existing keys, it will not delete
+ keys that were not present in the API response.
+ A user is only added if the bot can find a channel
+ with the given `channel_id` in its channel cache.
+ """
+
+ for entry in api_response:
+ user_id = int(entry['user_id'])
+ channel_id = int(entry['channel_id'])
+ channel = self.bot.get_channel(channel_id)
+
+ if channel is not None:
+ self.watched_users[user_id] = channel
+ else:
+ log.error(
+ f"Site specified to relay messages by `{user_id}` in `{channel_id}`, "
+ "but the given channel could not be found. Ignoring."
+ )
+
+ async def on_ready(self):
+ async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
+ data = await response.json()
+ self.update_cache(data)
+
+ async def on_member_ban(self, guild: Guild, user: Union[User, Member]):
+ if guild.id == GuildConfig.id and user.id in self.watched_users:
+ url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
+ channel = self.watched_users[user.id]
+
+ async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
+ del self.watched_users[user.id]
+ if response.status == 204:
+ await channel.send(
+ f"{Emojis.lemoneye2}:hammer: {user} got banned, so "
+ f"`BigBrother` will no longer relay their messages to {channel}"
+ )
+
+ else:
+ data = await response.json()
+ reason = data.get('error_message', "no message provided")
+ await channel.send(
+ f"{Emojis.lemoneye2}:x: {user} got banned, but trying to remove them from"
+ f"BigBrother's user dictionary on the API returned an error: {reason}"
+ )
+
+ async def on_message(self, msg: Message):
+ if msg.author.id in self.watched_users:
+ channel = self.watched_users[msg.author.id]
+ relay_content = (f"{Emojis.lemoneye2} {msg.author} sent the following "
+ f"in {msg.channel.mention}: {msg.clean_content}")
+ if msg.attachments:
+ relay_content += f" (with {len(msg.attachments)} attachment(s))"
+
+ await channel.send(relay_content)
+
+ @group(name='bigbrother', aliases=('bb',))
+ async def bigbrother_group(self, ctx: Context):
+ """Monitor users, NSA-style."""
+
+ @bigbrother_group.command(name='watched', aliases=('all',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watched_command(self, ctx: Context, from_cache: bool = True):
+ """
+ Shows all users that are currently monitored and in which channel.
+ By default, the users are returned from the cache.
+ If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'.
+ """
+
+ if from_cache:
+ lines = tuple(
+ f"• <@{user_id}> in <#{self.watched_users[user_id].id}>"
+ for user_id in self.watched_users
+ )
+ await LinePaginator.paginate(
+ lines or ("There's nothing here yet.",),
+ ctx,
+ Embed(title="Watched users (cached)", color=Color.blue()),
+ empty=False
+ )
+
+ else:
+ async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
+ if response.status == 200:
+ data = await response.json()
+ self.update_cache(data)
+ lines = tuple(f"• <@{entry['user_id']}> in <#{entry['channel_id']}>" for entry in data)
+
+ await LinePaginator.paginate(
+ lines or ("There's nothing here yet.",),
+ ctx,
+ Embed(title="Watched users", color=Color.blue()),
+ empty=False
+ )
+
+ else:
+ await ctx.send(f":x: got non-200 response from the API")
+
+ @bigbrother_group.command(name='watch', aliases=('w',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watch_command(self, ctx: Context, user: User, channel: TextChannel = None):
+ """
+ Relay messages sent by the given `user` in the given `channel`.
+ If `channel` is not specified, logs to the mod log channel.
+ """
+
+ if channel is not None:
+ channel_id = channel.id
+ else:
+ channel_id = Channels.big_brother_logs
+
+ post_data = {
+ 'user_id': str(user.id),
+ 'channel_id': str(channel_id)
+ }
+
+ async with self.bot.http_session.post(
+ URLs.site_bigbrother_api,
+ headers=self.HEADERS,
+ json=post_data
+ ) as response:
+ if response.status == 204:
+ await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>")
+
+ channel = self.bot.get_channel(channel_id)
+ if channel is None:
+ log.error(
+ f"could not update internal cache, failed to find a channel with ID {channel_id}"
+ )
+ else:
+ self.watched_users[user.id] = channel
+
+ else:
+ data = await response.json()
+ reason = data.get('error_message', "no message provided")
+ await ctx.send(f":x: the API returned an error: {reason}")
+
+ @bigbrother_group.command(name='unwatch', aliases=('uw',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def unwatch_command(self, ctx: Context, user: User):
+ """Stop relaying messages by the given `user`."""
+
+ url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
+ async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
+ if response.status == 204:
+ await ctx.send(f":ok_hand: will no longer relay messages sent by {user}")
+
+ if user.id in self.watched_users:
+ del self.watched_users[user.id]
+ else:
+ log.warning(f"user {user.id} was unwatched but was not found in the cache")
+
+ else:
+ data = await response.json()
+ reason = data.get('error_message', "no message provided")
+ await ctx.send(f":x: the API returned an error: {reason}")
+
+
+def setup(bot: Bot):
+ bot.add_cog(BigBrother(bot))
+ log.info("Cog loaded: BigBrother")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index af9abd7cb..2f8600c06 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -25,10 +25,12 @@ class Bot:
# Stores allowed channels plus epoch time since last call.
self.channel_cooldowns = {
- Channels.help0: 0,
- Channels.help1: 0,
- Channels.help2: 0,
- Channels.help3: 0,
+ Channels.help_0: 0,
+ Channels.help_1: 0,
+ Channels.help_2: 0,
+ Channels.help_3: 0,
+ Channels.help_4: 0,
+ Channels.help_5: 0,
Channels.python: 0,
}
@@ -47,15 +49,15 @@ class Bot:
await ctx.invoke(self.bot.get_command("help"), "bot")
- @bot_group.command(aliases=["about"], hidden=True)
+ @bot_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
- async def info(self, ctx: Context):
+ async def about_command(self, ctx: Context):
"""
Get information about the bot
"""
embed = Embed(
- description="A utility bot designed just for the Python server! Try `bot.help()` for more info.",
+ description="A utility bot designed just for the Python server! Try `!help()` for more info.",
url="https://gitlab.com/discord-python/projects/bot"
)
@@ -71,30 +73,21 @@ class Bot:
icon_url=URLs.bot_avatar
)
- log.info(f"{ctx.author} called bot.about(). Returning information about the bot.")
+ log.info(f"{ctx.author} called !about. Returning information about the bot.")
await ctx.send(embed=embed)
- @command(name="info()", aliases=["info", "about()", "about"])
- @with_role(Roles.verified)
- async def info_wrapper(self, ctx: Context):
- """
- Get information about the bot
- """
-
- await ctx.invoke(self.info)
-
- @command(name="print()", aliases=["print", "echo", "echo()"])
+ @command(name='echo', aliases=('print',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def echo_command(self, ctx: Context, text: str):
+ async def echo_command(self, ctx: Context, *, text: str):
"""
Send the input verbatim to the current channel
"""
await ctx.send(text)
- @command(name="embed()", aliases=["embed"])
+ @command(name='embed')
@with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def embed_command(self, ctx: Context, text: str):
+ async def embed_command(self, ctx: Context, *, text: str):
"""
Send the input within an embed to the current channel
"""
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
index 7eaf5005c..80b8607a4 100644
--- a/bot/cogs/cogs.py
+++ b/bot/cogs/cogs.py
@@ -2,7 +2,7 @@ import logging
import os
from discord import ClientException, Colour, Embed
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Context, group
from bot.constants import (
Emojis, Roles, URLs,
@@ -12,6 +12,8 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
+KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"]
+
class Cogs:
"""
@@ -34,13 +36,17 @@ class Cogs:
# Allow reverse lookups by reversing the pairs
self.cogs.update({v: k for k, v in self.cogs.items()})
- @command(name="cogs.load()", aliases=["cogs.load", "load_cog"])
+ @group(name='cogs', aliases=('c',))
+ async def cogs_group(self, ctx: Context):
+ """Load, unload, reload, and list active cogs."""
+
+ @cogs_group.command(name='load', aliases=('l',))
@with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
async def load_command(self, ctx: Context, cog: str):
"""
Load up an unloaded cog, given the module containing it
- You can specify the cog name for any cogs that are placed directly within `bot.cogs`, or specify the
+ You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
"""
@@ -91,13 +97,13 @@ class Cogs:
await ctx.send(embed=embed)
- @command(name="cogs.unload()", aliases=["cogs.unload", "unload_cog"])
+ @cogs_group.command(name='unload', aliases=('ul',))
@with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
async def unload_command(self, ctx: Context, cog: str):
"""
Unload an already-loaded cog, given the module containing it
- You can specify the cog name for any cogs that are placed directly within `bot.cogs`, or specify the
+ You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
"""
@@ -122,9 +128,9 @@ class Cogs:
embed.description = f"Unknown cog: {cog}"
if full_cog:
- if full_cog == "bot.cogs.cogs":
- log.warning(f"{ctx.author} requested we unload the cog management cog, that sneaky pete. We said no.")
- embed.description = "You may not unload the cog management cog!"
+ if full_cog in KEEP_LOADED:
+ log.warning(f"{ctx.author} requested we unload `{full_cog}`, that sneaky pete. We said no.")
+ embed.description = f"You may not unload `{full_cog}`!"
elif full_cog in self.bot.extensions:
try:
self.bot.unload_extension(full_cog)
@@ -143,13 +149,13 @@ class Cogs:
await ctx.send(embed=embed)
- @command(name="cogs.reload()", aliases=["cogs.reload", "reload_cog"])
+ @cogs_group.command(name='reload', aliases=('r',))
@with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
async def reload_command(self, ctx: Context, cog: str):
"""
Reload an unloaded cog, given the module containing it
- You can specify the cog name for any cogs that are placed directly within `bot.cogs`, or specify the
+ You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the
@@ -248,7 +254,7 @@ class Cogs:
await ctx.send(embed=embed)
- @command(name="cogs.list()", aliases=["cogs", "cogs.list", "cogs()"])
+ @cogs_group.command(name='list', aliases=('all',))
@with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
async def list_command(self, ctx: Context):
"""
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index ea50bdf63..054a93e63 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -2,13 +2,17 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed, Member
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Context, group
-from bot.constants import Channels, Keys, Roles, URLs
+from bot.cogs.modlog import ModLog
+from bot.constants import Channels, Emojis, Icons, Keys, Roles, URLs
from bot.decorators import with_role
log = logging.getLogger(__name__)
+COLOUR_RED = Colour(0xcd6d6d)
+COLOUR_GREEN = Colour(0x68c290)
+
REJECTION_MESSAGE = """
Hi, {user} - Thanks for your interest in our server!
@@ -31,6 +35,10 @@ class Defcon:
self.days = timedelta(days=0)
self.headers = {"X-API-KEY": Keys.site_api}
+ @property
+ def modlog(self) -> ModLog:
+ return self.bot.get_cog("ModLog")
+
async def on_ready(self):
try:
response = await self.bot.http_session.get(
@@ -65,20 +73,43 @@ class Defcon:
if now - member.created_at < self.days:
log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled")
+ message_sent = False
+
try:
await member.send(REJECTION_MESSAGE.format(user=member.mention))
+
+ message_sent = True
except Exception:
log.exception(f"Unable to send rejection message to user: {member}")
await member.kick(reason="DEFCON active, user is too new")
+ message = (
+ f"{member.name}#{member.discriminator} (`{member.id}`) "
+ f"was denied entry because their account is too new."
+ )
+
+ if not message_sent:
+ message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled."
+
+ await self.modlog.send_log_message(
+ Icons.defcon_denied, COLOUR_RED, "Entry denied",
+ message, member.avatar_url_as(static_format="png")
+ )
+
+ @group(name='defcon', aliases=('dc',), invoke_without_command=True)
+ async def defcon_group(self, ctx: Context):
+ """Check the DEFCON status or run a subcommand."""
+
+ await ctx.invoke(self.status_command)
+
+ @defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admin, Roles.owner)
- @command(name="defcon.enable", aliases=["defcon.enable()", "defcon_enable", "defcon_enable()"])
- async def enable(self, ctx: Context):
+ async def enable_command(self, ctx: Context):
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
- Currently, this just adds an account age requirement. Use bot.defcon.days(int) to set how old an account must
+ Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must
be, in days.
"""
@@ -92,15 +123,35 @@ class Defcon:
)
await response.json()
- except Exception:
+ except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send("DEFCON enabled locally, but there was a problem updating the site.")
+ await ctx.send(
+ f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
+ )
+
+ await self.modlog.send_log_message(
+ Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
+ f"**Days:** {self.days.days}\n\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
+ )
else:
- await ctx.send("DEFCON enabled.")
+ await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.")
+ await self.modlog.send_log_message(
+ Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
+ f"**Days:** {self.days.days}\n\n"
+ )
+
+ @defcon_group.command(name='disable', aliases=('off', 'd'))
@with_role(Roles.admin, Roles.owner)
- @command(name="defcon.disable", aliases=["defcon.disable()", "defcon_disable", "defcon_disable()"])
- async def disable(self, ctx: Context):
+ async def disable_command(self, ctx: Context):
"""
Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
"""
@@ -115,27 +166,47 @@ class Defcon:
)
await response.json()
- except Exception:
+ except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send("DEFCON disabled locally, but there was a problem updating the site.")
+ await ctx.send(
+ f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
+ )
+
+ await self.modlog.send_log_message(
+ Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
+ )
else:
- await ctx.send("DEFCON disabled.")
+ await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.")
+
+ await self.modlog.send_log_message(
+ Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)"
+ )
+ @defcon_group.command(name='status', aliases=('s',))
@with_role(Roles.admin, Roles.owner)
- @command(name="defcon", aliases=["defcon()", "defcon.status", "defcon.status()"])
- async def defcon(self, ctx: Context):
+ async def status_command(self, ctx: Context):
"""
Check the current status of DEFCON mode.
"""
- embed = Embed(colour=Colour.blurple(), title="DEFCON Status")
- embed.add_field(name="Enabled", value=str(self.enabled), inline=True)
- embed.add_field(name="Days", value=str(self.days.days), inline=True)
+ embed = Embed(
+ colour=Colour.blurple(), title="DEFCON Status",
+ description=f"**Enabled:** {self.enabled}\n"
+ f"**Days:** {self.days.days}"
+ )
await ctx.send(embed=embed)
+ @defcon_group.command(name='days')
@with_role(Roles.admin, Roles.owner)
- @command(name="defcon.days", aliases=["defcon.days()", "defcon_days", "defcon_days()"])
async def days_command(self, ctx: Context, days: int):
"""
Set how old an account must be to join the server, in days, with DEFCON mode enabled.
@@ -151,14 +222,34 @@ class Defcon:
)
await response.json()
- except Exception:
+ except Exception as e:
log.exception("Unable to update DEFCON settings.")
await ctx.send(
- f"DEFCON days updated; accounts must be {days} days old to join to the server "
- f"- but there was a problem updating the site."
+ f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} "
+ f"days old to join to the server.\n\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
+ )
+
+ await self.modlog.send_log_message(
+ Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
+ f"**Days:** {self.days.days}\n\n"
+ "**There was a problem updating the site** - This setting may be reverted when the bot is "
+ "restarted.\n\n"
+ f"```py\n{e}\n```"
)
else:
- await ctx.send(f"DEFCON days updated; accounts must be {days} days old to join to the server")
+ await ctx.send(
+ f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server"
+ )
+
+ await self.modlog.send_log_message(
+ Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
+ f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
+ f"**Days:** {self.days.days}"
+ )
def setup(bot: Bot):
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
index ca42fd980..72e1a5d92 100644
--- a/bot/cogs/deployment.py
+++ b/bot/cogs/deployment.py
@@ -1,7 +1,7 @@
import logging
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Context, command, group
from bot.constants import Keys, Roles, URLs
from bot.decorators import with_role
@@ -17,9 +17,13 @@ class Deployment:
def __init__(self, bot: Bot):
self.bot = bot
- @command(name="redeploy()", aliases=["bot.redeploy", "bot.redeploy()", "redeploy"])
+ @group(name='redeploy')
+ async def redeploy_group(self, ctx: Context):
+ """Redeploy the bot or the site."""
+
+ @redeploy_group.command(name='bot')
@with_role(Roles.admin, Roles.owner, Roles.devops)
- async def redeploy(self, ctx: Context):
+ async def bot_command(self, ctx: Context):
"""
Trigger bot deployment on the server - will only redeploy if there were changes to deploy
"""
@@ -34,9 +38,9 @@ class Deployment:
log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.")
await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!")
- @command(name="deploy_site()", aliases=["bot.deploy_site", "bot.deploy_site()", "deploy_site"])
+ @redeploy_group.command(name='site')
@with_role(Roles.admin, Roles.owner, Roles.devops)
- async def deploy_site(self, ctx: Context):
+ async def site_command(self, ctx: Context):
"""
Trigger website deployment on the server - will only redeploy if there were changes to deploy
"""
@@ -51,9 +55,9 @@ class Deployment:
log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.")
await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!")
- @command(name="uptimes()", aliases=["bot.uptimes", "bot.uptimes()", "uptimes"])
+ @command(name='uptimes')
@with_role(Roles.admin, Roles.owner, Roles.devops)
- async def uptimes(self, ctx: Context):
+ async def uptimes_command(self, ctx: Context):
"""
Check the various deployment uptimes for each service
"""
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 26079e3ec..2b310f11c 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -17,6 +17,7 @@ from sphinx.ext import intersphinx
from bot.constants import ERROR_REPLIES, Keys, Roles, URLs
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
+from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -355,7 +356,13 @@ class Doc:
changes = await resp.json()
return changes["deleted"] == 1 # Did the package delete successfully?
- @commands.command(name='docs.get()', aliases=['docs.get'])
+ @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
+ async def docs_group(self, ctx, symbol: commands.clean_content = None):
+ """Lookup documentation for Python symbols."""
+
+ await ctx.invoke(self.get_command)
+
+ @docs_group.command(name='get', aliases=('g',))
async def get_command(self, ctx, symbol: commands.clean_content = None):
"""
Return a documentation embed for a given symbol.
@@ -366,20 +373,20 @@ class Doc:
or nothing to get a list of all inventories
Examples:
- bot.docs.get('aiohttp')
- bot.docs['aiohttp']
+ !docs
+ !docs aiohttp
+ !docs aiohttp.ClientSession
+ !docs get aiohttp.ClientSession
"""
if symbol is None:
- all_inventories = "\n".join(
- f"• [`{name}`]({url})" for name, url in self.base_urls.items()
- )
inventory_embed = discord.Embed(
- title="All inventories",
- description=all_inventories or "*Seems like there's nothing here yet.*",
+ title=f"All inventories (`{len(self.base_urls)}` total)",
colour=discord.Colour.blue()
)
- await ctx.send(embed=inventory_embed)
+
+ lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items())
+ await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False)
else:
# Fetching documentation for a symbol (at least for the first time, since
@@ -397,8 +404,8 @@ class Doc:
else:
await ctx.send(embed=doc_embed)
+ @docs_group.command(name='set', aliases=('s',))
@with_role(Roles.admin, Roles.owner, Roles.moderator)
- @commands.command(name='docs.set()', aliases=['docs.set'])
async def set_command(
self, ctx, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
@@ -414,11 +421,10 @@ class Doc:
:param inventory_url: The intersphinx inventory URL.
Example:
- bot.docs.set(
- 'discord',
- 'https://discordpy.readthedocs.io/en/rewrite/',
- 'https://discordpy.readthedocs.io/en/rewrite/objects.inv'
- )
+ !docs set \
+ discord \
+ https://discordpy.readthedocs.io/en/rewrite/ \
+ https://discordpy.readthedocs.io/en/rewrite/objects.inv
"""
await self.set_package(package_name, base_url, inventory_url)
@@ -436,8 +442,8 @@ class Doc:
await self.refresh_inventory()
await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
+ @docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(Roles.admin, Roles.owner, Roles.moderator)
- @commands.command(name='docs.delete()', aliases=['docs.delete', 'docs.remove()', 'docs.remove'])
async def delete_command(self, ctx, package_name: ValidPythonIdentifier):
"""
Removes the specified package from the database.
@@ -446,8 +452,7 @@ class Doc:
:param package_name: The package name, for example `aiohttp`.
Examples:
- bot.tags.delete('aiohttp')
- bot.tags['aiohttp'] = None
+ !docs delete aiohttp
"""
success = await self.delete_package(package_name)
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index ddd5c558a..6506a5b9b 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -8,7 +8,7 @@ import traceback
from io import StringIO
import discord
-from discord.ext.commands import Bot, command
+from discord.ext.commands import Bot, group
from bot.constants import Roles
from bot.decorators import with_role
@@ -173,7 +173,11 @@ async def func(): # (None,) -> Any
out, embed = self._format(code, res)
await ctx.send(f"```py\n{out}```", embed=embed)
- @command(name="internal.eval()", aliases=["internal.eval"])
+ @group(name='internal', aliases=('int',))
+ async def internal_group(self, ctx):
+ """Internal commands. Top secret!"""
+
+ @internal_group.command(name='eval', aliases=('e',))
@with_role(Roles.admin, Roles.owner)
async def eval(self, ctx, *, code: str):
""" Run eval in a REPL-like format. """
diff --git a/bot/cogs/events.py b/bot/cogs/events.py
index 85fec3aa3..a7111b8a0 100644
--- a/bot/cogs/events.py
+++ b/bot/cogs/events.py
@@ -17,9 +17,7 @@ log = logging.getLogger(__name__)
class Events:
- """
- No commands, just event handlers
- """
+ """No commands, just event handlers."""
def __init__(self, bot: Bot):
self.bot = bot
diff --git a/bot/cogs/hiphopify.py b/bot/cogs/hiphopify.py
index 00c79809f..785aedca2 100644
--- a/bot/cogs/hiphopify.py
+++ b/bot/cogs/hiphopify.py
@@ -75,9 +75,9 @@ class Hiphopify:
"to DM them, and a discord.errors.Forbidden error was incurred."
)
+ @command(name='hiphopify', aliases=('force_nick', 'hh'))
@with_role(Roles.admin, Roles.owner, Roles.moderator)
- @command(name="hiphopify()", aliases=["hiphopify", "force_nick()", "force_nick"])
- async def hiphopify(self, ctx: Context, member: Member, duration: str, forced_nick: str = None):
+ async def hiphopify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None):
"""
This command will force a random rapper name (like Lil' Wayne) to be the users
nickname for a specified duration. If a forced_nick is provided, it will use that instead.
@@ -151,8 +151,8 @@ class Hiphopify:
await member.edit(nick=forced_nick)
await ctx.send(embed=embed)
+ @command(name='unhiphopify', aliases=('release_nick', 'uhh'))
@with_role(Roles.admin, Roles.owner, Roles.moderator)
- @command(name="unhiphopify()", aliases=["unhiphopify", "release_nick()", "release_nick"])
async def unhiphopify(self, ctx: Context, member: Member):
"""
This command will remove the entry from our database, allowing the user
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
new file mode 100644
index 000000000..87cea2b5a
--- /dev/null
+++ b/bot/cogs/modlog.py
@@ -0,0 +1,658 @@
+import asyncio
+import datetime
+import logging
+from typing import List, Optional, Union
+
+from dateutil.relativedelta import relativedelta
+from deepdiff import DeepDiff
+from discord import (
+ CategoryChannel, Colour, Embed, File, Guild,
+ Member, Message, NotFound, RawBulkMessageDeleteEvent,
+ RawMessageDeleteEvent, RawMessageUpdateEvent, Role,
+ TextChannel, User, VoiceChannel)
+from discord.abc import GuildChannel
+from discord.ext.commands import Bot
+
+from bot.constants import Channels, Emojis, Icons
+from bot.constants import Guild as GuildConstant
+from bot.utils.time import humanize
+
+
+log = logging.getLogger(__name__)
+
+BULLET_POINT = "\u2022"
+COLOUR_RED = Colour(0xcd6d6d)
+COLOUR_GREEN = Colour(0x68c290)
+GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]
+
+CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
+CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
+MEMBER_CHANGES_SUPPRESSED = ("activity", "status")
+ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
+
+
+class ModLog:
+ """
+ Logging for server events and staff actions
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self._ignored_deletions = []
+
+ self._cached_deletes = []
+ self._cached_edits = []
+
+ def ignore_message_deletion(self, *message_ids: int):
+ for message_id in message_ids:
+ if message_id not in self._ignored_deletions:
+ self._ignored_deletions.append(message_id)
+
+ async def send_log_message(
+ self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str, thumbnail: str = None,
+ channel_id: int = Channels.modlog, ping_everyone: bool = False, files: List[File] = None
+ ):
+ embed = Embed(description=text)
+
+ if title and icon_url:
+ embed.set_author(name=title, icon_url=icon_url)
+
+ embed.colour = colour
+ embed.timestamp = datetime.datetime.utcnow()
+
+ if thumbnail is not None:
+ embed.set_thumbnail(url=thumbnail)
+
+ content = None
+
+ if ping_everyone:
+ content = "@everyone"
+
+ await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files)
+
+ async def on_guild_channel_create(self, channel: GUILD_CHANNEL):
+ if channel.guild.id != GuildConstant.id:
+ return
+
+ if isinstance(channel, CategoryChannel):
+ title = "Category created"
+ message = f"{channel.name} (`{channel.id}`)"
+ elif isinstance(channel, VoiceChannel):
+ title = "Voice channel created"
+
+ if channel.category:
+ message = f"{channel.category}/{channel.name} (`{channel.id}`)"
+ else:
+ message = f"{channel.name} (`{channel.id}`)"
+ else:
+ title = "Text channel created"
+
+ if channel.category:
+ message = f"{channel.category}/{channel.name} (`{channel.id}`)"
+ else:
+ message = f"{channel.name} (`{channel.id}`)"
+
+ await self.send_log_message(Icons.hash_green, COLOUR_GREEN, title, message)
+
+ async def on_guild_channel_delete(self, channel: GUILD_CHANNEL):
+ if channel.guild.id != GuildConstant.id:
+ return
+
+ if isinstance(channel, CategoryChannel):
+ title = "Category deleted"
+ elif isinstance(channel, VoiceChannel):
+ title = "Voice channel deleted"
+ else:
+ title = "Text channel deleted"
+
+ if channel.category and not isinstance(channel, CategoryChannel):
+ message = f"{channel.category}/{channel.name} (`{channel.id}`)"
+ else:
+ message = f"{channel.name} (`{channel.id}`)"
+
+ await self.send_log_message(
+ Icons.hash_red, COLOUR_RED,
+ title, message
+ )
+
+ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel):
+ if before.guild.id != GuildConstant.id:
+ return
+
+ diff = DeepDiff(before, after)
+ changes = []
+ done = []
+
+ diff_values = diff.get("values_changed", {})
+ diff_values.update(diff.get("type_changes", {}))
+
+ for key, value in diff_values.items():
+ if not key: # Not sure why, but it happens
+ continue
+
+ key = key[5:] # Remove "root." prefix
+
+ if "[" in key:
+ key = key.split("[", 1)[0]
+
+ if "." in key:
+ key = key.split(".", 1)[0]
+
+ if key in done or key in CHANNEL_CHANGES_SUPPRESSED:
+ continue
+
+ if key in CHANNEL_CHANGES_UNSUPPORTED:
+ changes.append(f"**{key.title()}** updated")
+ else:
+ new = value["new_value"]
+ old = value["old_value"]
+
+ changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+
+ done.append(key)
+
+ if not changes:
+ return
+
+ message = ""
+
+ for item in sorted(changes):
+ message += f"{BULLET_POINT} {item}\n"
+
+ if after.category:
+ message = f"**{after.category}/#{after.name} (`{after.id}`)**\n{message}"
+ else:
+ message = f"**#{after.name}** (`{after.id}`)\n{message}"
+
+ await self.send_log_message(
+ Icons.hash_blurple, Colour.blurple(),
+ "Channel updated", message
+ )
+
+ async def on_guild_role_create(self, role: Role):
+ if role.guild.id != GuildConstant.id:
+ return
+
+ await self.send_log_message(
+ Icons.crown_green, COLOUR_GREEN,
+ "Role created", f"`{role.id}`"
+ )
+
+ async def on_guild_role_delete(self, role: Role):
+ if role.guild.id != GuildConstant.id:
+ return
+
+ await self.send_log_message(
+ Icons.crown_red, COLOUR_RED,
+ "Role removed", f"{role.name} (`{role.id}`)"
+ )
+
+ async def on_guild_role_update(self, before: Role, after: Role):
+ if before.guild.id != GuildConstant.id:
+ return
+
+ diff = DeepDiff(before, after)
+ changes = []
+ done = []
+
+ diff_values = diff.get("values_changed", {})
+ diff_values.update(diff.get("type_changes", {}))
+
+ for key, value in diff_values.items():
+ if not key: # Not sure why, but it happens
+ continue
+
+ key = key[5:] # Remove "root." prefix
+
+ if "[" in key:
+ key = key.split("[", 1)[0]
+
+ if "." in key:
+ key = key.split(".", 1)[0]
+
+ if key in done or key == "color":
+ continue
+
+ if key in ROLE_CHANGES_UNSUPPORTED:
+ changes.append(f"**{key.title()}** updated")
+ else:
+ new = value["new_value"]
+ old = value["old_value"]
+
+ changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+
+ done.append(key)
+
+ if not changes:
+ return
+
+ message = ""
+
+ for item in sorted(changes):
+ message += f"{BULLET_POINT} {item}\n"
+
+ message = f"**{after.name}** (`{after.id}`)\n{message}"
+
+ await self.send_log_message(
+ Icons.crown_blurple, Colour.blurple(),
+ "Role updated", message
+ )
+
+ async def on_guild_update(self, before: Guild, after: Guild):
+ if before.id != GuildConstant.id:
+ return
+
+ diff = DeepDiff(before, after)
+ changes = []
+ done = []
+
+ diff_values = diff.get("values_changed", {})
+ diff_values.update(diff.get("type_changes", {}))
+
+ for key, value in diff_values.items():
+ if not key: # Not sure why, but it happens
+ continue
+
+ key = key[5:] # Remove "root." prefix
+
+ if "[" in key:
+ key = key.split("[", 1)[0]
+
+ if "." in key:
+ key = key.split(".", 1)[0]
+
+ if key in done:
+ continue
+
+ new = value["new_value"]
+ old = value["old_value"]
+
+ changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+
+ done.append(key)
+
+ if not changes:
+ return
+
+ message = ""
+
+ for item in sorted(changes):
+ message += f"{BULLET_POINT} {item}\n"
+
+ message = f"**{after.name}** (`{after.id}`)\n{message}"
+
+ await self.send_log_message(
+ Icons.guild_update, Colour.blurple(),
+ "Guild updated", message,
+ thumbnail=after.icon_url_as(format="png")
+ )
+
+ async def on_member_ban(self, guild: Guild, member: Union[Member, User]):
+ if guild.id != GuildConstant.id:
+ return
+
+ await self.send_log_message(
+ Icons.user_ban, COLOUR_RED,
+ "User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ async def on_member_join(self, member: Member):
+ if member.guild.id != GuildConstant.id:
+ return
+
+ message = f"{member.name}#{member.discriminator} (`{member.id}`)"
+
+ now = datetime.datetime.utcnow()
+ difference = abs(relativedelta(now, member.created_at))
+
+ message += "\n\n**Account age:** " + humanize(difference)
+
+ if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
+ message = f"{Emojis.new} {message}"
+
+ await self.send_log_message(
+ Icons.sign_in, COLOUR_GREEN,
+ "User joined", message,
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ async def on_member_remove(self, member: Member):
+ if member.guild.id != GuildConstant.id:
+ return
+
+ await self.send_log_message(
+ Icons.sign_out, COLOUR_RED,
+ "User left", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ async def on_member_unban(self, guild: Guild, member: User):
+ if guild.id != GuildConstant.id:
+ return
+
+ await self.send_log_message(
+ Icons.user_unban, Colour.blurple(),
+ "User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)",
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ async def on_member_update(self, before: Member, after: Member):
+ if before.guild.id != GuildConstant.id:
+ return
+
+ diff = DeepDiff(before, after)
+ changes = []
+ done = []
+
+ diff_values = {}
+
+ diff_values.update(diff.get("values_changed", {}))
+ diff_values.update(diff.get("type_changes", {}))
+ diff_values.update(diff.get("iterable_item_removed", {}))
+ diff_values.update(diff.get("iterable_item_added", {}))
+
+ diff_user = DeepDiff(before._user, after._user)
+
+ diff_values.update(diff_user.get("values_changed", {}))
+ diff_values.update(diff_user.get("type_changes", {}))
+ diff_values.update(diff_user.get("iterable_item_removed", {}))
+ diff_values.update(diff_user.get("iterable_item_added", {}))
+
+ for key, value in diff_values.items():
+ if not key: # Not sure why, but it happens
+ continue
+
+ key = key[5:] # Remove "root." prefix
+
+ if "[" in key:
+ key = key.split("[", 1)[0]
+
+ if "." in key:
+ key = key.split(".", 1)[0]
+
+ if key in done or key in MEMBER_CHANGES_SUPPRESSED:
+ continue
+
+ if key == "roles":
+ new_roles = after.roles
+ old_roles = before.roles
+
+ for role in old_roles:
+ if role not in new_roles:
+ changes.append(f"**Role removed:** {role.name} (`{role.id}`)")
+
+ for role in new_roles:
+ if role not in old_roles:
+ changes.append(f"**Role added:** {role.name} (`{role.id}`)")
+
+ else:
+ new = value["new_value"]
+ old = value["old_value"]
+
+ changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+
+ done.append(key)
+
+ if before.name != after.name:
+ changes.append(
+ f"**Username:** `{before.name}` **->** `{after.name}`"
+ )
+
+ if before.discriminator != after.discriminator:
+ changes.append(
+ f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`"
+ )
+
+ if not changes:
+ return
+
+ message = ""
+
+ for item in sorted(changes):
+ message += f"{BULLET_POINT} {item}\n"
+
+ message = f"**{after.name}#{after.discriminator}** (`{after.id}`)\n{message}"
+
+ await self.send_log_message(
+ Icons.user_update, Colour.blurple(),
+ "Member updated", message,
+ thumbnail=after.avatar_url_as(static_format="png")
+ )
+
+ async def on_raw_bulk_message_delete(self, event: RawBulkMessageDeleteEvent):
+ if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
+ return
+
+ # Could upload the log to the site - maybe we should store all the messages somewhere?
+ # Currently if messages aren't in the cache, we ain't gonna have 'em.
+
+ ignored_messages = 0
+
+ for message_id in event.message_ids:
+ if message_id in self._ignored_deletions:
+ self._ignored_deletions.remove(message_id)
+ ignored_messages += 1
+
+ if ignored_messages >= len(event.message_ids):
+ return
+
+ channel = self.bot.get_channel(event.channel_id)
+
+ if channel.category:
+ message = f"{len(event.message_ids)} deleted in {channel.category}/#{channel.name} (`{channel.id}`)"
+ else:
+ message = f"{len(event.message_ids)} deleted in #{channel.name} (`{channel.id}`)"
+
+ await self.send_log_message(
+ Icons.message_bulk_delete, Colour.orange(),
+ "Bulk message delete",
+ message, channel_id=Channels.devalerts,
+ ping_everyone=True
+ )
+
+ async def on_message_delete(self, message: Message):
+ channel = message.channel
+ author = message.author
+
+ if message.guild.id != GuildConstant.id or channel.id in GuildConstant.ignored:
+ return
+
+ self._cached_deletes.append(message.id)
+
+ if message.id in self._ignored_deletions:
+ self._ignored_deletions.remove(message.id)
+ return
+
+ if author.bot:
+ return
+
+ if channel.category:
+ response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
+ else:
+ response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
+
+ if message.attachments:
+ # Prepend the message metadata with the number of attachments
+ response = f"**Attachments:** {len(message.attachments)}\n" + response
+
+ await self.send_log_message(
+ Icons.message_delete, COLOUR_RED,
+ "Message deleted",
+ response,
+ channel_id=Channels.message_log
+ )
+
+ async def on_raw_message_delete(self, event: RawMessageDeleteEvent):
+ if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
+ return
+
+ await asyncio.sleep(1) # Wait here in case the normal event was fired
+
+ if event.message_id in self._cached_deletes:
+ # It was in the cache and the normal event was fired, so we can just ignore it
+ self._cached_deletes.remove(event.message_id)
+ return
+
+ if event.message_id in self._ignored_deletions:
+ self._ignored_deletions.remove(event.message_id)
+ return
+
+ channel = self.bot.get_channel(event.channel_id)
+
+ if channel.category:
+ response = (
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{event.message_id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
+ else:
+ response = (
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{event.message_id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
+
+ await self.send_log_message(
+ Icons.message_delete, COLOUR_RED,
+ "Message deleted",
+ response,
+ channel_id=Channels.message_log
+ )
+
+ async def on_message_edit(self, before: Message, after: Message):
+ if before.guild.id != GuildConstant.id or before.channel.id in GuildConstant.ignored or before.author.bot:
+ return
+
+ self._cached_edits.append(before.id)
+
+ if before.content == after.content:
+ return
+
+ author = before.author
+ channel = before.channel
+
+ if channel.category:
+ before_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{before.id}`\n"
+ "\n"
+ f"{before.clean_content}"
+ )
+
+ after_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{before.id}`\n"
+ "\n"
+ f"{after.clean_content}"
+ )
+ else:
+ before_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{before.id}`\n"
+ "\n"
+ f"{before.clean_content}"
+ )
+
+ after_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{before.id}`\n"
+ "\n"
+ f"{after.clean_content}"
+ )
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.blurple(), "Message edited (Before)",
+ before_response, channel_id=Channels.message_log
+ )
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.blurple(), "Message edited (After)",
+ after_response, channel_id=Channels.message_log
+ )
+
+ async def on_raw_message_edit(self, event: RawMessageUpdateEvent):
+ try:
+ channel = self.bot.get_channel(int(event.data["channel_id"]))
+ message = await channel.get_message(event.message_id)
+ except NotFound: # Was deleted before we got the event
+ return
+
+ if message.guild.id != GuildConstant.id or message.channel.id in GuildConstant.ignored or message.author.bot:
+ return
+
+ await asyncio.sleep(1) # Wait here in case the normal event was fired
+
+ if event.message_id in self._cached_edits:
+ # It was in the cache and the normal event was fired, so we can just ignore it
+ self._cached_edits.remove(event.message_id)
+ return
+
+ author = message.author
+ channel = message.channel
+
+ if channel.category:
+ before_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
+
+ after_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
+ else:
+ before_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
+
+ after_response = (
+ f"**Author:** {author.name}#{author.discriminator} (`{author.id}`)\n"
+ f"**Channel:** #{channel.name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.blurple(), "Message edited (Before)",
+ before_response, channel_id=Channels.message_log
+ )
+
+ await self.send_log_message(
+ Icons.message_edit, Colour.blurple(), "Message edited (After)",
+ after_response, channel_id=Channels.message_log
+ )
+
+
+def setup(bot):
+ bot.add_cog(ModLog(bot))
+ log.info("Cog loaded: ModLog")
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 2a3cb2aa7..cc0373232 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -2,10 +2,12 @@ import asyncio
import logging
from datetime import datetime, timedelta
-from discord.ext.commands import BadArgument, Bot, Context, Converter, command
+from discord import Colour, Embed
+from discord.ext.commands import BadArgument, Bot, Context, Converter, group
from bot.constants import Channels, Keys, Roles, URLs
from bot.decorators import with_role
+from bot.pagination import LinePaginator
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
@@ -20,10 +22,10 @@ class OffTopicName(Converter):
if not (2 <= len(argument) <= 96):
raise BadArgument("Channel name must be between 2 and 96 chars long")
- elif not all(c.isalpha() or c == '-' for c in argument):
+ elif not all(c.isalnum() or c == '-' for c in argument):
raise BadArgument(
"Channel name must only consist of"
- " alphabetic characters or minus signs"
+ " alphanumeric characters or minus signs"
)
elif not argument.islower():
@@ -81,9 +83,13 @@ class OffTopicNames:
coro = update_names(self.bot, self.headers)
self.updater_task = await self.bot.loop.create_task(coro)
- @command(name='otname.add()', aliases=['otname.add'])
+ @group(name='otname', aliases=('otnames', 'otn'))
+ async def otname_group(self, ctx):
+ """Add or list items from the off-topic channel name rotation."""
+
+ @otname_group.command(name='add', aliases=('a',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def otname_add(self, ctx, name: OffTopicName):
+ async def add_command(self, ctx, name: OffTopicName):
"""Adds a new off-topic name to the rotation."""
result = await self.bot.http_session.post(
@@ -104,6 +110,27 @@ class OffTopicNames:
error_reason = response.get('message', "No reason provided.")
await ctx.send(f":warning: got non-200 from the API: {error_reason}")
+ @otname_group.command(name='list', aliases=('l',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def list_command(self, ctx):
+ """
+ Lists all currently known off-topic channel names in a paginator.
+ Restricted to Moderator and above to not spoil the surprise.
+ """
+
+ result = await self.bot.http_session.get(
+ URLs.site_off_topic_names_api,
+ headers=self.headers
+ )
+ response = await result.json()
+ lines = sorted(f"• {name}" for name in response)
+
+ embed = Embed(
+ title=f"Known off-topic names (`{len(response)}` total)",
+ colour=Colour.blue()
+ )
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+
def setup(bot: Bot):
bot.add_cog(OffTopicNames(bot))
diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py
index ec32a119d..f83f8e354 100644
--- a/bot/cogs/snakes.py
+++ b/bot/cogs/snakes.py
@@ -14,7 +14,7 @@ from typing import Any, Dict
import aiohttp
import async_timeout
from discord import Colour, Embed, File, Member, Message, Reaction
-from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, command
+from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, group
from PIL import Image, ImageDraw, ImageFont
from bot.constants import ERROR_REPLIES, Keys, URLs
@@ -462,10 +462,14 @@ class Snakes:
# endregion
# region: Commands
+ @group(name='snakes', aliases=('snake',))
+ async def snakes_group(self, ctx: Context):
+ """Commands from our first code jam."""
+
@bot_has_permissions(manage_messages=True)
- @command(name="snakes.antidote()", aliases=["snakes.antidote"])
+ @snakes_group.command(name='antidote')
@locked()
- async def antidote(self, ctx: Context):
+ async def antidote_command(self, ctx: Context):
"""
Antidote - Can you create the antivenom before the patient dies?
@@ -604,8 +608,8 @@ class Snakes:
log.debug("Ending pagination and removing all reactions...")
await board_id.clear_reactions()
- @command(name="snakes.draw()", aliases=["snakes.draw"])
- async def draw(self, ctx: Context):
+ @snakes_group.command(name='draw')
+ async def draw_command(self, ctx: Context):
"""
Draws a random snek using Perlin noise
@@ -648,10 +652,10 @@ class Snakes:
await ctx.send(file=file)
- @command(name="snakes.get()", aliases=["snakes.get"])
+ @snakes_group.command(name='get')
@bot_has_permissions(manage_messages=True)
@locked()
- async def get(self, ctx: Context, name: Snake = None):
+ async def get_command(self, ctx: Context, *, name: Snake = None):
"""
Fetches information about a snake from Wikipedia.
:param ctx: Context object passed from discord.py
@@ -699,9 +703,9 @@ class Snakes:
await ctx.send(embed=embed)
- @command(name="snakes.guess()", aliases=["snakes.guess", "identify"])
+ @snakes_group.command(name='guess', aliases=('identify',))
@locked()
- async def guess(self, ctx):
+ async def guess_command(self, ctx):
"""
Snake identifying game!
@@ -733,8 +737,8 @@ class Snakes:
options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes}
await self._validate_answer(ctx, guess, answer, options)
- @command(name="snakes.hatch()", aliases=["snakes.hatch", "hatch"])
- async def hatch(self, ctx: Context):
+ @snakes_group.command(name='hatch')
+ async def hatch_command(self, ctx: Context):
"""
Hatches your personal snake
@@ -765,8 +769,8 @@ class Snakes:
await ctx.channel.send(embed=my_snake_embed)
- @command(name="snakes.movie()", aliases=["snakes.movie"])
- async def movie(self, ctx: Context):
+ @snakes_group.command(name='movie')
+ async def movie_command(self, ctx: Context):
"""
Gets a random snake-related movie from OMDB.
@@ -835,9 +839,9 @@ class Snakes:
embed=embed
)
- @command(name="snakes.quiz()", aliases=["snakes.quiz"])
+ @snakes_group.command(name='quiz')
@locked()
- async def quiz(self, ctx: Context):
+ async def quiz_command(self, ctx: Context):
"""
Asks a snake-related question in the chat and validates the user's guess.
@@ -863,8 +867,8 @@ class Snakes:
quiz = await ctx.channel.send("", embed=embed)
await self._validate_answer(ctx, quiz, answer, options)
- @command(name="snakes.name()", aliases=["snakes.name", "snakes.name_gen", "snakes.name_gen()"])
- async def random_snake_name(self, ctx: Context, name: str = None):
+ @snakes_group.command(name='name', aliases=('name_gen',))
+ async def name_command(self, ctx: Context, *, name: str = None):
"""
Slices the users name at the last vowel (or second last if the name
ends with a vowel), and then combines it with a random snake name,
@@ -933,9 +937,9 @@ class Snakes:
return await ctx.send(embed=embed)
- @command(name="snakes.sal()", aliases=["snakes.sal"])
+ @snakes_group.command(name='sal')
@locked()
- async def sal(self, ctx: Context):
+ async def sal_command(self, ctx: Context):
"""
Play a game of Snakes and Ladders!
@@ -953,8 +957,8 @@ class Snakes:
await game.open_game()
- @command(name="snakes.about()", aliases=["snakes.about"])
- async def snake_about(self, ctx: Context):
+ @snakes_group.command(name='about')
+ async def about_command(self, ctx: Context):
"""
A command that shows an embed with information about the event,
it's participants, and its winners.
@@ -986,8 +990,8 @@ class Snakes:
"48 hours. The staff then selected the best features from all the best teams, and made modifications "
"to ensure they would all work together before integrating them into the community bot.\n\n"
"It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> "
- "walked away as grand champions. Make sure you check out `bot.snakes.sal()`, `bot.snakes.draw()` "
- "and `bot.snakes.hatch()` to see what they came up with."
+ "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` "
+ "and `!snakes hatch` to see what they came up with."
)
)
@@ -1000,8 +1004,8 @@ class Snakes:
await ctx.channel.send(embed=embed)
- @command(name="snakes.card()", aliases=["snakes.card"])
- async def snake_card(self, ctx: Context, name: Snake = None):
+ @snakes_group.command(name='card')
+ async def card_command(self, ctx: Context, *, name: Snake = None):
"""
Create an interesting little card from a snake!
@@ -1039,8 +1043,8 @@ class Snakes:
file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png")
)
- @command(name="snakes.fact()", aliases=["snakes.fact"])
- async def snake_fact(self, ctx: Context):
+ @snakes_group.command(name='fact')
+ async def fact_command(self, ctx: Context):
"""
Gets a snake-related fact
@@ -1060,8 +1064,8 @@ class Snakes:
)
await ctx.channel.send(embed=embed)
- @command(name="snakes()", aliases=["snakes"])
- async def snake_help(self, ctx: Context):
+ @snakes_group.command(name='help')
+ async def help_command(self, ctx: Context):
"""
This just invokes the help command on this cog.
"""
@@ -1069,8 +1073,8 @@ class Snakes:
log.debug(f"{ctx.author} requested info about the snakes cog")
return await ctx.invoke(self.bot.get_command("help"), "Snakes")
- @command(name="snakes.snakify()", aliases=["snakes.snakify"])
- async def snakify(self, ctx: Context, message: str = None):
+ @snakes_group.command(name='snakify')
+ async def snakify_command(self, ctx: Context, *, message: str = None):
"""
How would I talk if I were a snake?
:param ctx: context
@@ -1112,8 +1116,8 @@ class Snakes:
await ctx.channel.send(embed=embed)
- @command(name="snakes.video()", aliases=["snakes.video", "snakes.get_video()", "snakes.get_video"])
- async def video(self, ctx: Context, search: str = None):
+ @snakes_group.command(name='video', aliases=('get_video',))
+ async def video_command(self, ctx: Context, *, search: str = None):
"""
Gets a YouTube video about snakes
:param name: Optional, a name of a snake. Used to search for videos with that name
@@ -1153,8 +1157,8 @@ class Snakes:
else:
log.warning(f"YouTube API error. Full response looks like {response}")
- @command(name="snakes.zen()", aliases=["zen"])
- async def zen(self, ctx: Context):
+ @snakes_group.command(name='zen')
+ async def zen_command(self, ctx: Context):
"""
Gets a random quote from the Zen of Python,
except as if spoken by a snake.
@@ -1180,9 +1184,9 @@ class Snakes:
# endregion
# region: Error handlers
- @get.error
- @snake_card.error
- @video.error
+ @get_command.error
+ @card_command.error
+ @video_command.error
async def command_error(self, ctx, error):
embed = Embed()
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 2a68950bb..17acf757b 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -1,10 +1,16 @@
import datetime
import logging
+import random
import re
-from discord.ext.commands import Bot, Context, command
+from discord import Colour, Embed
+from discord.ext.commands import (
+ Bot, CommandError, Context, MissingPermissions,
+ NoPrivateMessage, check, command, guild_only
+)
from bot.cogs.rmq import RMQ
+from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs
log = logging.getLogger(__name__)
@@ -15,6 +21,9 @@ RMQ_ARGS = {
}
CODE_TEMPLATE = """
+venv_file = "/snekbox/.venv/bin/activate_this.py"
+exec(open(venv_file).read(), dict(__file__=venv_file))
+
try:
{CODE}
except Exception as e:
@@ -22,6 +31,21 @@ except Exception as e:
"""
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
+BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+WHITELISTED_CHANNELS = (Channels.bot,)
+WHITELISTED_CHANNELS_STRING = ', '.join(f"<#{channel_id}>" for channel_id in WHITELISTED_CHANNELS)
+
+
+async def channel_is_whitelisted_or_author_can_bypass(ctx: Context):
+ """
+ Checks that the author is either helper or above
+ or the channel is a whitelisted channel.
+ """
+
+ if ctx.channel.id not in WHITELISTED_CHANNELS and ctx.author.top_role.id not in BYPASS_ROLES:
+ raise MissingPermissions("You are not allowed to do that here.")
+
+ return True
class Snekbox:
@@ -39,14 +63,13 @@ class Snekbox:
def rmq(self) -> RMQ:
return self.bot.get_cog("RMQ")
- @command(name="snekbox.eval()", aliases=["snekbox.eval", "eval()", "eval"])
- async def do_eval(self, ctx: Context, code: str):
+ @command(name='eval', aliases=('e',))
+ @guild_only()
+ @check(channel_is_whitelisted_or_author_can_bypass)
+ async def eval_command(self, ctx: Context, *, code: str):
"""
Run some code. get the result back. We've done our best to make this safe, but do let us know if you
manage to find an issue with it!
-
- Remember, your code must be within some kind of string. Why not surround your code with quotes or put it in
- a docstring?
"""
if ctx.author.id in self.jobs:
@@ -56,7 +79,18 @@ class Snekbox:
log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}")
self.jobs[ctx.author.id] = datetime.datetime.now()
- code = [f" {line}" for line in code.split("\n")]
+ while code.startswith("\n"):
+ code = code[1:]
+
+ if code.startswith("```") and code.endswith("```"):
+ code = code[3:-3]
+
+ if code.startswith("python"):
+ code = code[6:]
+ elif code.startswith("py"):
+ code = code[2:]
+
+ code = [f" {line.strip()}" for line in code.split("\n")]
code = CODE_TEMPLATE.replace("{CODE}", "\n".join(code))
try:
@@ -67,6 +101,7 @@ class Snekbox:
async with ctx.typing():
message = await self.rmq.consume(str(ctx.author.id), **RMQ_ARGS)
+ paste_link = None
if isinstance(message, str):
output = str.strip(" \n")
@@ -76,9 +111,15 @@ class Snekbox:
if "<@" in output:
output = output.replace("<@", "<@\u200B") # Zero-width space
+ if "<!@" in output:
+ output = output.replace("<!@", "<!@\u200B") # Zero-width space
+
if ESCAPE_REGEX.findall(output):
output = "Code block escape attempt detected; will not output result"
else:
+ # the original output, to send to a pasting service if needed
+ full_output = output
+ truncated = False
if output.count("\n") > 0:
output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)]
output = "\n".join(output)
@@ -90,14 +131,32 @@ class Snekbox:
output = f"{output[:1000]}\n... (truncated - too long, too many lines)"
else:
output = f"{output}\n... (truncated - too many lines)"
+ truncated = True
elif len(output) >= 1000:
output = f"{output[:1000]}\n... (truncated - too long)"
+ truncated = True
+
+ if truncated:
+ try:
+ response = await self.bot.http_session.post(
+ URLs.paste_service.format(key="documents"),
+ data=full_output
+ )
+ data = await response.json()
+ if "key" in data:
+ paste_link = URLs.paste_service.format(key=data["key"])
+ except Exception:
+ log.exception("Failed to upload full output to paste service!")
if output.strip():
- await ctx.send(
- f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```"
- )
+ if paste_link:
+ msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \
+ f"\nFull output: {paste_link}"
+ else:
+ msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```"
+
+ await ctx.send(msg)
else:
await ctx.send(
f"{ctx.author.mention} Your eval job has completed.\n\n```py\n[No output]\n```"
@@ -108,6 +167,27 @@ class Snekbox:
del self.jobs[ctx.author.id]
raise
+ @eval_command.error
+ async def eval_command_error(self, ctx: Context, error: CommandError):
+ embed = Embed(colour=Colour.red())
+
+ if isinstance(error, NoPrivateMessage):
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = "You're not allowed to use this command in private messages."
+ await ctx.send(embed=embed)
+
+ elif isinstance(error, MissingPermissions):
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = f"Sorry, but you may only use this command within {WHITELISTED_CHANNELS_STRING}."
+ await ctx.send(embed=embed)
+
+ else:
+ original_error = getattr(error, 'original', "no original error")
+ log.error(f"Unhandled error in snekbox eval: {error} ({original_error})")
+ embed.title = random.choice(ERROR_REPLIES)
+ embed.description = "Some unhandled error occurred. Sorry for that!"
+ await ctx.send(embed=embed)
+
def setup(bot):
bot.add_cog(Snekbox(bot))
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index 82e009bf8..afdd6c1dc 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -5,7 +5,7 @@ import time
from discord import Colour, Embed
from discord.ext.commands import (
BadArgument, Bot,
- Context, Converter, command
+ Context, Converter, group
)
from bot.constants import (
@@ -150,19 +150,14 @@ class Tags:
return tag_data
- @command(name="tags()", aliases=["tags"], hidden=True)
- async def info_command(self, ctx: Context):
- """
- Show available methods for this class.
-
- :param ctx: Discord message context
- """
+ @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True)
+ async def tags_group(self, ctx: Context):
+ """Show all known tags, a single tag, or run a subcommand."""
- log.debug(f"{ctx.author} requested info about the tags cog")
- return await ctx.invoke(self.bot.get_command("help"), "Tags")
+ await ctx.invoke(self.get_command)
- @command(name="tags.get()", aliases=["tags.get", "tags.show()", "tags.show", "get_tag"])
- async def get_command(self, ctx: Context, tag_name: TagNameConverter=None):
+ @tags_group.command(name='get', aliases=('show', 'g'))
+ async def get_command(self, ctx: Context, *, tag_name: TagNameConverter=None):
"""
Get a list of all tags or a specified tag.
@@ -244,32 +239,25 @@ class Tags:
embed.description = "**There are no tags in the database!**"
if tag_name:
- embed.set_footer(text="To show a list of all tags, use bot.tags.get().")
+ embed.set_footer(text="To show a list of all tags, use !tags.")
embed.title = "Tag not found."
# Paginate if this is a list of all tags
if tags:
- if ctx.invoked_with == "tags.keys()":
- detail_invocation = "bot.tags[<tagname>]"
- elif ctx.invoked_with == "tags.get()":
- detail_invocation = "bot.tags.get(<tagname>)"
- else:
- detail_invocation = "bot.tags.get <tagname>"
-
log.debug(f"Returning a paginated list of all tags.")
return await LinePaginator.paginate(
(lines for lines in tags),
ctx, embed,
- footer_text=f"To show a tag, type {detail_invocation}.",
+ footer_text="To show a tag, type !tags <tagname>.",
empty=False,
max_lines=15
)
return await ctx.send(embed=embed)
+ @tags_group.command(name='set', aliases=('add', 'edit', 's'))
@with_role(Roles.admin, Roles.owner, Roles.moderator)
- @command(name="tags.set()", aliases=["tags.set", "tags.add", "tags.add()", "tags.edit", "tags.edit()", "add_tag"])
- async def set_command(self, ctx: Context, tag_name: TagNameConverter, tag_content: TagContentConverter):
+ async def set_command(self, ctx: Context, tag_name: TagNameConverter, *, tag_content: TagContentConverter):
"""
Create a new tag or edit an existing one.
@@ -303,9 +291,9 @@ class Tags:
return await ctx.send(embed=embed)
+ @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(Roles.admin, Roles.owner)
- @command(name="tags.delete()", aliases=["tags.delete", "tags.remove", "tags.remove()", "remove_tag"])
- async def delete_command(self, ctx: Context, tag_name: TagNameConverter):
+ async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter):
"""
Remove a tag from the database.
@@ -353,16 +341,6 @@ class Tags:
else:
log.error(f"Unhandled tag command error: {error} ({error.original})")
- @command(name="tags.keys()")
- async def keys_command(self, ctx: Context):
- """
- Alias for `tags.get()` with no arguments.
-
- :param ctx: discord message context
- """
-
- return await ctx.invoke(self.get_command)
-
def setup(bot):
bot.add_cog(Tags(bot))
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 14db6294c..22e0cfbe7 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -23,9 +23,9 @@ class Utils:
self.base_pep_url = "http://www.python.org/dev/peps/pep-"
self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-"
- @command(name="pep()", aliases=["pep", "get_pep"])
+ @command(name='pep', aliases=('get_pep', 'p'))
@with_role(Roles.verified)
- async def pep_search(self, ctx: Context, pep_number: str):
+ async def pep_command(self, ctx: Context, pep_number: str):
"""
Fetches information about a PEP and sends it to the channel.
"""
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 198fe861d..b0667fdd0 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -3,6 +3,7 @@ import logging
from discord import Message, NotFound, Object
from discord.ext.commands import Bot, Context, command
+from bot.cogs.modlog import ModLog
from bot.constants import Channels, Roles
from bot.decorators import in_channel, without_role
@@ -20,10 +21,10 @@ your information removed here as well.
Feel free to review them at any point!
Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \
-from time to time, you can send `self.subscribe()` to <#{Channels.bot}> at any time to assign yourself the \
+from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to assign yourself the \
**Announcements** role. We'll mention this role every time we make an announcement.
-If you'd like to unsubscribe from the announcement notifications, simply send `self.unsubscribe()` to <#{Channels.bot}>.
+If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>.
"""
@@ -35,6 +36,10 @@ class Verification:
def __init__(self, bot: Bot):
self.bot = bot
+ @property
+ def modlog(self) -> ModLog:
+ return self.bot.get_cog("ModLog")
+
async def on_message(self, message: Message):
if message.author.bot:
return # They're a bot, ignore
@@ -54,7 +59,7 @@ class Verification:
log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification "
"channel. We are providing instructions how to verify.")
await ctx.send(
- f"{ctx.author.mention} Please type `self.accept()` to verify that you accept our rules, "
+ f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
f"and gain access to the rest of the server.",
delete_after=20
)
@@ -66,15 +71,15 @@ class Verification:
except NotFound:
log.trace("No message found, it must have been deleted by another bot.")
- @command(name="accept", hidden=True, aliases=["verify", "verified", "accepted", "accept()"])
+ @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(Roles.verified)
@in_channel(Channels.verification)
- async def accept(self, ctx: Context, *_): # We don't actually care about the args
+ async def accept_command(self, ctx: Context, *_): # We don't actually care about the args
"""
Accept our rules and gain access to the rest of the server
"""
- log.debug(f"{ctx.author} called self.accept(). Assigning the 'Developer' role.")
+ log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules")
try:
await ctx.author.send(WELCOME_MESSAGE)
@@ -85,13 +90,14 @@ class Verification:
log.trace(f"Deleting the message posted by {ctx.author}.")
try:
+ self.modlog.ignore_message_deletion(ctx.message.id)
await ctx.message.delete()
except NotFound:
log.trace("No message found, it must have been deleted by another bot.")
- @command(name="subscribe", aliases=["subscribe()"])
+ @command(name='subscribe')
@in_channel(Channels.bot)
- async def subscribe(self, ctx: Context, *_): # We don't actually care about the args
+ async def subscribe_command(self, ctx: Context, *_): # We don't actually care about the args
"""
Subscribe to announcement notifications by assigning yourself the role
"""
@@ -108,7 +114,7 @@ class Verification:
f"{ctx.author.mention} You're already subscribed!",
)
- log.debug(f"{ctx.author} called self.subscribe(). Assigning the 'Announcements' role.")
+ log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements")
log.trace(f"Deleting the message posted by {ctx.author}.")
@@ -117,9 +123,9 @@ class Verification:
f"{ctx.author.mention} Subscribed to <#{Channels.announcements}> notifications.",
)
- @command(name="unsubscribe", aliases=["unsubscribe()"])
+ @command(name='unsubscribe')
@in_channel(Channels.bot)
- async def unsubscribe(self, ctx: Context, *_): # We don't actually care about the args
+ async def unsubscribe_command(self, ctx: Context, *_): # We don't actually care about the args
"""
Unsubscribe from announcement notifications by removing the role from yourself
"""
@@ -136,7 +142,7 @@ class Verification:
f"{ctx.author.mention} You're already unsubscribed!"
)
- log.debug(f"{ctx.author} called self.unsubscribe(). Removing the 'Announcements' role.")
+ log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements")
log.trace(f"Deleting the message posted by {ctx.author}.")
diff --git a/bot/constants.py b/bot/constants.py
index 6433d068a..adfd5d014 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -14,9 +14,10 @@ import logging
import os
from collections.abc import Mapping
from pathlib import Path
+from typing import List
import yaml
-
+from yaml.constructor import ConstructorError
log = logging.getLogger(__name__)
@@ -59,12 +60,42 @@ def _env_var_constructor(loader, node):
key: !ENV 'MY_APP_KEY'
"""
- value = loader.construct_scalar(node)
- return os.getenv(value)
+ default = None
+
+ try:
+ # Try to construct a list from this YAML node
+ value = loader.construct_sequence(node)
+
+ if len(value) >= 2:
+ # If we have at least two values, then we have both a key and a default value
+ default = value[1]
+ key = value[0]
+ else:
+ # Otherwise, we just have a key
+ key = value[0]
+ except ConstructorError:
+ # This YAML node is a plain value rather than a list, so we just have a key
+ value = loader.construct_scalar(node)
+
+ key = str(value)
+
+ return os.getenv(key, default)
+
+
+def _join_var_constructor(loader, node):
+ """
+ Implements a custom YAML tag for concatenating other tags in
+ the document to strings. This allows for a much more DRY configuration
+ file.
+ """
+
+ fields = loader.construct_sequence(node)
+ return "".join(str(x) for x in fields)
-yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _required_env_var_constructor)
yaml.SafeLoader.add_constructor("!ENV", _env_var_constructor)
+yaml.SafeLoader.add_constructor("!JOIN", _join_var_constructor)
+yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _required_env_var_constructor)
with open("config-default.yml") as f:
@@ -171,27 +202,73 @@ class Emojis(metaclass=YAMLGetter):
section = "bot"
subsection = "emojis"
+ defcon_disabled: str # noqa: E704
+ defcon_enabled: str # noqa: E704
+ defcon_updated: str # noqa: E704
+
green_chevron: str
red_chevron: str
white_chevron: str
+ new: str
+ pencil: str
+
+
+class Icons(metaclass=YAMLGetter):
+ section = "bot"
+ subsection = "icons"
+
+ crown_blurple: str
+ crown_green: str
+ crown_red: str
+
+ defcon_denied: str # noqa: E704
+ defcon_disabled: str # noqa: E704
+ defcon_enabled: str # noqa: E704
+ defcon_updated: str # noqa: E704
+
+ guild_update: str
+
+ hash_blurple: str
+ hash_green: str
+ hash_red: str
+
+ message_bulk_delete: str
+ message_delete: str
+ message_edit: str
+
+ sign_in: str
+ sign_out: str
+
+ user_ban: str
+ user_unban: str
+ user_update: str
+
class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
+ admins: int
announcements: int
+ big_brother_logs: int
bot: int
checkpoint_test: int
+ devalerts: int
devlog: int
devtest: int
- help0: int
- help1: int
- help2: int
- help3: int
- help4: int
+ help_0: int
+ help_1: int
+ help_2: int
+ help_3: int
+ help_4: int
+ help_5: int
helpers: int
+ message_log: int
modlog: int
+ off_topic_1: int
+ off_topic_2: int
+ off_topic_3: int
python: int
verification: int
@@ -215,6 +292,7 @@ class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
+ ignored: List[int]
class Keys(metaclass=YAMLGetter):
@@ -257,12 +335,14 @@ class URLs(metaclass=YAMLGetter):
site_idioms_api: str
site_names_api: str
site_quiz_api: str
+ site_schema: str
site_settings_api: str
site_special_api: str
site_tags_api: str
site_user_api: str
site_user_complete_api: str
status: str
+ paste_service: str
# Debug mode
diff --git a/bot/formatter.py b/bot/formatter.py
deleted file mode 100644
index 5ec23dcb2..000000000
--- a/bot/formatter.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""
-Credit to Rapptz's script used as an example:
-https://github.com/Rapptz/discord.py/blob/rewrite/discord/ext/commands/formatter.py
-Which falls under The MIT License.
-"""
-
-import itertools
-import logging
-from inspect import formatargspec, getfullargspec
-
-from discord.ext.commands import Command, HelpFormatter, Paginator
-
-from bot.constants import Bot
-
-log = logging.getLogger(__name__)
-
-
-class Formatter(HelpFormatter):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- def _add_subcommands_to_page(self, max_width: int, commands: list):
- """
- basically the same function from d.py but changed:
- - to make the helptext appear as a comment
- - to change the indentation to the PEP8 standard: 4 spaces
- """
-
- for name, command in commands:
- if name in command.aliases:
- # skip aliases
- continue
-
- entry = " {0}{1:<{width}} # {2}".format(Bot.help_prefix, name, command.short_doc, width=max_width)
- shortened = self.shorten(entry)
- self._paginator.add_line(shortened)
-
- if name.endswith('get()'):
- alternate_syntax_entry = " {0}{1:<{width}} # {2}".format(
- Bot.help_prefix, name.split('.')[0] + '[<arg>]',
- f"Alternative syntax for {name}", width=max_width
- )
- self._paginator.add_line(self.shorten(alternate_syntax_entry))
-
- async def format(self):
- """
- rewritten help command to make it more python-y
-
- example of specific command:
- async def <command>(ctx, <args>):
- \"""
- <help text>
- \"""
- await do_<command>(ctx, <args>)
-
- example of standard help page:
- class <cog1>:
- bot.<command1>() # <command1 help>
- class <cog2>:
- bot.<command2>() # <command2 help>
-
- # <ending help note>
- """
-
- self._paginator = Paginator(prefix="```py")
-
- if isinstance(self.command, Command):
- # string used purely to make logs a teensy bit more readable
- cog_string = f" from {self.command.cog_name}" if self.command.cog_name else ""
-
- log.trace(f"Help command is on specific command {self.command.name}{cog_string}.")
-
- # strip the command off bot. and ()
- stripped_command = self.command.name.replace(Bot.help_prefix, "").replace("()", "")
-
- # get the args using the handy inspect module
- argspec = getfullargspec(self.command.callback)
- arguments = formatargspec(*argspec)
-
- for annotation in argspec.annotations.values():
- # remove module name to only show class name
- # discord.ext.commands.context.Context -> Context
- arguments = arguments.replace(f"{annotation.__module__}.", "")
-
- log.trace(f"Acquired arguments for command: '{arguments}' ")
-
- # manipulate the argspec to make it valid python when 'calling' the do_<command>
- args_no_type_hints = argspec.args
- for kwarg in argspec.kwonlyargs:
- args_no_type_hints.append("{0}={0}".format(kwarg))
- args_no_type_hints = "({0})".format(", ".join(args_no_type_hints))
-
- # remove self from the args
- arguments = arguments.replace("self, ", "")
- args_no_type_hints = args_no_type_hints.replace("self, ", "")
-
- # indent every line in the help message
- helptext = "\n ".join(self.command.help.split("\n"))
-
- # prepare the different sections of the help output, and add them to the paginator
- definition = f"async def {stripped_command}{arguments}:"
- doc_elems = [
- '"""',
- helptext,
- '"""'
- ]
-
- docstring = ""
- for elem in doc_elems:
- docstring += f' {elem}\n'
-
- invocation = f" await do_{stripped_command}{args_no_type_hints}"
- self._paginator.add_line(definition)
- self._paginator.add_line(docstring)
- self._paginator.add_line(invocation)
-
- log.trace(f"Help for {self.command.name}{cog_string} added to paginator.")
-
- log.debug(f"Help for {self.command.name}{cog_string} generated.")
-
- return self._paginator.pages
-
- max_width = self.max_name_size
-
- def category_check(tup):
- cog = tup[1].cog_name
- # zero width character to make it appear last when put in alphabetical order
- return cog if cog is not None else "Bot"
-
- command_list = await self.filter_command_list()
- data = sorted(command_list, key=category_check)
-
- log.trace(f"Acquired command list and sorted by cog name: {[command[1].name for command in data]}")
-
- for category, commands in itertools.groupby(data, key=category_check):
- commands = sorted(commands)
- if len(commands) > 0:
- self._paginator.add_line(f"class {category}:")
- self._add_subcommands_to_page(max_width, commands)
-
- log.trace("Added cog and command names to the paginator.")
-
- self._paginator.add_line()
- ending_note = self.get_ending_note()
- # make the ending note appear as comments
- ending_note = "# "+ending_note.replace("\n", "\n# ")
- self._paginator.add_line(ending_note)
-
- log.trace("Added ending note to paginator.")
- log.debug("General or Cog help generated.")
-
- return self._paginator.pages
diff --git a/bot/utils/time.py b/bot/utils/time.py
new file mode 100644
index 000000000..b3f55932c
--- /dev/null
+++ b/bot/utils/time.py
@@ -0,0 +1,59 @@
+from dateutil.relativedelta import relativedelta
+
+
+def _plural_timestring(value: int, unit: str) -> str:
+ """
+ Takes a value and a unit type,
+ such as 24 and "hours".
+
+ Returns a string that takes
+ the correct plural into account.
+
+ >>> _plural_timestring(1, "seconds")
+ "1 second"
+ >>> _plural_timestring(24, "hours")
+ "24 hours"
+ """
+
+ if value == 1:
+ return f"{value} {unit[:-1]}"
+ else:
+ return f"{value} {unit}"
+
+
+def humanize(delta: relativedelta, accuracy: str = "seconds") -> str:
+ """
+ This takes a relativedelta and
+ returns a nice human readable string.
+
+ "4 days, 12 hours and 1 second"
+
+ :param delta: A dateutils.relativedelta.relativedelta object
+ :param accuracy: The smallest unit that should be included.
+ :return: A humanized string.
+ """
+
+ units = {
+ "years": delta.years,
+ "months": delta.months,
+ "days": delta.days,
+ "hours": delta.hours,
+ "minutes": delta.minutes,
+ "seconds": delta.seconds
+ }
+
+ # Add the time units that are >0, but stop at accuracy.
+ time_strings = []
+ for unit, value in units.items():
+ if value:
+ time_strings.append(_plural_timestring(value, unit))
+
+ if unit == accuracy:
+ break
+
+ # Add the 'and' between the last two units
+ if len(time_strings) > 1:
+ time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}"
+ del time_strings[-2]
+
+ return ", ".join(time_strings)
diff --git a/config-default.yml b/config-default.yml
index 4402eb9f1..b5bb13c1f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,38 +1,80 @@
bot:
- help_prefix: 'bot.'
- token: !REQUIRED_ENV 'BOT_TOKEN'
+ help_prefix: "bot."
+ token: !REQUIRED_ENV "BOT_TOKEN"
cooldowns:
# Per channel, per tag.
tags: 60
emojis:
- green_chevron: '<:greenchevron:418104310329769993>'
- red_chevron: '<:redchevron:418112778184818698>'
- white_chevron: '<:whitechevron:418110396973711363>'
+ defcon_disabled: "<:defcondisabled:470326273952972810>"
+ defcon_enabled: "<:defconenabled:470326274213150730>"
+ defcon_updated: "<:defconsettingsupdated:470326274082996224>"
+
+ green_chevron: "<:greenchevron:418104310329769993>"
+ red_chevron: "<:redchevron:418112778184818698>"
+ white_chevron: "<:whitechevron:418110396973711363>"
+ lemoneye2: "<:lemoneye2:435193765582340098>"
+
+ pencil: "\u270F"
+ new: "\U0001F195"
+
+ icons:
+ crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
+ crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png"
+ crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png"
+
+ defcon_denied: "https://cdn.discordapp.com/emojis/470326274217476107.png"
+ defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png"
+ defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png"
+ defcon_updated: "https://cdn.discordapp.com/emojis/470326274082996224.png"
+
+ guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png"
+
+ hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png"
+ hash_green: "https://cdn.discordapp.com/emojis/469950144918585344.png"
+ hash_red: "https://cdn.discordapp.com/emojis/469950145413251072.png"
+
+ message_bulk_delete: "https://cdn.discordapp.com/emojis/469952898994929668.png"
+ message_delete: "https://cdn.discordapp.com/emojis/469952898516779008.png"
+ message_edit: "https://cdn.discordapp.com/emojis/469952898143485972.png"
+
+ sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png"
+ sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png"
+
+ user_ban: "https://cdn.discordapp.com/emojis/469952898026045441.png"
+ user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png"
+ user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png"
guild:
id: 267624335836053506
channels:
- announcements: 354619224620138496
- bot: 267659945086812160
- checkpoint_test: 422077681434099723
- devlog: 409308876241108992
- devtest: 414574275865870337
- help0: 303906576991780866
- help1: 303906556754395136
- help2: 303906514266226689
- help3: 439702951246692352
- help4: 451312046647148554
- helpers: 385474242440986624
- modlog: 282638479504965634
- python: 267624335836053506
- verification: 352442727016693763
- off_topic_0: 291284109232308226
- off_topic_1: 463035241142026251
- off_topic_2: 463035268514185226
+ admins: &ADMINS 365960823622991872
+ announcements: 354619224620138496
+ big_brother_logs: 468507907357409333
+ bot: 267659945086812160
+ checkpoint_test: 422077681434099723
+ devalerts: 460181980097675264
+ devlog: 409308876241108992
+ devtest: 414574275865870337
+ help_0: 303906576991780866
+ help_1: 303906556754395136
+ help_2: 303906514266226689
+ help_3: 439702951246692352
+ help_4: 451312046647148554
+ help_5: 454941769734422538
+ helpers: 385474242440986624
+ message_log: &MESSAGE_LOG 467752170159079424
+ modlog: &MODLOG 282638479504965634
+ off_topic_0: 291284109232308226
+ off_topic_1: 463035241142026251
+ off_topic_2: 463035268514185226
+ python: 267624335836053506
+ verification: 352442727016693763
+
+ ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG]
roles:
admin: 267628507062992896
@@ -44,45 +86,55 @@ guild:
moderator: 267629731250176001
owner: 267627879762755584
verified: 352427296948486144
+ helpers: 267630620367257601
keys:
- deploy_bot: !ENV 'DEPLOY_BOT_KEY'
- deploy_site: !ENV 'DEPLOY_SITE'
- omdb: !ENV 'OMDB_API_KEY'
- site_api: !ENV 'BOT_API_KEY'
- youtube: !ENV 'YOUTUBE_API_KEY'
+ deploy_bot: !ENV "DEPLOY_BOT_KEY"
+ deploy_site: !ENV "DEPLOY_SITE"
+ omdb: !ENV "OMDB_API_KEY"
+ site_api: !ENV "BOT_API_KEY"
+ youtube: !ENV "YOUTUBE_API_KEY"
clickup:
- key: !ENV 'CLICKUP_KEY'
- space: 757069
- team: 754996
+ key: !ENV "CLICKUP_KEY"
+ space: 757069
+ team: 754996
rabbitmq:
- host: "pdrmq"
- password: !ENV "RABBITMQ_DEFAULT_PASS"
- port: 5672
- username: !ENV "RABBITMQ_DEFAULT_USER"
+ host: "pdrmq"
+ password: !ENV ["RABBITMQ_DEFAULT_PASS", "guest"]
+ port: 5672
+ username: !ENV ["RABBITMQ_DEFAULT_USER", "guest"]
urls:
- bot_avatar: 'https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png'
- deploy: !ENV 'DEPLOY_URL'
- gitlab_bot_repo: 'https://gitlab.com/discord-python/projects/bot'
- omdb: 'http://omdbapi.com'
- site: 'pythondiscord.com'
- site_docs_api: 'https://api.pythondiscord.com/bot/docs'
- site_facts_api: 'https://api.pythondiscord.com/bot/snake_facts'
- site_hiphopify_api: 'https://api.pythondiscord.com/bot/hiphopify'
- site_idioms_api: 'https://api.pythondiscord.com/bot/snake_idioms'
- site_names_api: 'https://api.pythondiscord.com/bot/snake_names'
- site_off_topic_names_api: 'https://api.pythondiscord.com/bot/off-topic-names'
- site_quiz_api: 'https://api.pythondiscord.com/bot/snake_quiz'
- site_settings_api: 'https://api.pythondiscord.com/bot/settings'
- site_special_api: 'https://api.pythondiscord.com/bot/special_snakes'
- site_tags_api: 'https://api.pythondiscord.com/bot/tags'
- site_user_api: 'https://api.pythondiscord.com/bot/users'
- site_user_complete_api: 'https://api.pythondiscord.com/bot/users/complete'
- status: !ENV 'STATUS_URL'
+ # PyDis site vars
+ site: &DOMAIN "api.pythondiscord.com"
+ site_schema: &SCHEMA "https://"
+
+ site_bigbrother_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/bigbrother"]
+ site_docs_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/docs"]
+ site_facts_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_facts"]
+ site_hiphopify_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/hiphopify"]
+ site_idioms_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_idioms"]
+ site_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_names"]
+ site_off_topic_names_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/off-topic-names"]
+ site_quiz_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/snake_quiz"]
+ site_settings_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/settings"]
+ site_special_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/special_snakes"]
+ site_tags_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/tags"]
+ site_user_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users"]
+ site_user_complete_api: !JOIN [*SCHEMA, *DOMAIN, "/bot/users/complete"]
+
+ # Env vars
+ deploy: !ENV "DEPLOY_URL"
+ status: !ENV "STATUS_URL"
+
+ # Misc URLs
+ bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
+ gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot"
+ omdb: "http://omdbapi.com"
+ paste_service: "https://paste.pydis.com/{key}"