diff options
| -rw-r--r-- | .gitlab-ci.yml | 2 | ||||
| -rw-r--r-- | CONTRIBUTING.md | 1 | ||||
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 315 | ||||
| -rw-r--r-- | bot/__init__.py | 218 | ||||
| -rw-r--r-- | bot/__main__.py | 19 | ||||
| -rw-r--r-- | bot/cogs/bigbrother.py | 186 | ||||
| -rw-r--r-- | bot/cogs/bot.py | 35 | ||||
| -rw-r--r-- | bot/cogs/cogs.py | 28 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 137 | ||||
| -rw-r--r-- | bot/cogs/deployment.py | 18 | ||||
| -rw-r--r-- | bot/cogs/doc.py | 41 | ||||
| -rw-r--r-- | bot/cogs/eval.py | 8 | ||||
| -rw-r--r-- | bot/cogs/events.py | 4 | ||||
| -rw-r--r-- | bot/cogs/hiphopify.py | 6 | ||||
| -rw-r--r-- | bot/cogs/modlog.py | 658 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 37 | ||||
| -rw-r--r-- | bot/cogs/snakes.py | 80 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 100 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 48 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 4 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 30 | ||||
| -rw-r--r-- | bot/constants.py | 98 | ||||
| -rw-r--r-- | bot/formatter.py | 152 | ||||
| -rw-r--r-- | bot/utils/time.py | 59 | ||||
| -rw-r--r-- | config-default.yml | 156 |
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). - @@ -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}" |