aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2019-01-01 19:22:48 +0100
committerGravatar Johannes Christ <[email protected]>2019-01-01 19:22:48 +0100
commit06d866fbffff87913e05f6c0b3b5ba788e9def06 (patch)
treebd8d7edf9729c0c6ba1bdfeec8e31fac99963e17
parentRemove superfluous `self.headers` setting. (diff)
parentMerge pull request #242 from python-discord/eval-indent-fix (diff)
Merge branch 'master' into django
-rw-r--r--.gitignore3
-rw-r--r--.gitlab-ci.yml38
-rw-r--r--Pipfile12
-rw-r--r--Pipfile.lock635
-rw-r--r--README.md6
-rw-r--r--azure-pipelines.yml57
-rw-r--r--bot/__main__.py12
-rw-r--r--bot/cogs/alias.py181
-rw-r--r--bot/cogs/bigbrother.py137
-rw-r--r--bot/cogs/bot.py72
-rw-r--r--bot/cogs/cogs.py10
-rw-r--r--bot/cogs/defcon.py2
-rw-r--r--bot/cogs/deployment.py4
-rw-r--r--bot/cogs/eval.py18
-rw-r--r--bot/cogs/events.py20
-rw-r--r--bot/cogs/filtering.py21
-rw-r--r--bot/cogs/help.py724
-rw-r--r--bot/cogs/hiphopify.py195
-rw-r--r--bot/cogs/information.py13
-rw-r--r--bot/cogs/moderation.py829
-rw-r--r--bot/cogs/modlog.py36
-rw-r--r--bot/cogs/off_topic_names.py4
-rw-r--r--bot/cogs/reminders.py406
-rw-r--r--bot/cogs/site.py16
-rw-r--r--bot/cogs/snakes.py4
-rw-r--r--bot/cogs/snekbox.py31
-rw-r--r--bot/cogs/superstarify.py285
-rw-r--r--bot/cogs/tags.py60
-rw-r--r--bot/cogs/utils.py63
-rw-r--r--bot/cogs/verification.py11
-rw-r--r--bot/cogs/wolfram.py289
-rw-r--r--bot/constants.py106
-rw-r--r--bot/converters.py66
-rw-r--r--bot/decorators.py44
-rw-r--r--bot/pagination.py191
-rw-r--r--bot/rules/links.py16
-rw-r--r--bot/utils/__init__.py6
-rw-r--r--bot/utils/messages.py112
-rw-r--r--bot/utils/moderation.py45
-rw-r--r--bot/utils/scheduling.py77
-rw-r--r--bot/utils/snakes/hatching.py28
-rw-r--r--bot/utils/time.py22
-rw-r--r--config-default.yml49
-rw-r--r--docker/base.Dockerfile12
-rw-r--r--docker/bot.Dockerfile7
-rw-r--r--scripts/deploy-azure.sh31
-rw-r--r--tox.ini2
47 files changed, 3965 insertions, 1043 deletions
diff --git a/.gitignore b/.gitignore
index 4321d9324..be4f43c7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,6 +103,9 @@ ENV/
# PyCharm
.idea/
+# VSCode
+.vscode/
+
# Vagrant
.vagrant
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index f7aee8165..000000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-image: pythondiscord/bot-ci:latest
-
-variables:
- PIPENV_CACHE_DIR: "/root/.cache/pipenv"
- PIP_CACHE_DIR: "/root/.cache/pip"
-
-cache:
- paths:
- - "/root/.cache/pip/"
- - "/root/.cache/pipenv/"
- - "/usr/local/lib/python3.6/site-packages/"
-
-stages:
- - test
- - build
-
-test:
- tags:
- - docker
-
- stage: test
-
- script:
- - ls /root/.cache/
- - pipenv install --dev --deploy --system
- - python -m flake8
- - ls /root/.cache/
-
-build:
- tags:
- - docker
-
- services:
- - docker:dind
-
- stage: build
- script:
- - sh scripts/deploy.sh
diff --git a/Pipfile b/Pipfile
index d3d315e6e..179b317df 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,27 +4,24 @@ verify_ssl = true
name = "pypi"
[packages]
-discord = {file = "https://github.com/Rapptz/discord.py/archive/rewrite.zip", egg = "discord.py[voice]"}
+discord-py = {git = "https://github.com/Rapptz/discord.py.git", extras = ["voice"], ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb", editable = true}
dulwich = "*"
-multidict = "*"
-sympy = "*"
aiodns = "*"
logmatic-python = "*"
-aiohttp = "<2.3.0,>=2.0.0"
-websockets = ">=4.0,<5.0"
+aiohttp = "*"
sphinx = "*"
markdownify = "*"
lxml = "*"
pyyaml = "*"
-yarl = "==1.1.1"
fuzzywuzzy = "*"
pillow = "*"
aio-pika = "*"
python-dateutil = "*"
deepdiff = "*"
+requests = "*"
[dev-packages]
-"flake8" = "*"
+"flake8" = ">=3.6"
"flake8-bugbear" = "*"
"flake8-import-order" = "*"
"flake8-tidy-imports" = "*"
@@ -32,7 +29,6 @@ deepdiff = "*"
"flake8-string-format" = "*"
safety = "*"
dodgy = "*"
-requests = "*"
[requires]
python_version = "3.6"
diff --git a/Pipfile.lock b/Pipfile.lock
index 8b43235bb..506b17065 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "9c22a342245c638b196b519a8afb8a2c66410d76283746cfdd89f19ff7dce94c"
+ "sha256": "79a3c633f145dbf93ba5b2460d3f49346495328af7302e59be326e9324785cf3"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:c19c38155f4972f6a9f3f0f1095ce261bfb4e8b89553ead240486593aafd9431",
- "sha256:d41748994e2f809c440a04a1eb809aaae00691caa8e2dab7376d640131754aa4"
+ "sha256:6438e72963e459552f196a07a081a5f6dc54d42a474292b8497bd4a59554fc85",
+ "sha256:dc15b451dca6d2b1c504ab353e3f2fe7e7e252fdb1c219261b5412e1cafbc72d"
],
"index": "pypi",
- "version": "==3.0.1"
+ "version": "==4.6.3"
},
"aiodns": {
"hashes": [
@@ -34,36 +34,51 @@
},
"aiohttp": {
"hashes": [
- "sha256:129d83dd067760cec3cfd4456b5c6d7ac29f2c639d856884568fd539bed5a51f",
- "sha256:33c62afd115c456b0cf1e890fe6753055effe0f31a28321efd4f787378d6f4ab",
- "sha256:666756e1d4cf161ed1486b82f65fdd386ac07dd20fb10f025abf4be54be12746",
- "sha256:9705ded5a0faa25c8f14c6afb7044002d66c9120ed7eadb4aa9ca4aad32bd00c",
- "sha256:af5bfdd164256118a0a306b3f7046e63207d1f8cba73a67dcc0bd858dcfcd3bc",
- "sha256:b80f44b99fa3c9b4530fcfa324a99b84843043c35b084e0b653566049974435d",
- "sha256:c67e105ec74b85c8cb666b6877569dee6f55b9548f982983b9bee80b3d47e6f3",
- "sha256:d15c6658de5b7783c2538407278fa062b079a46d5f814a133ae0f09bbb2cfbc4",
- "sha256:d611ebd1ef48498210b65486306e065fde031040a1f3c455ca1b6baa7bf32ad3",
- "sha256:dcc7e4dcec6b0012537b9f8a0726f8b111188894ab0f924b680d40b13d3298a0",
- "sha256:de8ef106e130b94ca143fdfc6f27cda1d8ba439462542377738af4d99d9f5dd2",
- "sha256:eb6f1405b607fff7e44168e3ceb5d3c8a8c5a2d3effe0a27f843b16ec047a6d7",
- "sha256:f0e2ac69cb709367400008cebccd5d48161dd146096a009a632a132babe5714c"
- ],
- "index": "pypi",
- "version": "==2.2.5"
+ "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b",
+ "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08",
+ "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd",
+ "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac",
+ "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650",
+ "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa",
+ "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95",
+ "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330",
+ "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc",
+ "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b",
+ "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de",
+ "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4",
+ "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7",
+ "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b",
+ "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8",
+ "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd",
+ "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2",
+ "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698",
+ "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95",
+ "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6",
+ "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0",
+ "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07"
+ ],
+ "version": "==3.4.4"
},
"alabaster": {
"hashes": [
- "sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456",
- "sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7"
+ "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
+ "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
],
- "version": "==0.7.11"
+ "version": "==0.7.12"
},
"async-timeout": {
"hashes": [
- "sha256:474d4bc64cee20603e225eb1ece15e248962958b45a3648a9f5cc29e827a610c",
- "sha256:b3c0ddc416736619bd4a95ca31de8da6920c3b9a140c64dbef2b2fa7bf521287"
+ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
+ "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
+ ],
+ "version": "==3.0.1"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
+ "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
- "version": "==3.0.0"
+ "version": "==18.2.0"
},
"babel": {
"hashes": [
@@ -74,20 +89,55 @@
},
"beautifulsoup4": {
"hashes": [
- "sha256:2545357585a6cc7d050d3c43a86eba2c0b91b9e7ac8a3965e64a6ead6a1a9a3d",
- "sha256:272081ad78c5495ba67083a0e50920163701fa6fe67fbb5eefeb21b5dd88c40b",
- "sha256:4ddc90ad88bccc005a71d8ef32f7b1cd8f935475cd561c4122b2f87de45d28ab",
- "sha256:5a3d659840960a4107047b6328d6d4cdaaee69939bf11adc07466a1856c99a80",
- "sha256:bd43a3b26d2886acd63070c43da821b60dea603eb6d45bab0294aac6129adbfa"
+ "sha256:194ec62a25438adcb3fdb06378b26559eda1ea8a747367d34c33cef9c7f48d57",
+ "sha256:90f8e61121d6ae58362ce3bed8cd997efb00c914eae0ff3d363c32f9a9822d10",
+ "sha256:f0abd31228055d698bb392a826528ea08ebb9959e6bea17c606fd9c9009db938"
],
- "version": "==4.6.1"
+ "version": "==4.6.3"
},
"certifi": {
"hashes": [
- "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
- "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
- ],
- "version": "==2018.4.16"
+ "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
+ "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
+ ],
+ "version": "==2018.10.15"
+ },
+ "cffi": {
+ "hashes": [
+ "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
+ "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
+ "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
+ "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
+ "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
+ "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
+ "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
+ "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
+ "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
+ "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
+ "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
+ "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
+ "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
+ "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
+ "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
+ "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
+ "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
+ "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
+ "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
+ "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
+ "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
+ "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
+ "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
+ "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
+ "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
+ "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
+ "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
+ "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
+ "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
+ "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
+ "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
+ "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
+ ],
+ "version": "==1.11.5"
},
"chardet": {
"hashes": [
@@ -105,9 +155,13 @@
"index": "pypi",
"version": "==3.3.0"
},
- "discord": {
- "egg": "discord.py[voice]",
- "file": "https://github.com/Rapptz/discord.py/archive/rewrite.zip"
+ "discord-py": {
+ "editable": true,
+ "extras": [
+ "voice"
+ ],
+ "git": "https://github.com/Rapptz/discord.py.git",
+ "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb"
},
"docutils": {
"hashes": [
@@ -119,18 +173,18 @@
},
"dulwich": {
"hashes": [
- "sha256:34f99e575fe1f1e89cca92cec1ddd50b4991199cb00609203b28df9eb83ce259"
+ "sha256:5e1e39555f594939a8aff1ca08b3bdf6c7efd4b941c2850760983a0197240974"
],
"index": "pypi",
- "version": "==0.19.5"
+ "version": "==0.19.9"
},
"fuzzywuzzy": {
"hashes": [
- "sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e",
- "sha256:ecf490216fb4d76b558a03042ff8f45a8782f17326caca1384d834cbaa2c7e6f"
+ "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254",
+ "sha256:6f49de47db00e1c71d40ad16da42284ac357936fa9b66bea1df63fed07122d62"
],
"index": "pypi",
- "version": "==0.16.0"
+ "version": "==0.17.0"
},
"idna": {
"hashes": [
@@ -139,12 +193,19 @@
],
"version": "==2.7"
},
+ "idna-ssl": {
+ "hashes": [
+ "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"
+ ],
+ "markers": "python_version < '3.7'",
+ "version": "==1.1.0"
+ },
"imagesize": {
"hashes": [
- "sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18",
- "sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315"
+ "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
+ "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
],
- "version": "==1.0.0"
+ "version": "==1.1.0"
},
"jinja2": {
"hashes": [
@@ -155,9 +216,11 @@
},
"jsonpickle": {
"hashes": [
- "sha256:545b3bee0d65e1abb4baa1818edcc9ec239aa9f2ffbfde8084d71c056180054f"
+ "sha256:8b6212f1155f43ce67fa945efae6d010ed059f3ca5ed377aa070e5903d45b722",
+ "sha256:d43ede55b3d9b5524a8e11566ea0b11c9c8109116ef6a509a1b619d2041e7397",
+ "sha256:ed4adf0d14564c56023862eabfac211cf01211a20c5271896c8ab6f80c68086c"
],
- "version": "==0.9.6"
+ "version": "==1.0"
},
"logmatic-python": {
"hashes": [
@@ -168,35 +231,39 @@
},
"lxml": {
"hashes": [
- "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"
+ "sha256:02bc220d61f46e9b9d5a53c361ef95e9f5e1d27171cd461dddb17677ae2289a5",
+ "sha256:22f253b542a342755f6cfc047fe4d3a296515cf9b542bc6e261af45a80b8caf6",
+ "sha256:2f31145c7ff665b330919bfa44aacd3a0211a76ca7e7b441039d2a0b0451e415",
+ "sha256:36720698c29e7a9626a0dc802ef8885f8f0239bfd1689628ecd459a061f2807f",
+ "sha256:438a1b0203545521f6616132bfe0f4bca86f8a401364008b30e2b26ec408ce85",
+ "sha256:4815892904c336bbaf73dafd54f45f69f4021c22b5bad7332176bbf4fb830568",
+ "sha256:5be031b0f15ad63910d8e5038b489d95a79929513b3634ad4babf77100602588",
+ "sha256:5c93ae37c3c588e829b037fdfbd64a6e40c901d3f93f7beed6d724c44829a3ad",
+ "sha256:60842230678674cdac4a1cf0f707ef12d75b9a4fc4a565add4f710b5fcf185d5",
+ "sha256:62939a8bb6758d1bf923aa1c13f0bcfa9bf5b2fc0f5fa917a6e25db5fe0cfa4e",
+ "sha256:75830c06a62fe7b8fe3bbb5f269f0b308f19f3949ac81cfd40062f47c1455faf",
+ "sha256:81992565b74332c7c1aff6a913a3e906771aa81c9d0c68c68113cffcae45bc53",
+ "sha256:8c892fb0ee52c594d9a7751c7d7356056a9682674b92cc1c4dc968ff0f30c52f",
+ "sha256:9d862e3cf4fc1f2837dedce9c42269c8c76d027e49820a548ac89fdcee1e361f",
+ "sha256:a623965c086a6e91bb703d4da62dabe59fe88888e82c4117d544e11fd74835d6",
+ "sha256:a7783ab7f6a508b0510490cef9f857b763d796ba7476d9703f89722928d1e113",
+ "sha256:aab09fbe8abfa3b9ce62aaf45aca2d28726b1b9ee44871dbe644050a2fff4940",
+ "sha256:abf181934ac3ef193832fb973fd7f6149b5c531903c2ec0f1220941d73eee601",
+ "sha256:ae07fa0c115733fce1e9da96a3ac3fa24801742ca17e917e0c79d63a01eeb843",
+ "sha256:b9c78242219f674ab645ec571c9a95d70f381319a23911941cd2358a8e0521cf",
+ "sha256:bccb267678b870d9782c3b44d0cefe3ba0e329f9af8c946d32bf3778e7a4f271",
+ "sha256:c4df4d27f4c93b2cef74579f00b1d3a31a929c7d8023f870c4b476f03a274db4",
+ "sha256:caf0e50b546bb60dfa99bb18dfa6748458a83131ecdceaf5c071d74907e7e78a",
+ "sha256:d3266bd3ac59ac4edcd5fa75165dee80b94a3e5c91049df5f7c057ccf097551c",
+ "sha256:db0d213987bcd4e6d41710fb4532b22315b0d8fb439ff901782234456556aed1",
+ "sha256:dbbd5cf7690a40a9f0a9325ab480d0fccf46d16b378eefc08e195d84299bfae1",
+ "sha256:e16e07a0ec3a75b5ee61f2b1003c35696738f937dc8148fbda9fe2147ccb6e61",
+ "sha256:e175a006725c7faadbe69e791877d09936c0ef2cf49d01b60a6c1efcb0e8be6f",
+ "sha256:edd9c13a97f6550f9da2236126bb51c092b3b1ce6187f2bd966533ad794bbb5e",
+ "sha256:fa39ea60d527fbdd94215b5e5552f1c6a912624521093f1384a491a8ad89ad8b"
],
"index": "pypi",
- "version": "==4.2.3"
+ "version": "==4.2.5"
},
"markdownify": {
"hashes": [
@@ -207,97 +274,113 @@
},
"markupsafe": {
"hashes": [
- "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
- ],
- "version": "==1.0"
- },
- "mpmath": {
- "hashes": [
- "sha256:04d14803b6875fe6d69e6dccea87d5ae5599802e4b1df7997bddd2024001050c"
+ "sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
+ "sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
+ "sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
+ "sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
+ "sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
+ "sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
+ "sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
+ "sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
+ "sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
+ "sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
+ "sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
+ "sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
+ "sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
+ "sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
+ "sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
+ "sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
+ "sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
+ "sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
+ "sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
+ "sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
+ "sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
+ "sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
+ "sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
+ "sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
+ "sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
+ "sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
+ "sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
+ "sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
],
- "version": "==1.0.0"
+ "version": "==1.1.0"
},
"multidict": {
"hashes": [
- "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0",
- "sha256:1d6e191965505652f194bc4c40270a842922685918a4f45e6936a6b15cc5816d",
- "sha256:295961a6a88f1199e19968e15d9b42f3a191c89ec13034dbc212bf9c394c3c82",
- "sha256:2be5af084de6c3b8e20d6421cb0346378a9c867dcf7c86030d6b0b550f9888e4",
- "sha256:2eb99617c7a0e9f2b90b64bc1fb742611718618572747d6f3d6532b7b78755ab",
- "sha256:4ba654c6b5ad1ae4a4d792abeb695b29ce981bb0f157a41d0fd227b385f2bef0",
- "sha256:5ba766433c30d703f6b2c17eb0b6826c6f898e5f58d89373e235f07764952314",
- "sha256:a59d58ee85b11f337b54933e8d758b2356fcdcc493248e004c9c5e5d11eedbe4",
- "sha256:a6e35d28900cf87bcc11e6ca9e474db0099b78f0be0a41d95bef02d49101b5b2",
- "sha256:b4df7ca9c01018a51e43937eaa41f2f5dce17a6382fda0086403bcb1f5c2cf8e",
- "sha256:bbd5a6bffd3ba8bfe75b16b5e28af15265538e8be011b0b9fddc7d86a453fd4a",
- "sha256:d870f399fcd58a1889e93008762a3b9a27cf7ea512818fc6e689f59495648355",
- "sha256:e9404e2e19e901121c3c5c6cffd5a8ae0d1d67919c970e3b3262231175713068"
- ],
- "index": "pypi",
- "version": "==4.3.1"
+ "sha256:013eb6591ab95173fd3deb7667d80951abac80100335b3e97b5fa778c1bb4b91",
+ "sha256:0bffbbbb48db35f57dfb4733e943ac8178efb31aab5601cb7b303ee228ce96af",
+ "sha256:1a34aab1dfba492407c757532f665ba3282ec4a40b0d2f678bda828ef422ebb7",
+ "sha256:1b4b46a33f459a2951b0fd26c2d80639810631eb99b3d846d298b02d28a3e31d",
+ "sha256:1d616d80c37a388891bf760d64bc50cac7c61dbb7d7013f2373aa4b44936e9f0",
+ "sha256:225aefa7befbe05bd0116ef87e8cd76cbf4ac39457a66faf7fb5f3c2d7bea19a",
+ "sha256:2c9b28985ef7c830d5c7ea344d068bcdee22f8b6c251369dea98c3a814713d44",
+ "sha256:39e0600f8dd72acb011d09960da560ba3451b1eca8de5557c15705afc9d35f0e",
+ "sha256:3c642c40ea1ca074397698446893a45cd6059d5d071fc3ba3915c430c125320f",
+ "sha256:42357c90b488fac38852bcd7b31dcd36b1e2325413960304c28b8d98e6ff5fd4",
+ "sha256:6ac668f27dbdf8a69c31252f501e128a69a60b43a44e43d712fb58ce3e5dfcca",
+ "sha256:713683da2e3f1dd81a920c995df5dda51f1fff2b3995f5864c3ee782fcdcb96c",
+ "sha256:73b6e7853b6d3bc0eac795044e700467631dff37a5a33d3230122b03076ac2f9",
+ "sha256:77534c1b9f4a5d0962392cad3f668d1a04036b807618e3357eb2c50d8b05f7f7",
+ "sha256:77b579ef57e27457064bb6bb4c8e5ede866af071af60fe3576226136048c6dfa",
+ "sha256:82cf28f18c935d66c15a6f82fda766a4138d21e78532a1946b8ec603019ba0b8",
+ "sha256:937e8f12f9edc0d2e351c09fc3e7335a65eefb75406339d488ee46ef241f75d8",
+ "sha256:985dbf59e92f475573a04598f9a00f92b4fdb64fc41f1df2ea6f33b689319537",
+ "sha256:9c4fab7599ba8c0dbf829272c48c519625c2b7f5630b49925802f1af3a77f1f4",
+ "sha256:9e8772be8455b49a85ad6dbf6ce433da7856ba481d6db36f53507ae540823b15",
+ "sha256:a06d6d88ce3be4b54deabd078810e3c077a8b2e20f0ce541c979b5dd49337031",
+ "sha256:a1da0cdc3bc45315d313af976dab900888dbb477d812997ee0e6e4ea43d325e5",
+ "sha256:a6652466a4800e9fde04bf0252e914fff5f05e2a40ee1453db898149624dfe04",
+ "sha256:a7f23523ea6a01f77e0c6da8aae37ab7943e35630a8d2eda7e49502f36b51b46",
+ "sha256:a87429da49f4c9fb37a6a171fa38b59a99efdeabffb34b4255a7a849ffd74a20",
+ "sha256:c26bb81d0d19619367a96593a097baec2d5a7b3a0cfd1e3a9470277505a465c2",
+ "sha256:d4f4545edb4987f00fde44241cef436bf6471aaac7d21c6bbd497cca6049f613",
+ "sha256:daabc2766a2b76b3bec2086954c48d5f215f75a335eaee1e89c8357922a3c4d5",
+ "sha256:f08c1dcac70b558183b3b755b92f1135a76fd1caa04009b89ddea57a815599aa"
+ ],
+ "version": "==4.5.1"
},
"packaging": {
"hashes": [
- "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
- "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
+ "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
+ "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
],
- "version": "==17.1"
+ "version": "==18.0"
},
"pillow": {
"hashes": [
- "sha256:00def5b638994f888d1058e4d17c86dec8e1113c3741a0a8a659039aec59a83a",
- "sha256:026449b64e559226cdb8e6d8c931b5965d8fc90ec18ebbb0baa04c5b36503c72",
- "sha256:03dbb224ee196ef30ed2156d41b579143e1efeb422974719a5392fc035e4f574",
- "sha256:03eb0e04f929c102ae24bc436bf1c0c60a4e63b07ebd388e84d8b219df3e6acd",
- "sha256:087b0551ce2d19b3f092f2b5f071a065f7379e748867d070b29999cc83db15e3",
- "sha256:091a0656688d85fd6e10f49a73fa3ab9b37dbfcb2151f5a3ab17f8b879f467ee",
- "sha256:0f3e2d0a9966161b7dfd06d147f901d72c3a88ea1a833359b92193b8e1f68e1c",
- "sha256:114398d0e073b93e1d7da5b5ab92ff4b83c0180625c8031911425e51f4365d2e",
- "sha256:1be66b9a89e367e7d20d6cae419794997921fe105090fafd86ef39e20a3baab2",
- "sha256:1c5e93c40d4ce8cb133d3b105a869be6fa767e703f6eb1003eb4b90583e08a59",
- "sha256:1e977a3ed998a599bda5021fb2c2889060617627d3ae228297a529a082a3cd5c",
- "sha256:22cf3406d135cfcc13ec6228ade774c8461e125c940e80455f500638429be273",
- "sha256:24adccf1e834f82718c7fc8e3ec1093738da95144b8b1e44c99d5fc7d3e9c554",
- "sha256:2a3e362c97a5e6a259ee9cd66553292a1f8928a5bdfa3622fdb1501570834612",
- "sha256:3518f9fc666cbc58a5c1f48a6a23e9e6ceef69665eab43cdad5144de9383e72c",
- "sha256:3709339f4619e8c9b00f53079e40b964f43c5af61fb89a923fe24437167298bb",
- "sha256:3832e26ecbc9d8a500821e3a1d3765bda99d04ae29ffbb2efba49f5f788dc934",
- "sha256:452d159024faf37cc080537df308e8fa0026076eb38eb75185d96ed9642bd6d7",
- "sha256:4fd1f0c2dc02aaec729d91c92cd85a2df0289d88e9f68d1e8faba750bb9c4786",
- "sha256:4fda62030f2c515b6e2e673c57caa55cb04026a81968f3128aae10fc28e5cc27",
- "sha256:5044d75a68b49ce36a813c82d8201384207112d5d81643937fc758c05302f05b",
- "sha256:522184556921512ec484cb93bd84e0bab915d0ac5a372d49571c241a7f73db62",
- "sha256:5914cff11f3e920626da48e564be6818831713a3087586302444b9c70e8552d9",
- "sha256:653d48fe46378f40e3c2b892be88d8440efbb2c9df78559da44c63ad5ecb4142",
- "sha256:6661a7908d68c4a133e03dac8178287aa20a99f841ea90beeb98a233ae3fd710",
- "sha256:6735a7e560df6f0deb78246a6fe056cf2ae392ba2dc060ea8a6f2535aec924f1",
- "sha256:6d26a475a19cb294225738f5c974b3a24599438a67a30ed2d25638f012668026",
- "sha256:791f07fe13937e65285f9ef30664ddf0e10a0230bdb236751fa0ca67725740dd",
- "sha256:79258a8df3e309a54c7ef2ef4a59bb8e28f7e4a8992a3ad17c24b1889ced44f3",
- "sha256:7d74c20b8f1c3e99d3f781d3b8ff5abfefdd7363d61e23bdeba9992ff32cc4b4",
- "sha256:81918afeafc16ba5d9d0d4e9445905f21aac969a4ebb6f2bff4b9886da100f4b",
- "sha256:8194d913ca1f459377c8a4ed8f9b7ad750068b8e0e3f3f9c6963fcc87a84515f",
- "sha256:84d5d31200b11b3c76fab853b89ac898bf2d05c8b3da07c1fcc23feb06359d6e",
- "sha256:989981db57abffb52026b114c9a1f114c7142860a6d30a352d28f8cbf186500b",
- "sha256:a3d7511d3fad1618a82299aab71a5fceee5c015653a77ffea75ced9ef917e71a",
- "sha256:a4a6ac01b8c2f9d2d83719f193e6dea493e18445ce5bfd743d739174daa974d9",
- "sha256:acb90eb6c7ed6526551a78211d84c81e33082a35642ff5fe57489abc14e6bf6e",
- "sha256:b3ef168d4d6fd4fa6685aef7c91400f59f7ab1c0da734541f7031699741fb23f",
- "sha256:c1c5792b6e74bbf2af0f8e892272c2a6c48efa895903211f11b8342e03129fea",
- "sha256:c5dcb5a56aebb8a8f2585042b2f5c496d7624f0bcfe248f0cc33ceb2fd8d39e7",
- "sha256:d16f90810106822833a19bdb24c7cb766959acf791ca0edf5edfec674d55c8ee",
- "sha256:dcdc9cd9880027688007ff8f7c8e7ae6f24e81fae33bfd18d1e691e7bda4855f",
- "sha256:e2807aad4565d8de15391a9548f97818a14ef32624015c7bf3095171e314445e",
- "sha256:e2bed4a04e2ca1050bb5f00865cf2f83c0b92fd62454d9244f690fcd842e27a4",
- "sha256:e87a527c06319428007e8c30511e1f0ce035cb7f14bb4793b003ed532c3b9333",
- "sha256:ebcfc33a6c34984086451e230253bc33727bd17b4cdc4b39ec03032c3a6fc9e9",
- "sha256:f63e420180cbe22ff6e32558b612e75f50616fc111c5e095a4631946c782e109",
- "sha256:f7717eb360d40e7598c30cc44b33d98f79c468d9279379b66c1e28c568e0bf47",
- "sha256:f8582e1ab155302ea9ef1235441a0214919f4f79c4c7c21833ce9eec58181781",
- "sha256:f8b3d413c5a8f84b12cd4c5df1d8e211777c9852c6be3ee9c094b626644d3eab"
+ "sha256:00203f406818c3f45d47bb8fe7e67d3feddb8dcbbd45a289a1de7dd789226360",
+ "sha256:0616f800f348664e694dddb0b0c88d26761dd5e9f34e1ed7b7a7d2da14b40cb7",
+ "sha256:1f7908aab90c92ad85af9d2fec5fc79456a89b3adcc26314d2cde0e238bd789e",
+ "sha256:2ea3517cd5779843de8a759c2349a3cd8d3893e03ab47053b66d5ec6f8bc4f93",
+ "sha256:48a9f0538c91fc136b3a576bee0e7cd174773dc9920b310c21dcb5519722e82c",
+ "sha256:5280ebc42641a1283b7b1f2c20e5b936692198b9dd9995527c18b794850be1a8",
+ "sha256:5e34e4b5764af65551647f5cc67cf5198c1d05621781d5173b342e5e55bf023b",
+ "sha256:63b120421ab85cad909792583f83b6ca3584610c2fe70751e23f606a3c2e87f0",
+ "sha256:696b5e0109fe368d0057f484e2e91717b49a03f1e310f857f133a4acec9f91dd",
+ "sha256:870ed021a42b1b02b5fe4a739ea735f671a84128c0a666c705db2cb9abd528eb",
+ "sha256:916da1c19e4012d06a372127d7140dae894806fad67ef44330e5600d77833581",
+ "sha256:9303a289fa0811e1c6abd9ddebfc770556d7c3311cb2b32eff72164ddc49bc64",
+ "sha256:9577888ecc0ad7d06c3746afaba339c94d62b59da16f7a5d1cff9e491f23dace",
+ "sha256:987e1c94a33c93d9b209315bfda9faa54b8edfce6438a1e93ae866ba20de5956",
+ "sha256:99a3bbdbb844f4fb5d6dd59fac836a40749781c1fa63c563bc216c27aef63f60",
+ "sha256:99db8dc3097ceafbcff9cb2bff384b974795edeb11d167d391a02c7bfeeb6e16",
+ "sha256:a5a96cf49eb580756a44ecf12949e52f211e20bffbf5a95760ac14b1e499cd37",
+ "sha256:aa6ca3eb56704cdc0d876fc6047ffd5ee960caad52452fbee0f99908a141a0ae",
+ "sha256:aade5e66795c94e4a2b2624affeea8979648d1b0ae3fcee17e74e2c647fc4a8a",
+ "sha256:b78905860336c1d292409e3df6ad39cc1f1c7f0964e66844bbc2ebfca434d073",
+ "sha256:b92f521cdc4e4a3041cc343625b699f20b0b5f976793fb45681aac1efda565f8",
+ "sha256:bfde84bbd6ae5f782206d454b67b7ee8f7f818c29b99fd02bf022fd33bab14cb",
+ "sha256:c2b62d3df80e694c0e4a0ed47754c9480521e25642251b3ab1dff050a4e60409",
+ "sha256:c5e2be6c263b64f6f7656e23e18a4a9980cffc671442795682e8c4e4f815dd9f",
+ "sha256:c99aa3c63104e0818ec566f8ff3942fb7c7a8f35f9912cb63fd8e12318b214b2",
+ "sha256:dae06620d3978da346375ebf88b9e2dd7d151335ba668c995aea9ed07af7add4",
+ "sha256:db5499d0710823fa4fb88206050d46544e8f0e0136a9a5f5570b026584c8fd74",
+ "sha256:f36baafd82119c4a114b9518202f2a983819101dcc14b26e43fc12cbefdce00e",
+ "sha256:f52b79c8796d81391ab295b04e520bda6feed54d54931708872e8f9ae9db0ea1",
+ "sha256:ff8cff01582fa1a7e533cb97f628531c4014af4b5f38e33cdcfe5eec29b6d888"
],
"index": "pypi",
- "version": "==5.2.0"
+ "version": "==5.3.0"
},
"pycares": {
"hashes": [
@@ -325,6 +408,12 @@
],
"version": "==2.3.0"
},
+ "pycparser": {
+ "hashes": [
+ "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
+ ],
+ "version": "==2.19"
+ },
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
@@ -332,39 +421,70 @@
],
"version": "==2.2.0"
},
+ "pynacl": {
+ "hashes": [
+ "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca",
+ "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512",
+ "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6",
+ "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776",
+ "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac",
+ "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b",
+ "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb",
+ "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98",
+ "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd",
+ "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2",
+ "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef",
+ "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f",
+ "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988",
+ "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b",
+ "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415",
+ "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2",
+ "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101",
+ "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0",
+ "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582",
+ "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a",
+ "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975",
+ "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1",
+ "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb",
+ "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45",
+ "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031",
+ "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9",
+ "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752",
+ "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0",
+ "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c",
+ "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053",
+ "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4"
+ ],
+ "version": "==1.2.1"
+ },
"pyparsing": {
"hashes": [
- "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
- "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
- "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
- "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
- "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
- "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
- "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
+ "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b",
+ "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592"
],
- "version": "==2.2.0"
+ "version": "==2.3.0"
},
"python-dateutil": {
"hashes": [
- "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
- "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"
+ "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93",
+ "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02"
],
"index": "pypi",
- "version": "==2.7.3"
+ "version": "==2.7.5"
},
"python-json-logger": {
"hashes": [
- "sha256:a292e22c5e03105a05a746ade6209d43db1c4c763b91c75c8486e81d10904d85",
- "sha256:e3636824d35ba6a15fc39f573588cba63cf46322a5dc86fb2f280229077e9fbe"
+ "sha256:3e000053837500f9eb28d6228d7cb99fabfc1874d34b40c08289207292abaf2e",
+ "sha256:cf2caaf34bd2eff394915b6242de4d0245de79971712439380ece6f149748cde"
],
- "version": "==0.1.9"
+ "version": "==0.1.10"
},
"pytz": {
"hashes": [
- "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
- "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
+ "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca",
+ "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6"
],
- "version": "==2018.5"
+ "version": "==2018.7"
},
"pyyaml": {
"hashes": [
@@ -385,10 +505,11 @@
},
"requests": {
"hashes": [
- "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
- "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
+ "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54",
+ "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263"
],
- "version": "==2.19.1"
+ "index": "pypi",
+ "version": "==2.20.1"
},
"shortuuid": {
"hashes": [
@@ -412,11 +533,11 @@
},
"sphinx": {
"hashes": [
- "sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc",
- "sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896"
+ "sha256:120732cbddb1b2364471c3d9f8bfd4b0c5b550862f99a65736c77f970b142aea",
+ "sha256:b348790776490894e0424101af9c8413f2a86831524bd55c5f379d3e3e12ca64"
],
"index": "pypi",
- "version": "==1.7.6"
+ "version": "==1.8.2"
},
"sphinxcontrib-websupport": {
"hashes": [
@@ -425,76 +546,68 @@
],
"version": "==1.1.0"
},
- "sympy": {
- "hashes": [
- "sha256:286ca070d72e250861dea7a21ab44f541cb2341e8268c70264cf8642dbd9225f"
- ],
- "index": "pypi",
- "version": "==1.2"
- },
"urllib3": {
"hashes": [
- "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
- "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
+ "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
+ "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
- "version": "==1.23"
+ "version": "==1.24.1"
},
"websockets": {
"hashes": [
- "sha256:0c31bc832d529dc7583d324eb6c836a4f362032a1902723c112cf57883488d8c",
- "sha256:1f3e5a52cab6daa3d432c7b0de0a14109be39d2bfaad033ee5de4a3d3e11dcdf",
- "sha256:341824d8c9ad53fc43cca3fa9407f294125fa258592f7676640396501448e57e",
- "sha256:367ff945bc0950ad9634591e2afe50bf2222bc4fad1088a386c4bb700888026e",
- "sha256:3859ca16c229ddb0fa21c5090e4efcb037c08ce69b0c1dfed6122c3f98cd0c22",
- "sha256:3d425ae081fb4ba1eef9ecf30472ffd79f8e868297ccc7a47993c96dbf2a819c",
- "sha256:64896a6b3368c959b8096b655e46f03dfa65b96745249f374bd6a35705cc3489",
- "sha256:6df87698022aef2596bffdfecc96d656db59c8d719708c8a471daa815ee61656",
- "sha256:80188abdadd23edaaea05ce761dc9a2e1df31a74a0533967f0dcd9560c85add0",
- "sha256:d1a0572b6edb22c9208e3e5381064e09d287d2a915f90233fef994ee7a14a935",
- "sha256:da4d4fbe059b0453e726d6d993760065d69b823a27efc3040402a6fcfe6a1ed9",
- "sha256:da7610a017f5343fdf765f4e0eb6fd0dfd08264ca1565212b110836d9367fc9c",
- "sha256:ebdd4f18fe7e3bea9bd3bf446b0f4117739478caa2c76e4f0fb72cc45b03cbd7",
- "sha256:f5192da704535a7cbf76d6e99c1ec4af7e8d1288252bf5a2385d414509ded0cf",
- "sha256:fd81af8cf3e69f9a97f3a6c0623a0527de0f922c2df725f00cd7646d478af632",
- "sha256:fecf51c13195c416c22422353b306dddb9c752e4b80b21e0fa1fccbe38246677"
- ],
- "index": "pypi",
- "version": "==4.0.1"
+ "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136",
+ "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6",
+ "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1",
+ "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538",
+ "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4",
+ "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908",
+ "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0",
+ "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d",
+ "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c",
+ "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d",
+ "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c",
+ "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb",
+ "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf",
+ "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e",
+ "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96",
+ "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584",
+ "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484",
+ "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d",
+ "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559",
+ "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff",
+ "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
+ ],
+ "version": "==6.0"
},
"yarl": {
"hashes": [
- "sha256:045dbba18c9142278113d5dc62622978a6f718ba662392d406141c59b540c514",
- "sha256:17e57a495efea42bcfca08b49e16c6d89e003acd54c99c903ea1cb3de0ba1248",
- "sha256:213e8f54b4a942532d6ac32314c69a147d3b82fa1725ca05061b7c1a19a1d9b1",
- "sha256:3353fae45d93cc3e7e41bfcb1b633acc37db821d368e660b03068dbfcf68f8c8",
- "sha256:51a084ff8756811101f8b5031a14d1c2dd26c666976e1b18579c6b1c8761a102",
- "sha256:5580f22ac1298261cd24e8e584180d83e2cca9a6167113466d2d16cb2aa1f7b1",
- "sha256:64727a2593fdba5d6ef69e94eba793a196deeda7152c7bd3a64edda6b1f95f6e",
- "sha256:6e75753065c310befab71c5077a59b7cb638d2146b1cfbb1c3b8f08b51362714",
- "sha256:7236eba4911a5556b497235828e7a4bc5d90957efa63b7c4b3e744d2d2cf1b94",
- "sha256:a69dd7e262cdb265ac7d5e929d55f2f3d07baaadd158c8f19caebf8dde08dfe8",
- "sha256:d9ca55a5a297408f08e5401c23ad22bd9f580dab899212f0d5dc1830f0909404",
- "sha256:e072edbd1c5628c0b8f97d00cf6c9fcd6a4ee2b5ded10d463fcb6eaa066cf40c",
- "sha256:e9a6a319c4bbfb57618f207e86a7c519ab0f637be3d2366e4cdac271577834b8"
- ],
- "index": "pypi",
- "version": "==1.1.1"
+ "sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9",
+ "sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee",
+ "sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308",
+ "sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357",
+ "sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78",
+ "sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8",
+ "sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1",
+ "sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4",
+ "sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7"
+ ],
+ "version": "==1.2.6"
}
},
"develop": {
"attrs": {
"hashes": [
- "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
- "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
+ "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
+ "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
- "version": "==18.1.0"
+ "version": "==18.2.0"
},
"certifi": {
"hashes": [
- "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
- "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
+ "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
+ "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
],
- "version": "==2018.4.16"
+ "version": "==2018.10.15"
},
"chardet": {
"hashes": [
@@ -505,10 +618,10 @@
},
"click": {
"hashes": [
- "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
- "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
+ "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
+ "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
],
- "version": "==6.7"
+ "version": "==7.0"
},
"dodgy": {
"hashes": [
@@ -526,19 +639,19 @@
},
"flake8": {
"hashes": [
- "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
- "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
+ "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670",
+ "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2"
],
"index": "pypi",
- "version": "==3.5.0"
+ "version": "==3.6.0"
},
"flake8-bugbear": {
"hashes": [
- "sha256:541746f0f3b2f1a8d7278e1d2d218df298996b60b02677708560db7c7e620e3b",
- "sha256:5f14a99d458e29cb92be9079c970030e0dd398b2decb179d76d39a5266ea1578"
+ "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83",
+ "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a"
],
"index": "pypi",
- "version": "==18.2.0"
+ "version": "==18.8.0"
},
"flake8-import-order": {
"hashes": [
@@ -587,36 +700,31 @@
},
"packaging": {
"hashes": [
- "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
- "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
+ "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
+ "sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
],
- "version": "==17.1"
+ "version": "==18.0"
},
"pycodestyle": {
"hashes": [
- "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
- "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
+ "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83",
+ "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a"
],
- "version": "==2.3.1"
+ "version": "==2.4.0"
},
"pyflakes": {
"hashes": [
- "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
- "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
+ "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49",
+ "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae"
],
- "version": "==1.6.0"
+ "version": "==2.0.0"
},
"pyparsing": {
"hashes": [
- "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
- "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
- "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
- "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
- "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
- "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
- "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
+ "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b",
+ "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592"
],
- "version": "==2.2.0"
+ "version": "==2.3.0"
},
"pyyaml": {
"hashes": [
@@ -637,18 +745,19 @@
},
"requests": {
"hashes": [
- "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
- "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
+ "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54",
+ "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263"
],
- "version": "==2.19.1"
+ "index": "pypi",
+ "version": "==2.20.1"
},
"safety": {
"hashes": [
- "sha256:2689fe629bafe9450796d36578aa112820ff65038578aee004f60b9db1ba4ae8",
- "sha256:cd04e57ff8cf8984ff2cb11973e1d5469dae681e25d4edfccb1ef08cc107b2c0"
+ "sha256:399511524f47230d5867f1eb75548f9feefb7a2711a4985cb5be0e034f87040f",
+ "sha256:69b970918324865dcd7b92337e07152a0ea1ceecaf92f4d3b38529ee0ca83441"
],
"index": "pypi",
- "version": "==1.8.3"
+ "version": "==1.8.4"
},
"six": {
"hashes": [
@@ -659,10 +768,10 @@
},
"urllib3": {
"hashes": [
- "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
- "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
+ "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
+ "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
],
- "version": "==1.23"
+ "version": "==1.24.1"
}
}
}
diff --git a/README.md b/README.md
index 26d2c9e56..1c9e52b71 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-Python Utility Bot
-==================
+# Python Utility Bot
+[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot%20(Mainline))](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1)
[![Discord](https://discordapp.com/api/guilds/267624335836053506/embed.png)](https://discord.gg/2B963hn)
-This project is a Discord bot specifically for use with the Python Discord server. It will provide numerous utilities
+This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities
and other tools to help keep the server running like a well-oiled machine.
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
new file mode 100644
index 000000000..6a63cfe21
--- /dev/null
+++ b/azure-pipelines.yml
@@ -0,0 +1,57 @@
+# https://aka.ms/yaml
+
+variables:
+ PIPENV_HIDE_EMOJIS: 1
+ PIPENV_IGNORE_VIRTUALENVS: 1
+ PIPENV_NOSPIN: 1
+
+jobs:
+- job: test
+ displayName: 'Lint & Test'
+
+ pool:
+ vmImage: 'Ubuntu 16.04'
+
+ variables:
+ PIPENV_CACHE_DIR: ".cache/pipenv"
+ PIP_CACHE_DIR: ".cache/pip"
+ PIP_SRC: ".cache/src"
+
+ steps:
+ - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev
+ displayName: 'Install base dependencies'
+
+ - task: UsePythonVersion@0
+ displayName: 'Set Python version'
+ inputs:
+ versionSpec: '3.7.x'
+ addToPath: true
+
+ - script: sudo pip install pipenv
+ displayName: 'Install pipenv'
+
+ - script: pipenv install --dev --deploy --system
+ displayName: 'Install project using pipenv'
+
+ - script: python -m flake8
+ displayName: 'Run linter'
+
+- job: build
+ displayName: 'Build Containers'
+ dependsOn: 'test'
+
+ steps:
+ - task: Docker@1
+ displayName: 'Login: Docker Hub'
+
+ inputs:
+ containerregistrytype: 'Container Registry'
+ dockerRegistryEndpoint: 'DockerHub'
+ command: 'login'
+
+ - task: ShellScript@2
+ displayName: 'Build and deploy containers'
+
+ inputs:
+ scriptPath: scripts/deploy-azure.sh
+ args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)'
diff --git a/bot/__main__.py b/bot/__main__.py
index 666c2d61f..c598fd921 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -14,7 +14,7 @@ from bot.utils.service_discovery import wait_for_rmq
log = logging.getLogger(__name__)
bot = Bot(
- command_prefix=when_mentioned_or("!"),
+ command_prefix=when_mentioned_or(BotConfig.prefix),
activity=Game(name="Commands: !help"),
case_insensitive=True,
max_messages=10_000
@@ -52,6 +52,7 @@ bot.load_extension("bot.cogs.bigbrother")
bot.load_extension("bot.cogs.bot")
bot.load_extension("bot.cogs.clean")
bot.load_extension("bot.cogs.cogs")
+bot.load_extension("bot.cogs.help")
# Only load this in production
if not DEBUG_MODE:
@@ -59,15 +60,21 @@ if not DEBUG_MODE:
bot.load_extension("bot.cogs.verification")
# Feature cogs
+<<<<<<< HEAD
+=======
+bot.load_extension("bot.cogs.alias")
+bot.load_extension("bot.cogs.deployment")
+>>>>>>> master
bot.load_extension("bot.cogs.defcon")
bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.fun")
-bot.load_extension("bot.cogs.hiphopify")
+bot.load_extension("bot.cogs.superstarify")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.moderation")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
+bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
bot.load_extension("bot.cogs.snakes")
bot.load_extension("bot.cogs.snekbox")
@@ -75,6 +82,7 @@ bot.load_extension("bot.cogs.sync")
bot.load_extension("bot.cogs.tags")
bot.load_extension("bot.cogs.token_remover")
bot.load_extension("bot.cogs.utils")
+bot.load_extension("bot.cogs.wolfram")
if has_rmq:
bot.load_extension("bot.cogs.rmq")
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
new file mode 100644
index 000000000..2ce4a51e3
--- /dev/null
+++ b/bot/cogs/alias.py
@@ -0,0 +1,181 @@
+import inspect
+import logging
+
+from discord import Colour, Embed, User
+from discord.ext.commands import (
+ Command, Context, clean_content, command, group
+)
+
+from bot.converters import TagNameConverter
+from bot.pagination import LinePaginator
+
+log = logging.getLogger(__name__)
+
+
+class Alias:
+ """
+ Aliases for more used commands
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def invoke(self, ctx, cmd_name, *args, **kwargs):
+ """
+ Invokes a command with args and kwargs.
+ Fail early through `command.can_run`, and logs warnings.
+
+ :param ctx: Context instance for command call
+ :param cmd_name: Name of command/subcommand to be invoked
+ :param args: args to be passed to the command
+ :param kwargs: kwargs to be passed to the command
+ :return: None
+ """
+
+ log.debug(f"{cmd_name} was invoked through an alias")
+ cmd = self.bot.get_command(cmd_name)
+ if not cmd:
+ return log.warning(f'Did not find command "{cmd_name}" to invoke.')
+ elif not await cmd.can_run(ctx):
+ return log.warning(
+ f'{str(ctx.author)} tried to run the command "{cmd_name}"'
+ )
+
+ await ctx.invoke(cmd, *args, **kwargs)
+
+ @command(name='aliases')
+ async def aliases_command(self, ctx):
+ """Show configured aliases on the bot."""
+
+ embed = Embed(
+ title='Configured aliases',
+ colour=Colour.blue()
+ )
+ await LinePaginator.paginate(
+ (
+ f"• `{ctx.prefix}{value.name}` "
+ f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`"
+ for name, value in inspect.getmembers(self)
+ if isinstance(value, Command) and name.endswith('_alias')
+ ),
+ ctx, embed, empty=False, max_lines=20
+ )
+
+ @command(name="resources", aliases=("resource",), hidden=True)
+ async def site_resources_alias(self, ctx):
+ """
+ Alias for invoking <prefix>site resources.
+ """
+
+ await self.invoke(ctx, "site resources")
+
+ @command(name="watch", hidden=True)
+ async def bigbrother_watch_alias(
+ self, ctx, user: User, *, reason: str = None
+ ):
+ """
+ Alias for invoking <prefix>bigbrother watch user [text_channel].
+ """
+
+ await self.invoke(ctx, "bigbrother watch", user, reason=reason)
+
+ @command(name="unwatch", hidden=True)
+ async def bigbrother_unwatch_alias(self, ctx, user: User):
+ """
+ Alias for invoking <prefix>bigbrother unwatch user.
+
+ user: discord.User - A user instance to unwatch
+ """
+
+ await self.invoke(ctx, "bigbrother unwatch", user)
+
+ @command(name="home", hidden=True)
+ async def site_home_alias(self, ctx):
+ """
+ Alias for invoking <prefix>site home.
+ """
+
+ await self.invoke(ctx, "site home")
+
+ @command(name="faq", hidden=True)
+ async def site_faq_alias(self, ctx):
+ """
+ Alias for invoking <prefix>site faq.
+ """
+
+ await self.invoke(ctx, "site faq")
+
+ @command(name="rules", hidden=True)
+ async def site_rules_alias(self, ctx):
+ """
+ Alias for invoking <prefix>site rules.
+ """
+
+ await self.invoke(ctx, "site rules")
+
+ @command(name="reload", hidden=True)
+ async def cogs_reload_alias(self, ctx, *, cog_name: str):
+ """
+ Alias for invoking <prefix>cogs reload cog_name.
+
+ cog_name: str - name of the cog to be reloaded.
+ """
+
+ await self.invoke(ctx, "cogs reload", cog_name)
+
+ @command(name="defon", hidden=True)
+ async def defcon_enable_alias(self, ctx):
+ """
+ Alias for invoking <prefix>defcon enable.
+ """
+
+ await self.invoke(ctx, "defcon enable")
+
+ @command(name="defoff", hidden=True)
+ async def defcon_disable_alias(self, ctx):
+ """
+ Alias for invoking <prefix>defcon disable.
+ """
+
+ await self.invoke(ctx, "defcon disable")
+
+ @group(name="get",
+ aliases=("show", "g"),
+ hidden=True,
+ invoke_without_command=True)
+ async def get_group_alias(self, ctx):
+ """
+ Group for reverse aliases for commands like `tags get`,
+ allowing for `get tags` or `get docs`.
+ """
+
+ pass
+
+ @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True)
+ async def tags_get_alias(
+ self, ctx: Context, *, tag_name: TagNameConverter = None
+ ):
+ """
+ Alias for invoking <prefix>tags get [tag_name].
+
+ tag_name: str - tag to be viewed.
+ """
+
+ await self.invoke(ctx, "tags get", tag_name)
+
+ @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True)
+ async def docs_get_alias(
+ self, ctx: Context, symbol: clean_content = None
+ ):
+ """
+ Alias for invoking <prefix>docs get [symbol].
+
+ symbol: str - name of doc to be viewed.
+ """
+
+ await self.invoke(ctx, "docs get", symbol)
+
+
+def setup(bot):
+ bot.add_cog(Alias(bot))
+ log.info("Cog loaded: Alias")
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
index 9ea8efdb0..29b13f038 100644
--- a/bot/cogs/bigbrother.py
+++ b/bot/cogs/bigbrother.py
@@ -1,16 +1,22 @@
+import asyncio
import logging
+import re
+from collections import defaultdict, deque
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.constants import BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Keys, Roles, URLs
from bot.decorators import with_role
from bot.pagination import LinePaginator
-
+from bot.utils import messages
+from bot.utils.moderation import post_infraction
log = logging.getLogger(__name__)
+URL_RE = re.compile(r"(https?://[^\s]+)")
+
class BigBrother:
"""User monitoring to assist with moderation."""
@@ -19,7 +25,12 @@ class BigBrother:
def __init__(self, bot: Bot):
self.bot = bot
- self.watched_users = {}
+ self.watched_users = {} # { user_id: log_channel_id }
+ self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) }
+ self.last_log = [None, None, 0] # [user_id, channel_id, message_count]
+ self.consuming = False
+
+ self.bot.loop.create_task(self.get_watched_users())
def update_cache(self, api_response: List[dict]):
"""
@@ -43,7 +54,10 @@ class BigBrother:
"but the given channel could not be found. Ignoring."
)
- async def on_ready(self):
+ async def get_watched_users(self):
+ """Retrieves watched users from the API."""
+
+ await self.bot.wait_until_ready()
async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
data = await response.json()
self.update_cache(data)
@@ -55,9 +69,10 @@ class BigBrother:
async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
del self.watched_users[user.id]
+ del self.channel_queues[user.id]
if response.status == 204:
await channel.send(
- f"{Emojis.lemoneye2}:hammer: {user} got banned, so "
+ f"{Emojis.bb_message}:hammer: {user} got banned, so "
f"`BigBrother` will no longer relay their messages to {channel}"
)
@@ -65,25 +80,102 @@ class BigBrother:
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"{Emojis.bb_message}: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):
+ """Queues up messages sent by watched users."""
+
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))"
+ if not self.consuming:
+ self.bot.loop.create_task(self.consume_messages())
+
+ log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
+ self.channel_queues[msg.author.id][msg.channel.id].append(msg)
+
+ async def consume_messages(self):
+ """Consumes the message queues to log watched users' messages."""
+
+ if not self.consuming:
+ self.consuming = True
+ log.trace("Sleeping before consuming...")
+ await asyncio.sleep(BigBrotherConfig.log_delay)
+
+ log.trace("Begin consuming messages.")
+ channel_queues = self.channel_queues.copy()
+ self.channel_queues.clear()
+ for user_id, queues in channel_queues.items():
+ for _, queue in queues.items():
+ channel = self.watched_users[user_id]
+ while queue:
+ msg = queue.popleft()
+ log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)")
+
+ self.last_log[2] += 1 # Increment message count.
+ await self.send_header(msg, channel)
+ await self.log_message(msg, channel)
+
+ if self.channel_queues:
+ log.trace("Queue not empty; continue consumption.")
+ self.bot.loop.create_task(self.consume_messages())
+ else:
+ log.trace("Done consuming messages.")
+ self.consuming = False
+
+ async def send_header(self, message: Message, destination: TextChannel):
+ """
+ Sends a log message header to the given channel.
+
+ A header is only sent if the user or channel are different than the previous, or if the configured message
+ limit for a single header has been exceeded.
+
+ :param message: the first message in the queue
+ :param destination: the channel in which to send the header
+ """
+
+ last_user, last_channel, msg_count = self.last_log
+ limit = BigBrotherConfig.header_message_limit
+
+ # Send header if user/channel are different or if message limit exceeded.
+ if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit:
+ self.last_log = [message.author.id, message.channel.id, 0]
- await channel.send(relay_content)
+ embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})")
+ embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url)
+ await destination.send(embed=embed)
- @group(name='bigbrother', aliases=('bb',))
+ @staticmethod
+ async def log_message(message: Message, destination: TextChannel):
+ """
+ Logs a watched user's message in the given channel.
+
+ Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview
+ embeds from being automatically generated.
+
+ :param message: the message to log
+ :param destination: the channel in which to log the message
+ """
+
+ content = message.clean_content
+ if content:
+ # Put all non-media URLs in inline code blocks.
+ media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")}
+ for url in URL_RE.findall(content):
+ if url not in media_urls:
+ content = content.replace(url, f"`{url}`")
+
+ await destination.send(content)
+
+ await messages.send_attachments(message, destination)
+
+ @group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
@with_role(Roles.owner, Roles.admin, Roles.moderator)
async def bigbrother_group(self, ctx: Context):
"""Monitor users, NSA-style."""
+ await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+
@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):
@@ -124,16 +216,15 @@ class BigBrother:
@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):
+ async def watch_command(self, ctx: Context, user: User, *, reason: str):
"""
- Relay messages sent by the given `user` in the given `channel`.
- If `channel` is not specified, logs to the mod log channel.
+ Relay messages sent by the given `user` to the `#big-brother-logs` channel
+
+ A `reason` for watching is required, which is added for the user to be watched as a
+ note (aka: shadow warning)
"""
- if channel is not None:
- channel_id = channel.id
- else:
- channel_id = Channels.big_brother_logs
+ channel_id = Channels.big_brother_logs
post_data = {
'user_id': str(user.id),
@@ -161,6 +252,10 @@ class BigBrother:
reason = data.get('error_message', "no message provided")
await ctx.send(f":x: the API returned an error: {reason}")
+ # Add a note (shadow warning) with the reason for watching
+ reason = "bb watch: " + reason # Prepend for situational awareness
+ await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+
@bigbrother_group.command(name='unwatch', aliases=('uw',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
async def unwatch_command(self, ctx: Context, user: User):
@@ -173,6 +268,8 @@ class BigBrother:
if user.id in self.watched_users:
del self.watched_users[user.id]
+ if user.id in self.channel_queues:
+ del self.channel_queues[user.id]
else:
log.warning(f"user {user.id} was unwatched but was not found in the cache")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index fcc642313..b684ad886 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -3,14 +3,15 @@ import logging
import re
import time
-from discord import Embed, Member, Message, Reaction
+from discord import Embed, Message, RawMessageUpdateEvent
from discord.ext.commands import Bot, Context, command, group
from dulwich.repo import Repo
from bot.constants import (
- Channels, Emojis, Guild, Roles, URLs
+ Channels, Guild, Roles, URLs
)
from bot.decorators import with_role
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ class Bot:
"""
embed = Embed(
- description="A utility bot designed just for the Python server! Try `!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"
)
@@ -236,8 +237,7 @@ class Bot:
"\u3003\u3003\u3003"
]
- has_bad_ticks = msg.content[:3] in not_backticks
- return has_bad_ticks
+ return msg.content[:3] in not_backticks
async def on_message(self, msg: Message):
"""
@@ -286,9 +286,9 @@ class Bot:
howto = (
"It looks like you are trying to paste code into this channel.\n\n"
"You seem to be using the wrong symbols to indicate where the codeblock should start. "
- f"The correct symbols would be \`\`\`, not `{ticks}`.\n\n"
+ f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n"
"**Here is an example of how it should look:**\n"
- f"\`\`\`python\n{content}\n\`\`\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -330,7 +330,7 @@ class Bot:
"syntax highlighting. Please use these whenever you paste code, as this "
"helps improve the legibility and makes it easier for us to help you.\n\n"
f"**To do this, use the following method:**\n"
- f"\`\`\`python\n{content}\n\`\`\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -343,7 +343,10 @@ class Bot:
howto_embed = Embed(description=howto)
bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed)
self.codeblock_message_ids[msg.id] = bot_message.id
- await bot_message.add_reaction(Emojis.cross_mark)
+
+ self.bot.loop.create_task(
+ wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot)
+ )
else:
return
@@ -357,42 +360,29 @@ class Bot:
f"The message that was posted was:\n\n{msg.content}\n\n"
)
- async def on_message_edit(self, before: Message, after: Message):
- has_fixed_codeblock = (
- # Checks if the original message was previously called out by the bot
- before.id in self.codeblock_message_ids
- # Checks to see if the user has corrected their codeblock
- and self.codeblock_stripping(after.content, self.has_bad_ticks(after)) is None
- )
- if has_fixed_codeblock:
- bot_message = await after.channel.get_message(self.codeblock_message_ids[after.id])
- await bot_message.delete()
- del self.codeblock_message_ids[after.id]
-
- async def on_reaction_add(self, reaction: Reaction, user: Member):
- # Ignores reactions added by the bot or added to non-codeblock correction embed messages
- if user.bot or reaction.message.id not in self.codeblock_message_ids.values():
+ async def on_raw_message_edit(self, payload: RawMessageUpdateEvent):
+ if (
+ # Checks to see if the message was called out by the bot
+ payload.message_id not in self.codeblock_message_ids
+ # Makes sure that there is content in the message
+ or payload.data.get("content") is None
+ # Makes sure there's a channel id in the message payload
+ or payload.data.get("channel_id") is None
+ ):
return
- # Finds the appropriate bot message/ user message pair and assigns them to variables
- for user_message_id, bot_message_id in self.codeblock_message_ids.items():
- if bot_message_id == reaction.message.id:
- user_message = await reaction.message.channel.get_message(user_message_id)
- bot_message = await reaction.message.channel.get_message(bot_message_id)
- break
+ # Retrieve channel and message objects for use later
+ channel = self.bot.get_channel(payload.data.get("channel_id"))
+ user_message = await channel.get_message(payload.message_id)
- # If the reaction was clicked on by the author of the user message, deletes the bot message
- if user.id == user_message.author.id:
- await bot_message.delete()
- del self.codeblock_message_ids[user_message_id]
- return
+ # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None
+ has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message))
- # If the reaction was clicked by staff (mod or higher), deletes the bot message
- for role in user.roles:
- if role.id in (Roles.owner, Roles.admin, Roles.moderator):
- await bot_message.delete()
- del self.codeblock_message_ids[user_message_id]
- return
+ # If the message is fixed, delete the bot message and the entry from the id dictionary
+ if has_fixed_codeblock is None:
+ bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id])
+ await bot_message.delete()
+ del self.codeblock_message_ids[payload.message_id]
def setup(bot):
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
index 780850b5a..0a33b3de0 100644
--- a/bot/cogs/cogs.py
+++ b/bot/cogs/cogs.py
@@ -1,7 +1,7 @@
import logging
import os
-from discord import ClientException, Colour, Embed
+from discord import Colour, Embed
from discord.ext.commands import Bot, Context, group
from bot.constants import (
@@ -36,11 +36,13 @@ class Cogs:
# Allow reverse lookups by reversing the pairs
self.cogs.update({v: k for k, v in self.cogs.items()})
- @group(name='cogs', aliases=('c',))
+ @group(name='cogs', aliases=('c',), invoke_without_command=True)
@with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
async def cogs_group(self, ctx: Context):
"""Load, unload, reload, and list active cogs."""
+ await ctx.invoke(self.bot.get_command("help"), "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):
@@ -75,10 +77,6 @@ class Cogs:
if full_cog not in self.bot.extensions:
try:
self.bot.load_extension(full_cog)
- except ClientException:
- log.error(f"{ctx.author} requested we load the '{cog}' cog, "
- "but that cog doesn't have a 'setup()' function.")
- embed.description = f"Invalid cog: {cog}\n\nCog does not have a `setup()` function"
except ImportError:
log.error(f"{ctx.author} requested we load the '{cog}' cog, "
f"but the cog module {full_cog} could not be found!")
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index beb05ba46..c432d377c 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -102,7 +102,7 @@ class Defcon:
async def defcon_group(self, ctx: Context):
"""Check the DEFCON status or run a subcommand."""
- await ctx.invoke(self.status_command)
+ await ctx.invoke(self.bot.get_command("help"), "defcon")
@defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admin, Roles.owner)
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
index 790af582b..bc9dbf5ab 100644
--- a/bot/cogs/deployment.py
+++ b/bot/cogs/deployment.py
@@ -17,11 +17,13 @@ class Deployment:
def __init__(self, bot: Bot):
self.bot = bot
- @group(name='redeploy')
+ @group(name='redeploy', invoke_without_command=True)
@with_role(Roles.owner, Roles.admin, Roles.moderator)
async def redeploy_group(self, ctx: Context):
"""Redeploy the bot or the site."""
+ await ctx.invoke(self.bot.get_command("help"), "redeploy")
+
@redeploy_group.command(name='bot')
@with_role(Roles.admin, Roles.owner, Roles.devops)
async def bot_command(self, ctx: Context):
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 30e528efa..8e97a35a2 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -66,9 +66,9 @@ class CodeEval:
# far enough to align them.
# we first `str()` the line number
# then we get the length
- # and do a simple {:<LENGTH}
+ # and use `str.rjust()`
# to indent it.
- start = f"{'':<{len(str(self.ln))+2}}...: "
+ start = "...: ".rjust(len(str(self.ln)) + 7)
if i == len(lines) - 2:
if line.startswith("return"):
@@ -97,8 +97,7 @@ class CodeEval:
res = (res, out)
else:
- if (isinstance(out, str) and
- out.startswith("Traceback (most recent call last):\n")):
+ if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")):
# Leave out the traceback message
out = "\n" + "\n".join(out.split("\n")[1:])
@@ -115,9 +114,9 @@ class CodeEval:
# Text too long, shorten
li = pretty.split("\n")
- pretty = ("\n".join(li[:3]) + # First 3 lines
- "\n ...\n" + # Ellipsis to indicate removed lines
- "\n".join(li[-3:])) # last 3 lines
+ pretty = ("\n".join(li[:3]) # First 3 lines
+ + "\n ...\n" # Ellipsis to indicate removed lines
+ + "\n".join(li[-3:])) # last 3 lines
# Add the output
res += pretty
@@ -178,12 +177,15 @@ async def func(): # (None,) -> Any
async def internal_group(self, ctx):
"""Internal commands. Top secret!"""
+ if not ctx.invoked_subcommand:
+ await ctx.invoke(self.bot.get_command("help"), "internal")
+
@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. """
code = code.strip("`")
- if code.startswith("py\n"):
+ if re.match('py(thon)?\n', code):
code = "\n".join(code.split("\n")[1:])
if not re.search( # Check if it's an expression
diff --git a/bot/cogs/events.py b/bot/cogs/events.py
index 9a0b0b106..160791fb0 100644
--- a/bot/cogs/events.py
+++ b/bot/cogs/events.py
@@ -4,8 +4,8 @@ from aiohttp import ClientResponseError
from discord import Colour, Embed, Member, Object
from discord.ext.commands import (
BadArgument, Bot, BotMissingPermissions,
- CommandError, CommandInvokeError, Context,
- NoPrivateMessage, UserInputError
+ CommandError, CommandInvokeError, CommandNotFound,
+ Context, NoPrivateMessage, UserInputError
)
from bot.cogs.modlog import ModLog
@@ -122,7 +122,13 @@ class Events:
log.debug(f"Command {command} has a local error handler, ignoring.")
return
- if isinstance(e, BadArgument):
+ if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
+
+ # Return to not raise the exception
+ return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
+ elif isinstance(e, BadArgument):
await ctx.send(f"Bad argument: {e}\n")
await ctx.invoke(*help_command)
elif isinstance(e, UserInputError):
@@ -196,10 +202,10 @@ class Events:
async def on_member_update(self, before: Member, after: Member):
if (
- before.roles == after.roles and
- before.name == after.name and
- before.discriminator == after.discriminator and
- before.avatar == after.avatar):
+ before.roles == after.roles
+ and before.name == after.name
+ and before.discriminator == after.discriminator
+ and before.avatar == after.avatar):
return
before_role_names = [role.name for role in before.roles] # type: List[str]
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 36be78a7e..f5811d9d2 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -22,7 +22,7 @@ INVITE_RE = (
r"([a-zA-Z0-9]+)" # the invite code itself
)
-URL_RE = "(https?://[^\s]+)"
+URL_RE = r"(https?://[^\s]+)"
ZALGO_RE = r"[\u0300-\u036F\u0489]"
RETARDED_RE = r"(re+)tar+(d+|t+)(ed)?"
SELF_DEPRECATION_RE = fr"((i'?m)|(i am)|(it'?s)|(it is)) (.+? )?{RETARDED_RE}"
@@ -217,16 +217,12 @@ class Filtering:
async def _has_invites(self, text: str) -> bool:
"""
- Returns True if the text contains an invite which
- is not on the guild_invite_whitelist in config.yml.
+ Returns True if the text contains an invite which is not on the guild_invite_whitelist in
+ config.yml
- Also catches a lot of common ways to try to cheat the system.
+ Attempts to catch some of common ways to try to cheat the system.
"""
- # Remove spaces to prevent cases like
- # d i s c o r d . c o m / i n v i t e / s e x y t e e n s
- text = text.replace(" ", "")
-
# Remove backslashes to prevent escape character aroundfuckery like
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
@@ -238,7 +234,14 @@ class Filtering:
f"{URLs.discord_invite_api}/{invite}"
)
response = await response.json()
- guild_id = int(response.get("guild", {}).get("id"))
+ guild = response.get("guild")
+ if guild is None:
+ # Lack of a "guild" key in the JSON response indicates either an group DM invite, an
+ # expired invite, or an invalid invite. The API does not currently differentiate
+ # between invalid and expired invites
+ return True
+
+ guild_id = int(guild.get("id"))
if guild_id not in Filter.guild_invite_whitelist:
return True
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
new file mode 100644
index 000000000..d30ff0dfb
--- /dev/null
+++ b/bot/cogs/help.py
@@ -0,0 +1,724 @@
+import asyncio
+import inspect
+import itertools
+from collections import namedtuple
+from contextlib import suppress
+
+from discord import Colour, Embed, HTTPException
+from discord.ext import commands
+from fuzzywuzzy import fuzz, process
+
+from bot import constants
+from bot.pagination import (
+ DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI,
+ LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
+)
+
+REACTIONS = {
+ FIRST_EMOJI: 'first',
+ LEFT_EMOJI: 'back',
+ RIGHT_EMOJI: 'next',
+ LAST_EMOJI: 'end',
+ DELETE_EMOJI: 'stop'
+}
+
+Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+
+
+class HelpQueryNotFound(ValueError):
+ """
+ Raised when a HelpSession Query doesn't match a command or cog.
+
+ Contains the custom attribute of ``possible_matches``.
+
+ Attributes
+ ----------
+ possible_matches: dict
+ Any commands that were close to matching the Query.
+ The possible matched command names are the keys.
+ The likeness match scores are the values.
+ """
+
+ def __init__(self, arg, possible_matches=None):
+ super().__init__(arg)
+ self.possible_matches = possible_matches
+
+
+class HelpSession:
+ """
+ An interactive session for bot and command help output.
+
+ Attributes
+ ----------
+ title: str
+ The title of the help message.
+ query: Union[:class:`discord.ext.commands.Bot`,
+ :class:`discord.ext.commands.Command]
+ description: str
+ The description of the query.
+ pages: list[str]
+ A list of the help content split into manageable pages.
+ message: :class:`discord.Message`
+ The message object that's showing the help contents.
+ destination: :class:`discord.abc.Messageable`
+ Where the help message is to be sent to.
+ """
+
+ def __init__(self, ctx, *command, cleanup=False, only_can_run=True, show_hidden=False, max_lines=15):
+ """
+ Creates an instance of the HelpSession class.
+
+ Parameters
+ ----------
+ ctx: :class:`discord.Context`
+ The context of the invoked help command.
+ *command: str
+ A variable argument of the command being queried.
+ cleanup: Optional[bool]
+ Set to ``True`` to have the message deleted on timeout.
+ If ``False``, it will clear all reactions on timeout.
+ Defaults to ``False``.
+ only_can_run: Optional[bool]
+ Set to ``True`` to hide commands the user can't run.
+ Defaults to ``False``.
+ show_hidden: Optional[bool]
+ Set to ``True`` to include hidden commands.
+ Defaults to ``False``.
+ max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a
+ single page.
+ Defaults to 20.
+ """
+
+ self._ctx = ctx
+ self._bot = ctx.bot
+ self.title = "Command Help"
+
+ # set the query details for the session
+ if command:
+ query_str = ' '.join(command)
+ self.query = self._get_query(query_str)
+ self.description = self.query.description or self.query.help
+ else:
+ self.query = ctx.bot
+ self.description = self.query.description
+ self.author = ctx.author
+ self.destination = ctx.author if ctx.bot.pm_help else ctx.channel
+
+ # set the config for the session
+ self._cleanup = cleanup
+ self._only_can_run = only_can_run
+ self._show_hidden = show_hidden
+ self._max_lines = max_lines
+
+ # init session states
+ self._pages = None
+ self._current_page = 0
+ self.message = None
+ self._timeout_task = None
+ self.reset_timeout()
+
+ def _get_query(self, query):
+ """
+ Attempts to match the provided query with a valid command or cog.
+
+ Parameters
+ ----------
+ query: str
+ The joined string representing the session query.
+
+ Returns
+ -------
+ Union[:class:`discord.ext.commands.Command`, :class:`Cog`]
+ """
+
+ command = self._bot.get_command(query)
+ if command:
+ return command
+
+ cog = self._bot.cogs.get(query)
+ if cog:
+ return Cog(
+ name=cog.__class__.__name__,
+ description=inspect.getdoc(cog),
+ commands=[c for c in self._bot.commands if c.instance is cog]
+ )
+
+ self._handle_not_found(query)
+
+ def _handle_not_found(self, query):
+ """
+ Handles when a query does not match a valid command or cog.
+
+ Will pass on possible close matches along with the
+ ``HelpQueryNotFound`` exception.
+
+ Parameters
+ ----------
+ query: str
+ The full query that was requested.
+
+ Raises
+ ------
+ HelpQueryNotFound
+ """
+
+ # combine command and cog names
+ choices = list(self._bot.all_commands) + list(self._bot.cogs)
+
+ result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
+
+ raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
+
+ async def timeout(self, seconds=30):
+ """
+ Waits for a set number of seconds, then stops the help session.
+
+ Parameters
+ ----------
+ seconds: int
+ Number of seconds to wait.
+ """
+
+ await asyncio.sleep(seconds)
+ await self.stop()
+
+ def reset_timeout(self):
+ """
+ Cancels the original timeout task and sets it again from the start.
+ """
+
+ # cancel original if it exists
+ if self._timeout_task:
+ if not self._timeout_task.cancelled():
+ self._timeout_task.cancel()
+
+ # recreate the timeout task
+ self._timeout_task = self._bot.loop.create_task(self.timeout())
+
+ async def on_reaction_add(self, reaction, user):
+ """
+ Event handler for when reactions are added on the help message.
+
+ Parameters
+ ----------
+ reaction: :class:`discord.Reaction`
+ The reaction that was added.
+ user: :class:`discord.User`
+ The user who added the reaction.
+ """
+
+ # ensure it was the relevant session message
+ if reaction.message.id != self.message.id:
+ return
+
+ # ensure it was the session author who reacted
+ if user.id != self.author.id:
+ return
+
+ emoji = str(reaction.emoji)
+
+ # check if valid action
+ if emoji not in REACTIONS:
+ return
+
+ self.reset_timeout()
+
+ # Run relevant action method
+ action = getattr(self, f'do_{REACTIONS[emoji]}', None)
+ if action:
+ await action()
+
+ # remove the added reaction to prep for re-use
+ with suppress(HTTPException):
+ await self.message.remove_reaction(reaction, user)
+
+ async def on_message_delete(self, message):
+ """
+ Closes the help session when the help message is deleted.
+
+ Parameters
+ ----------
+ message: :class:`discord.Message`
+ The message that was deleted.
+ """
+
+ if message.id == self.message.id:
+ await self.stop()
+
+ async def prepare(self):
+ """
+ Sets up the help session pages, events, message and reactions.
+ """
+
+ # create paginated content
+ await self.build_pages()
+
+ # setup listeners
+ self._bot.add_listener(self.on_reaction_add)
+ self._bot.add_listener(self.on_message_delete)
+
+ # Send the help message
+ await self.update_page()
+ self.add_reactions()
+
+ def add_reactions(self):
+ """
+ Adds the relevant reactions to the help message based on if
+ pagination is required.
+ """
+
+ # if paginating
+ if len(self._pages) > 1:
+ for reaction in REACTIONS:
+ self._bot.loop.create_task(self.message.add_reaction(reaction))
+
+ # if single-page
+ else:
+ self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
+
+ def _category_key(self, cmd):
+ """
+ Returns a cog name of a given command. Used as a key for
+ ``sorted`` and ``groupby``.
+
+ A zero width space is used as a prefix for results with no cogs
+ to force them last in ordering.
+
+ Parameters
+ ----------
+ cmd: :class:`discord.ext.commands.Command`
+ The command object being checked.
+
+ Returns
+ -------
+ str
+ """
+
+ cog = cmd.cog_name
+ return f'**{cog}**' if cog else f'**\u200bNo Category:**'
+
+ def _get_command_params(self, cmd):
+ """
+ Returns the command usage signature.
+
+ This is a custom implementation of ``command.signature`` in
+ order to format the command signature without aliases.
+
+ Parameters
+ ----------
+ cmd: :class:`discord.ext.commands.Command`
+ The command object to get the parameters of.
+
+ Returns
+ -------
+ str
+ """
+
+ results = []
+ for name, param in cmd.clean_params.items():
+
+ # if argument has a default value
+ if param.default is not param.empty:
+
+ if isinstance(param.default, str):
+ show_default = param.default
+ else:
+ show_default = param.default is not None
+
+ # if default is not an empty string or None
+ if show_default:
+ results.append(f'[{name}={param.default}]')
+ else:
+ results.append(f'[{name}]')
+
+ # if variable length argument
+ elif param.kind == param.VAR_POSITIONAL:
+ results.append(f'[{name}...]')
+
+ # if required
+ else:
+ results.append(f'<{name}>')
+
+ return f"{cmd.name} {' '.join(results)}"
+
+ async def build_pages(self):
+ """
+ Builds the list of content pages to be paginated through in the
+ help message.
+
+ Returns
+ -------
+ list[str]
+ """
+
+ # Use LinePaginator to restrict embed line height
+ paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+
+ prefix = constants.Bot.prefix
+
+ # show signature if query is a command
+ if isinstance(self.query, commands.Command):
+ signature = self._get_command_params(self.query)
+ parent = self.query.full_parent_name + ' ' if self.query.parent else ''
+ paginator.add_line(f'**```{prefix}{parent}{signature}```**')
+
+ # show command aliases
+ aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
+ if aliases:
+ paginator.add_line(f'**Can also use:** {aliases}\n')
+
+ if not await self.query.can_run(self._ctx):
+ paginator.add_line('***You cannot run this command.***\n')
+
+ # show name if query is a cog
+ if isinstance(self.query, Cog):
+ paginator.add_line(f'**{self.query.name}**')
+
+ if self.description:
+ paginator.add_line(f'*{self.description}*')
+
+ # list all children commands of the queried object
+ if isinstance(self.query, (commands.GroupMixin, Cog)):
+
+ # remove hidden commands if session is not wanting hiddens
+ if not self._show_hidden:
+ filtered = [c for c in self.query.commands if not c.hidden]
+ else:
+ filtered = self.query.commands
+
+ # if after filter there are no commands, finish up
+ if not filtered:
+ self._pages = paginator.pages
+ return
+
+ # set category to Commands if cog
+ if isinstance(self.query, Cog):
+ grouped = (('**Commands:**', self.query.commands),)
+
+ # set category to Subcommands if command
+ elif isinstance(self.query, commands.Command):
+ grouped = (('**Subcommands:**', self.query.commands),)
+
+ # don't show prefix for subcommands
+ prefix = ''
+
+ # otherwise sort and organise all commands into categories
+ else:
+ cat_sort = sorted(filtered, key=self._category_key)
+ grouped = itertools.groupby(cat_sort, key=self._category_key)
+
+ # process each category
+ for category, cmds in grouped:
+ cmds = sorted(cmds, key=lambda c: c.name)
+
+ # if there are no commands, skip category
+ if len(cmds) == 0:
+ continue
+
+ cat_cmds = []
+
+ # format details for each child command
+ for command in cmds:
+
+ # skip if hidden and hide if session is set to
+ if command.hidden and not self._show_hidden:
+ continue
+
+ # see if the user can run the command
+ strikeout = ''
+ can_run = await command.can_run(self._ctx)
+ if not can_run:
+ # skip if we don't show commands they can't run
+ if self._only_can_run:
+ continue
+ strikeout = '~~'
+
+ signature = self._get_command_params(command)
+ info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+
+ # handle if the command has no docstring
+ if command.short_doc:
+ cat_cmds.append(f'{info}\n*{command.short_doc}*')
+ else:
+ cat_cmds.append(f'{info}\n*No details provided.*')
+
+ # state var for if the category should be added next
+ print_cat = 1
+ new_page = True
+
+ for details in cat_cmds:
+
+ # keep details together, paginating early if it won't fit
+ lines_adding = len(details.split('\n')) + print_cat
+ if paginator._linecount + lines_adding > self._max_lines:
+ paginator._linecount = 0
+ new_page = True
+ paginator.close_page()
+
+ # new page so print category title again
+ print_cat = 1
+
+ if print_cat:
+ if new_page:
+ paginator.add_line('')
+ paginator.add_line(category)
+ print_cat = 0
+
+ paginator.add_line(details)
+
+ # save organised pages to session
+ self._pages = paginator.pages
+
+ def embed_page(self, page_number=0):
+ """
+ Returns an Embed with the requested page formatted within.
+
+ Parameters
+ ----------
+ page_number: int
+ The page to be retrieved. Zero indexed.
+
+ Returns
+ -------
+ :class:`discord.Embed`
+ """
+
+ embed = Embed()
+
+ # if command or cog, add query to title for pages other than first
+ if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
+ title = f'Command Help | "{self.query.name}"'
+ else:
+ title = self.title
+
+ embed.set_author(name=title, icon_url=constants.Icons.questionmark)
+ embed.description = self._pages[page_number]
+
+ # add page counter to footer if paginating
+ page_count = len(self._pages)
+ if page_count > 1:
+ embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+
+ return embed
+
+ async def update_page(self, page_number=0):
+ """
+ Sends the intial message, or changes the existing one to the
+ given page number.
+
+ Parameters
+ ----------
+ page_number: int
+ The page number to show in the help message.
+ """
+
+ self._current_page = page_number
+ embed_page = self.embed_page(page_number)
+
+ if not self.message:
+ self.message = await self.destination.send(embed=embed_page)
+ else:
+ await self.message.edit(embed=embed_page)
+
+ @classmethod
+ async def start(cls, ctx, *command, **options):
+ """
+ Create and begin a help session based on the given command
+ context.
+
+ Parameters
+ ----------
+ ctx: :class:`discord.ext.commands.Context`
+ The context of the invoked help command.
+ *command: str
+ A variable argument of the command being queried.
+ cleanup: Optional[bool]
+ Set to ``True`` to have the message deleted on session end.
+ Defaults to ``False``.
+ only_can_run: Optional[bool]
+ Set to ``True`` to hide commands the user can't run.
+ Defaults to ``False``.
+ show_hidden: Optional[bool]
+ Set to ``True`` to include hidden commands.
+ Defaults to ``False``.
+ max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a
+ single page.
+ Defaults to 20.
+
+ Returns
+ -------
+ :class:`HelpSession`
+ """
+
+ session = cls(ctx, *command, **options)
+ await session.prepare()
+
+ return session
+
+ async def stop(self):
+ """
+ Stops the help session, removes event listeners and attempts to
+ delete the help message.
+ """
+
+ self._bot.remove_listener(self.on_reaction_add)
+ self._bot.remove_listener(self.on_message_delete)
+
+ # ignore if permission issue, or the message doesn't exist
+ with suppress(HTTPException, AttributeError):
+ if self._cleanup:
+ await self.message.delete()
+ else:
+ await self.message.clear_reactions()
+
+ @property
+ def is_first_page(self):
+ """
+ A bool reflecting if session is currently showing the first page.
+
+ Returns
+ -------
+ bool
+ """
+
+ return self._current_page == 0
+
+ @property
+ def is_last_page(self):
+ """
+ A bool reflecting if the session is currently showing the last page.
+
+ Returns
+ -------
+ bool
+ """
+
+ return self._current_page == (len(self._pages)-1)
+
+ async def do_first(self):
+ """
+ Event that is called when the user requests the first page.
+ """
+
+ if not self.is_first_page:
+ await self.update_page(0)
+
+ async def do_back(self):
+ """
+ Event that is called when the user requests the previous page.
+ """
+
+ if not self.is_first_page:
+ await self.update_page(self._current_page-1)
+
+ async def do_next(self):
+ """
+ Event that is called when the user requests the next page.
+ """
+
+ if not self.is_last_page:
+ await self.update_page(self._current_page+1)
+
+ async def do_end(self):
+ """
+ Event that is called when the user requests the last page.
+ """
+
+ if not self.is_last_page:
+ await self.update_page(len(self._pages)-1)
+
+ async def do_stop(self):
+ """
+ Event that is called when the user requests to stop the help session.
+ """
+
+ await self.message.delete()
+
+
+class Help:
+ """
+ Custom Embed Pagination Help feature
+ """
+ @commands.command('help')
+ async def new_help(self, ctx, *commands):
+ """
+ Shows Command Help.
+ """
+
+ try:
+ await HelpSession.start(ctx, *commands)
+ except HelpQueryNotFound as error:
+ embed = Embed()
+ embed.colour = Colour.red()
+ embed.title = str(error)
+
+ if error.possible_matches:
+ matches = '\n'.join(error.possible_matches.keys())
+ embed.description = f'**Did you mean:**\n`{matches}`'
+
+ await ctx.send(embed=embed)
+
+
+def unload(bot):
+ """
+ Reinstates the original help command.
+
+ This is run if the cog raises an exception on load, or if the
+ extension is unloaded.
+
+ Parameters
+ ----------
+ bot: :class:`discord.ext.commands.Bot`
+ The discord bot client.
+ """
+
+ bot.remove_command('help')
+ bot.add_command(bot._old_help)
+
+
+def setup(bot):
+ """
+ The setup for the help extension.
+
+ This is called automatically on `bot.load_extension` being run.
+
+ Stores the original help command instance on the ``bot._old_help``
+ attribute for later reinstatement, before removing it from the
+ command registry so the new help command can be loaded successfully.
+
+ If an exception is raised during the loading of the cog, ``unload``
+ will be called in order to reinstate the original help command.
+
+ Parameters
+ ----------
+ bot: `discord.ext.commands.Bot`
+ The discord bot client.
+ """
+
+ bot._old_help = bot.get_command('help')
+ bot.remove_command('help')
+
+ try:
+ bot.add_cog(Help())
+ except Exception:
+ unload(bot)
+ raise
+
+
+def teardown(bot):
+ """
+ The teardown for the help extension.
+
+ This is called automatically on `bot.unload_extension` being run.
+
+ Calls ``unload`` in order to reinstate the original help command.
+
+ Parameters
+ ----------
+ bot: `discord.ext.commands.Bot`
+ The discord bot client.
+ """
+
+ unload(bot)
diff --git a/bot/cogs/hiphopify.py b/bot/cogs/hiphopify.py
deleted file mode 100644
index 785aedca2..000000000
--- a/bot/cogs/hiphopify.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import logging
-import random
-
-from discord import Colour, Embed, Member
-from discord.errors import Forbidden
-from discord.ext.commands import Bot, Context, command
-
-from bot.constants import (
- Channels, Keys,
- NEGATIVE_REPLIES, POSITIVE_REPLIES,
- Roles, URLs
-)
-from bot.decorators import with_role
-
-
-log = logging.getLogger(__name__)
-
-
-class Hiphopify:
- """
- A set of commands to moderate terrible nicknames.
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
-
- async def on_member_update(self, before, after):
- """
- This event will trigger when someone changes their name.
- At this point we will look up the user in our database and check
- whether they are allowed to change their names, or if they are in
- hiphop-prison. If they are not allowed, we will change it back.
- :return:
- """
-
- if before.display_name == after.display_name:
- return # User didn't change their nickname. Abort!
-
- log.debug(
- f"{before.display_name} is trying to change their nickname to {after.display_name}. "
- "Checking if the user is in hiphop-prison..."
- )
-
- response = await self.bot.http_session.get(
- URLs.site_hiphopify_api,
- headers=self.headers,
- params={"user_id": str(before.id)}
- )
-
- response = await response.json()
-
- if response and response.get("end_timestamp") and not response.get("error_code"):
- if after.display_name == response.get("forced_nick"):
- return # Nick change was triggered by this event. Ignore.
-
- log.debug(
- f"{after.display_name} is currently in hiphop-prison. "
- f"Changing the nick back to {before.display_name}."
- )
- await after.edit(nick=response.get("forced_nick"))
- try:
- await after.send(
- "You have tried to change your nickname on the **Python Discord** server "
- f"from **{before.display_name}** to **{after.display_name}**, but as you "
- "are currently in hiphop-prison, you do not have permission to do so. "
- "You will be allowed to change your nickname again at the following time:\n\n"
- f"**{response.get('end_timestamp')}**."
- )
- except Forbidden:
- log.warning(
- "The user tried to change their nickname while in hiphop-prison. "
- "This led to the bot trying to DM the user to let them know they cannot do that, "
- "but the user had either blocked the bot or disabled DMs, so it was not possible "
- "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)
- 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.
-
- :param ctx: Discord message context
- :param ta:
- If provided, this function shows data for that specific tag.
- If not provided, this function shows the caller a list of all tags.
- """
-
- log.debug(
- f"Attempting to hiphopify {member.display_name} for {duration}. "
- f"forced_nick is set to {forced_nick}."
- )
-
- embed = Embed()
- embed.colour = Colour.blurple()
-
- params = {
- "user_id": str(member.id),
- "duration": duration
- }
-
- if forced_nick:
- params["forced_nick"] = forced_nick
-
- response = await self.bot.http_session.post(
- URLs.site_hiphopify_api,
- headers=self.headers,
- json=params
- )
-
- response = await response.json()
-
- if "error_message" in response:
- log.warning(
- "Encountered the following error when trying to hiphopify the user:\n"
- f"{response.get('error_message')}"
- )
- embed.colour = Colour.red()
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = response.get("error_message")
- return await ctx.send(embed=embed)
-
- else:
- forced_nick = response.get('forced_nick')
- end_time = response.get("end_timestamp")
- image_url = response.get("image_url")
-
- embed.title = "Congratulations!"
- embed.description = (
- f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. "
- f"Your new nickname will be **{forced_nick}**.\n\n"
- f"You will be unable to change your nickname until \n**{end_time}**.\n\n"
- "If you're confused by this, please read our "
- "[official nickname policy](https://pythondiscord.com/about/rules#nickname-policy)."
- )
- embed.set_image(url=image_url)
-
- # Log to the mod_log channel
- log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
- mod_log = self.bot.get_channel(Channels.modlog)
- await mod_log.send(
- f":middle_finger: {member.name}#{member.discriminator} (`{member.id}`) "
- f"has been hiphopified by **{ctx.author.name}**. Their new nickname is `{forced_nick}`. "
- f"They will not be able to change their nickname again until **{end_time}**"
- )
-
- # Change the nick and return the embed
- log.debug("Changing the users nickname and sending the embed.")
- 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)
- async def unhiphopify(self, ctx: Context, member: Member):
- """
- This command will remove the entry from our database, allowing the user
- to once again change their nickname.
-
- :param ctx: Discord message context
- :param member: The member to unhiphopify
- """
-
- log.debug(f"Attempting to unhiphopify the following user: {member.display_name}")
-
- embed = Embed()
- embed.colour = Colour.blurple()
-
- response = await self.bot.http_session.delete(
- URLs.site_hiphopify_api,
- headers=self.headers,
- json={"user_id": str(member.id)}
- )
-
- response = await response.json()
- embed.description = "User has been released from hiphop-prison."
- embed.title = random.choice(POSITIVE_REPLIES)
-
- if "error_message" in response:
- embed.colour = Colour.red()
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = response.get("error_message")
- log.warning(
- f"Error encountered when trying to unhiphopify {member.display_name}:\n"
- f"{response}"
- )
-
- log.debug(f"{member.display_name} was successfully released from hiphop-prison.")
- await ctx.send(embed=embed)
-
-
-def setup(bot):
- bot.add_cog(Hiphopify(bot))
- log.info("Cog loaded: Hiphopify")
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index a313d2379..7a244cdbe 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -10,6 +10,8 @@ from bot.utils.time import time_since
log = logging.getLogger(__name__)
+MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
+
class Information:
"""
@@ -22,7 +24,7 @@ class Information:
self.bot = bot
self.headers = {"X-API-Key": Keys.site_api}
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context):
"""
@@ -101,7 +103,7 @@ class Information:
Features: {features}
**Counts**
- Members: {member_count}
+ Members: {member_count:,}
Roles: {roles}
Text: {text_channels}
Voice: {voice_channels}
@@ -119,12 +121,16 @@ class Information:
await ctx.send(embed=embed)
+ @with_role(*MODERATION_ROLES)
@command(name="user", aliases=["user_info", "member", "member_info"])
- async def user_info(self, ctx: Context, user: Member = None):
+ async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False):
"""
Returns info about a user.
"""
+ # Validates hidden input
+ hidden = str(hidden)
+
if user is None:
user = ctx.author
@@ -146,6 +152,7 @@ class Information:
# Infractions
api_response = await self.bot.http_session.get(
url=URLs.site_infractions_user.format(user_id=user.id),
+ params={"hidden": hidden},
headers=self.headers
)
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index ee28a3600..ac08d3dd4 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -1,12 +1,15 @@
import asyncio
-import datetime
import logging
import textwrap
-from typing import Dict
+from typing import Union
from aiohttp import ClientError
-from discord import Colour, Embed, Guild, Member, Object, User
-from discord.ext.commands import Bot, Context, command, group
+from discord import (
+ Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User
+)
+from discord.ext.commands import (
+ BadArgument, BadUnionArgument, Bot, Context, command, group
+)
from bot import constants
from bot.cogs.modlog import ModLog
@@ -14,13 +17,33 @@ from bot.constants import Colours, Event, Icons, Keys, Roles, URLs
from bot.converters import InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
+from bot.utils.moderation import post_infraction
+from bot.utils.scheduling import Scheduler, create_task
+from bot.utils.time import parse_rfc1123, wait_until
log = logging.getLogger(__name__)
MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
+INFRACTION_ICONS = {
+ "Mute": Icons.user_mute,
+ "Kick": Icons.sign_out,
+ "Ban": Icons.user_ban
+}
+RULES_URL = "https://pythondiscord.com/about/rules"
-class Moderation:
+def proxy_user(user_id: str) -> Object:
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ raise BadArgument
+ user = Object(user_id)
+ user.mention = user.id
+ user.avatar_url_as = lambda static_format: None
+ return user
+
+
+class Moderation(Scheduler):
"""
Rowboat replacement moderation tools.
"""
@@ -28,8 +51,8 @@ class Moderation:
def __init__(self, bot: Bot):
self.bot = bot
self.headers = {"X-API-KEY": Keys.site_api}
- self.expiration_tasks: Dict[str, asyncio.Task] = {}
self._muted_role = Object(constants.Roles.muted)
+ super().__init__()
@property
def mod_log(self) -> ModLog:
@@ -46,86 +69,85 @@ class Moderation:
loop = asyncio.get_event_loop()
for infraction_object in infraction_list:
if infraction_object["expires_at"] is not None:
- self.schedule_expiration(loop, infraction_object)
+ self.schedule_task(loop, infraction_object["id"], infraction_object)
# region: Permanent infractions
@with_role(*MODERATION_ROLES)
@command(name="warn")
- async def warn(self, ctx: Context, user: User, *, reason: str = None):
+ async def warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
"""
Create a warning infraction in the database for a user.
:param user: accepts user mention, ID, etc.
:param reason: The reason for the warning.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "warning",
- "reason": reason,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Warning",
+ reason=reason
+ )
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ response_object = await post_infraction(ctx, user, type="warning", reason=reason)
+ if response_object is None:
return
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: warned {user.mention}"
+
if reason is None:
- result_message = f":ok_hand: warned {user.mention}."
+ await ctx.send(f"{action}.")
else:
- result_message = f":ok_hand: warned {user.mention} ({reason})."
+ await ctx.send(f"{action} ({reason}).")
- await ctx.send(result_message)
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "warning")
+
+ # Send a message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_warn,
+ colour=Colour(Colours.soft_red),
+ title="Member warned",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ """)
+ )
@with_role(*MODERATION_ROLES)
@command(name="kick")
- async def kick(self, ctx, user: Member, *, reason: str = None):
+ async def kick(self, ctx: Context, user: Member, *, reason: str = None):
"""
Kicks a user.
:param user: accepts user mention, ID, etc.
:param reason: The reason for the kick.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "kick",
- "reason": reason,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Kick",
+ reason=reason
+ )
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ response_object = await post_infraction(ctx, user, type="kick", reason=reason)
+ if response_object is None:
return
self.mod_log.ignore(Event.member_remove, user.id)
await user.kick(reason=reason)
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: kicked {user.mention}"
+
if reason is None:
- result_message = f":ok_hand: kicked {user.mention}."
+ await ctx.send(f"{action}.")
else:
- result_message = f":ok_hand: kicked {user.mention} ({reason})."
+ await ctx.send(f"{action} ({reason}).")
- await ctx.send(result_message)
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "kick")
# Send a log message to the mod log
await self.mod_log.send_log_message(
@@ -142,44 +164,38 @@ class Moderation:
@with_role(*MODERATION_ROLES)
@command(name="ban")
- async def ban(self, ctx: Context, user: User, *, reason: str = None):
+ async def ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
"""
Create a permanent ban infraction in the database for a user.
:param user: Accepts user mention, ID, etc.
:param reason: The reason for the ban.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "ban",
- "reason": reason,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Ban",
+ duration="Permanent",
+ reason=reason
+ )
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ response_object = await post_infraction(ctx, user, type="ban", reason=reason)
+ if response_object is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
self.mod_log.ignore(Event.member_remove, user.id)
await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: permanently banned {user.mention}"
+
if reason is None:
- result_message = f":ok_hand: permanently banned {user.mention}."
+ await ctx.send(f"{action}.")
else:
- result_message = f":ok_hand: permanently banned {user.mention} ({reason})."
+ await ctx.send(f"{action} ({reason}).")
- await ctx.send(result_message)
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "ban")
# Send a log message to the mod log
await self.mod_log.send_log_message(
@@ -203,37 +219,31 @@ class Moderation:
:param reason: The reason for the mute.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "mute",
- "reason": reason,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Mute",
+ duration="Permanent",
+ reason=reason
+ )
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ response_object = await post_infraction(ctx, user, type="mute", reason=reason)
+ if response_object is None:
return
# add the mute role
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: permanently muted {user.mention}"
+
if reason is None:
- result_message = f":ok_hand: permanently muted {user.mention}."
+ await ctx.send(f"{action}.")
else:
- result_message = f":ok_hand: permanently muted {user.mention} ({reason})."
+ await ctx.send(f"{action} ({reason}).")
- await ctx.send(result_message)
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "mute")
# Send a log message to the mod log
await self.mod_log.send_log_message(
@@ -261,26 +271,271 @@ class Moderation:
:param reason: The reason for the temporary mute.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "mute",
- "reason": reason,
- "duration": duration,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Mute",
+ duration=duration,
+ reason=reason
+ )
+
+ response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration)
+ if response_object is None:
+ return
+
+ self.mod_log.ignore(Event.member_update, user.id)
+ await user.add_roles(self._muted_role, reason=reason)
+
+ infraction_object = response_object["infraction"]
+ infraction_expiration = infraction_object["expires_at"]
+
+ loop = asyncio.get_event_loop()
+ self.schedule_task(loop, infraction_object["id"], infraction_object)
+
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}"
+
+ if reason is None:
+ await ctx.send(f"{action}.")
+ else:
+ await ctx.send(f"{action} ({reason}).")
+
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "mute")
+
+ # Send a log message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_mute,
+ colour=Colour(Colours.soft_red),
+ title="Member temporarily muted",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ Duration: {duration}
+ Expires: {infraction_expiration}
+ """)
+ )
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="tempban")
+ async def tempban(self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None):
+ """
+ Create a temporary ban infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param duration: The duration for the temporary ban infraction
+ :param reason: The reason for the temporary ban.
+ """
+
+ notified = await self.notify_infraction(
+ user=user,
+ infr_type="Ban",
+ duration=duration,
+ reason=reason
+ )
+
+ response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration)
+ if response_object is None:
+ return
+
+ self.mod_log.ignore(Event.member_ban, user.id)
+ self.mod_log.ignore(Event.member_remove, user.id)
+ guild: Guild = ctx.guild
+ await guild.ban(user, reason=reason, delete_message_days=0)
+
+ infraction_object = response_object["infraction"]
+ infraction_expiration = infraction_object["expires_at"]
+
+ loop = asyncio.get_event_loop()
+ self.schedule_task(loop, infraction_object["id"], infraction_object)
+
+ dm_result = ":incoming_envelope: " if notified else ""
+ action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}"
+
+ if reason is None:
+ await ctx.send(f"{action}.")
+ else:
+ await ctx.send(f"{action} ({reason}).")
+
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "ban")
+
+ # Send a log message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_ban,
+ colour=Colour(Colours.soft_red),
+ thumbnail=user.avatar_url_as(static_format="png"),
+ title="Member temporarily banned",
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ Duration: {duration}
+ Expires: {infraction_expiration}
+ """)
+ )
+
+ # endregion
+ # region: Permanent shadow infractions
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note'])
+ async def shadow_warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ """
+ Create a warning infraction in the database for a user.
+ :param user: accepts user mention, ID, etc.
+ :param reason: The reason for the warning.
+ """
+
+ response_object = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+ if response_object is None:
+ return
+
+ if reason is None:
+ result_message = f":ok_hand: note added for {user.mention}."
+ else:
+ result_message = f":ok_hand: note added for {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ # Send a message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_warn,
+ colour=Colour(Colours.soft_red),
+ title="Member shadow warned",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ """)
+ )
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick'])
+ async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None):
+ """
+ Kicks a user.
+ :param user: accepts user mention, ID, etc.
+ :param reason: The reason for the kick.
+ """
+
+ response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
+ if response_object is None:
+ return
+
+ self.mod_log.ignore(Event.member_remove, user.id)
+ await user.kick(reason=reason)
+
+ if reason is None:
+ result_message = f":ok_hand: kicked {user.mention}."
+ else:
+ result_message = f":ok_hand: kicked {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ # Send a log message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.sign_out,
+ colour=Colour(Colours.soft_red),
+ title="Member shadow kicked",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ """)
+ )
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban'])
+ async def shadow_ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
+ """
+ Create a permanent ban infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param reason: The reason for the ban.
+ """
+
+ response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
+ if response_object is None:
return
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ self.mod_log.ignore(Event.member_ban, user.id)
+ self.mod_log.ignore(Event.member_remove, user.id)
+ await ctx.guild.ban(user, reason=reason, delete_message_days=0)
+
+ if reason is None:
+ result_message = f":ok_hand: permanently banned {user.mention}."
+ else:
+ result_message = f":ok_hand: permanently banned {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ # Send a log message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_ban,
+ colour=Colour(Colours.soft_red),
+ title="Member permanently banned",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ """)
+ )
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="shadow_mute", hidden=True, aliases=['shadowmute', 'smute'])
+ async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None):
+ """
+ Create a permanent mute infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param reason: The reason for the mute.
+ """
+
+ response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
+ if response_object is None:
+ return
+
+ # add the mute role
+ self.mod_log.ignore(Event.member_update, user.id)
+ await user.add_roles(self._muted_role, reason=reason)
+
+ if reason is None:
+ result_message = f":ok_hand: permanently muted {user.mention}."
+ else:
+ result_message = f":ok_hand: permanently muted {user.mention} ({reason})."
+
+ await ctx.send(result_message)
+
+ # Send a log message to the mod log
+ await self.mod_log.send_log_message(
+ icon_url=Icons.user_mute,
+ colour=Colour(Colours.soft_red),
+ title="Member permanently muted",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ """)
+ )
+
+ # endregion
+ # region: Temporary shadow infractions
+
+ @with_role(*MODERATION_ROLES)
+ @command(name="shadow_tempmute", hidden=True, aliases=["shadowtempmute, stempmute"])
+ async def shadow_tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None):
+ """
+ Create a temporary mute infraction in the database for a user.
+ :param user: Accepts user mention, ID, etc.
+ :param duration: The duration for the temporary mute infraction
+ :param reason: The reason for the temporary mute.
+ """
+
+ response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration, hidden=True)
+ if response_object is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -315,8 +570,10 @@ class Moderation:
)
@with_role(*MODERATION_ROLES)
- @command(name="tempban")
- async def tempban(self, ctx, user: User, duration: str, *, reason: str = None):
+ @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"])
+ async def shadow_tempban(
+ self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None
+ ):
"""
Create a temporary ban infraction in the database for a user.
:param user: Accepts user mention, ID, etc.
@@ -324,26 +581,8 @@ class Moderation:
:param reason: The reason for the temporary ban.
"""
- try:
- response = await self.bot.http_session.post(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "type": "ban",
- "reason": reason,
- "duration": duration,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id)
- }
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
-
- response_object = await response.json()
- if "error_code" in response_object:
- await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration, hidden=True)
+ if response_object is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
@@ -384,7 +623,7 @@ class Moderation:
@with_role(*MODERATION_ROLES)
@command(name="unmute")
- async def unmute(self, ctx, user: Member):
+ async def unmute(self, ctx: Context, user: Member):
"""
Deactivates the active mute infraction for a user.
:param user: Accepts user mention, ID, etc.
@@ -414,7 +653,18 @@ class Moderation:
if infraction_object["expires_at"] is not None:
self.cancel_expiration(infraction_object["id"])
- await ctx.send(f":ok_hand: Un-muted {user.mention}.")
+ notified = await self.notify_pardon(
+ user=user,
+ title="You have been unmuted.",
+ content="You may now send messages in the server.",
+ icon_url=Icons.user_unmute
+ )
+
+ dm_result = ":incoming_envelope: " if notified else ""
+ await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.")
+
+ if not notified:
+ await self.log_notify_failure(user, ctx.author, "unmute")
# Send a log message to the mod log
await self.mod_log.send_log_message(
@@ -435,7 +685,7 @@ class Moderation:
@with_role(*MODERATION_ROLES)
@command(name="unban")
- async def unban(self, ctx, user: User):
+ async def unban(self, ctx: Context, user: Union[User, proxy_user]):
"""
Deactivates the active ban infraction for a user.
:param user: Accepts user mention, ID, etc.
@@ -488,18 +738,22 @@ class Moderation:
# region: Edit infraction commands
@with_role(*MODERATION_ROLES)
- @group(name='infraction', aliases=('infr',))
+ @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
async def infraction_group(self, ctx: Context):
"""Infraction manipulation commands."""
+ await ctx.invoke(self.bot.get_command("help"), "infraction")
+
@with_role(*MODERATION_ROLES)
- @infraction_group.group(name='edit')
+ @infraction_group.group(name='edit', invoke_without_command=True)
async def infraction_edit_group(self, ctx: Context):
"""Infraction editing commands."""
+ await ctx.invoke(self.bot.get_command("help"), "infraction", "edit")
+
@with_role(*MODERATION_ROLES)
@infraction_edit_group.command(name="duration")
- async def edit_duration(self, ctx, infraction_id: str, duration: str):
+ async def edit_duration(self, ctx: Context, infraction_id: str, duration: str):
"""
Sets the duration of the given infraction, relative to the time of updating.
:param infraction_id: the id (UUID) of the infraction
@@ -535,9 +789,9 @@ class Moderation:
infraction_object = response_object["infraction"]
# Re-schedule
- self.cancel_expiration(infraction_id)
+ self.cancel_task(infraction_id)
loop = asyncio.get_event_loop()
- self.schedule_expiration(loop, infraction_object)
+ self.schedule_task(loop, infraction_object["id"], infraction_object)
if duration is None:
await ctx.send(f":ok_hand: Updated infraction: marked as permanent.")
@@ -582,7 +836,7 @@ class Moderation:
@with_role(*MODERATION_ROLES)
@infraction_edit_group.command(name="reason")
- async def edit_reason(self, ctx, infraction_id: str, *, reason: str):
+ async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str):
"""
Sets the reason of the given infraction.
:param infraction_id: the id (UUID) of the infraction
@@ -654,70 +908,89 @@ class Moderation:
# region: Search infractions
@with_role(*MODERATION_ROLES)
- @infraction_group.command(name="search")
- async def search(self, ctx, arg: InfractionSearchQuery):
+ @infraction_group.group(name="search", invoke_without_command=True)
+ async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery):
"""
Searches for infractions in the database.
- :param arg: Either a user or a reason string. If a string, you can use the Re2 matching syntax.
- """
-
- if isinstance(arg, User):
- user: User = arg
- # get infractions for this user
- try:
- response = await self.bot.http_session.get(
- URLs.site_infractions_user.format(
- user_id=user.id
- ),
- headers=self.headers
- )
- infraction_list = await response.json()
- except ClientError:
- log.exception("There was an error fetching infractions.")
- await ctx.send(":x: There was an error fetching infraction.")
- return
+ """
- if not infraction_list:
- await ctx.send(f":warning: No infractions found for {user}.")
- return
+ if isinstance(query, User):
+ await ctx.invoke(self.search_user, query)
+
+ else:
+ await ctx.invoke(self.search_reason, query)
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_search_group.command(name="user", aliases=("member", "id"))
+ async def search_user(self, ctx: Context, user: Union[User, proxy_user]):
+ """
+ Search for infractions by member.
+ """
- embed = Embed(
- title=f"Infractions for {user} ({len(infraction_list)} total)",
- colour=Colour.orange()
+ try:
+ response = await self.bot.http_session.get(
+ URLs.site_infractions_user.format(
+ user_id=user.id
+ ),
+ params={"hidden": "True"},
+ headers=self.headers
)
+ infraction_list = await response.json()
+ except ClientError:
+ log.exception(f"Failed to fetch infractions for user {user} ({user.id}).")
+ await ctx.send(":x: An error occurred while fetching infractions.")
+ return
- elif isinstance(arg, str):
- # search by reason
- try:
- response = await self.bot.http_session.get(
- URLs.site_infractions,
- headers=self.headers,
- params={"search": arg}
- )
- infraction_list = await response.json()
- except ClientError:
- log.exception("There was an error fetching infractions.")
- await ctx.send(":x: There was an error fetching infraction.")
- return
+ embed = Embed(
+ title=f"Infractions for {user} ({len(infraction_list)} total)",
+ colour=Colour.orange()
+ )
- if not infraction_list:
- await ctx.send(f":warning: No infractions matching `{arg}`.")
- return
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ @with_role(*MODERATION_ROLES)
+ @infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
+ async def search_reason(self, ctx: Context, reason: str):
+ """
+ Search for infractions by their reason. Use Re2 for matching.
+ """
- embed = Embed(
- title=f"Infractions matching `{arg}` ({len(infraction_list)} total)",
- colour=Colour.orange()
+ try:
+ response = await self.bot.http_session.get(
+ URLs.site_infractions,
+ params={"search": reason, "hidden": "True"},
+ headers=self.headers
)
+ infraction_list = await response.json()
+ except ClientError:
+ log.exception(f"Failed to fetch infractions matching reason `{reason}`.")
+ await ctx.send(":x: An error occurred while fetching infractions.")
+ return
- else:
- await ctx.send(":x: Invalid infraction search query.")
+ embed = Embed(
+ title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
+ colour=Colour.orange()
+ )
+
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ # endregion
+ # region: Utility functions
+
+ async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list):
+
+ if not infractions:
+ await ctx.send(f":warning: No infractions could be found for that query.")
return
+ lines = []
+ for infraction in infractions:
+ lines.append(
+ self._infraction_to_string(infraction)
+ )
+
await LinePaginator.paginate(
- lines=(
- self._infraction_to_string(infraction_object, show_user=isinstance(arg, str))
- for infraction_object in infraction_list
- ),
+ lines,
ctx=ctx,
embed=embed,
empty=True,
@@ -736,15 +1009,12 @@ class Moderation:
"""
infraction_id = infraction_object["id"]
- if infraction_id in self.expiration_tasks:
+ if infraction_id in self.scheduled_tasks:
return
- task: asyncio.Task = asyncio.ensure_future(self._scheduled_expiration(infraction_object), loop=loop)
-
- # Silently ignore exceptions in a callback (handles the CancelledError nonsense)
- task.add_done_callback(_silent_exception)
+ task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object))
- self.expiration_tasks[infraction_id] = task
+ self.scheduled_tasks[infraction_id] = task
def cancel_expiration(self, infraction_id: str):
"""
@@ -752,15 +1022,15 @@ class Moderation:
:param infraction_id: the ID of the infraction in question
"""
- task = self.expiration_tasks.get(infraction_id)
+ task = self.scheduled_tasks.get(infraction_id)
if task is None:
log.warning(f"Failed to unschedule {infraction_id}: no task found.")
return
task.cancel()
log.debug(f"Unscheduled {infraction_id}.")
- del self.expiration_tasks[infraction_id]
+ del self.scheduled_tasks[infraction_id]
- async def _scheduled_expiration(self, infraction_object):
+ async def _scheduled_task(self, infraction_object: dict):
"""
A co-routine which marks an infraction as expired after the delay from the time of scheduling
to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website,
@@ -772,17 +1042,22 @@ class Moderation:
# transform expiration to delay in seconds
expiration_datetime = parse_rfc1123(infraction_object["expires_at"])
- delay = expiration_datetime - datetime.datetime.now(tz=datetime.timezone.utc)
- delay_seconds = delay.total_seconds()
-
- if delay_seconds > 1.0:
- log.debug(f"Scheduling expiration for infraction {infraction_id} in {delay_seconds} seconds")
- await asyncio.sleep(delay_seconds)
+ await wait_until(expiration_datetime)
log.debug(f"Marking infraction {infraction_id} as inactive (expired).")
await self._deactivate_infraction(infraction_object)
- self.cancel_expiration(infraction_object["id"])
+ self.cancel_task(infraction_object["id"])
+
+ # Notify the user that they've been unmuted.
+ user_id = int(infraction_object["user"]["user_id"])
+ guild = self.bot.get_guild(constants.Guild.id)
+ await self.notify_pardon(
+ user=guild.get_member(user_id),
+ title="You have been unmuted.",
+ content="You may now send messages in the server.",
+ icon_url=Icons.user_unmute
+ )
async def _deactivate_infraction(self, infraction_object):
"""
@@ -790,6 +1065,7 @@ class Moderation:
un-schedule an expiration task.
:param infraction_object: the infraction in question
"""
+
guild: Guild = self.bot.get_guild(constants.Guild.id)
user_id = int(infraction_object["user"]["user_id"])
infraction_type = infraction_object["type"]
@@ -803,7 +1079,7 @@ class Moderation:
else:
log.warning(f"Failed to un-mute user: {user_id} (not found)")
elif infraction_type == "ban":
- user: User = self.bot.get_user(user_id)
+ user: Object = Object(user_id)
await guild.unban(user)
await self.bot.http_session.patch(
@@ -815,46 +1091,121 @@ class Moderation:
}
)
- def _infraction_to_string(self, infraction_object, show_user=False):
+ def _infraction_to_string(self, infraction_object):
actor_id = int(infraction_object["actor"]["user_id"])
guild: Guild = self.bot.get_guild(constants.Guild.id)
actor = guild.get_member(actor_id)
active = infraction_object["active"] is True
+ user_id = int(infraction_object["user"]["user_id"])
+ hidden = infraction_object.get("hidden", False) is True
+
+ lines = textwrap.dedent(f"""
+ {"**===============**" if active else "==============="}
+ Status: {"__**Active**__" if active else "Inactive"}
+ User: {self.bot.get_user(user_id)} (`{user_id}`)
+ Type: **{infraction_object["type"]}**
+ Shadow: {hidden}
+ Reason: {infraction_object["reason"] or "*None*"}
+ Created: {infraction_object["inserted_at"]}
+ Expires: {infraction_object["expires_at"] or "*Permanent*"}
+ Actor: {actor.mention if actor else actor_id}
+ ID: `{infraction_object["id"]}`
+ {"**===============**" if active else "==============="}
+ """)
+
+ return lines.strip()
+
+ async def notify_infraction(
+ self, user: Union[User, Member], infr_type: str, duration: str = None, reason: str = None
+ ):
+ """
+ Notify a user of their fresh infraction :)
- lines = [
- "**===============**" if active else "===============",
- "Status: {0}".format("__**Active**__" if active else "Inactive"),
- "Type: **{0}**".format(infraction_object["type"]),
- "Reason: {0}".format(infraction_object["reason"] or "*None*"),
- "Created: {0}".format(infraction_object["inserted_at"]),
- "Expires: {0}".format(infraction_object["expires_at"] or "*Permanent*"),
- "Actor: {0}".format(actor.mention if actor else actor_id),
- "ID: `{0}`".format(infraction_object["id"]),
- "**===============**" if active else "==============="
- ]
-
- if show_user:
- user_id = int(infraction_object["user"]["user_id"])
- user = self.bot.get_user(user_id)
- lines.insert(1, "User: {0}".format(user.mention if user else user_id))
-
- return "\n".join(lines)
+ :param user: The user to send the message to.
+ :param infr_type: The type of infraction, as a string.
+ :param duration: The duration of the infraction.
+ :param reason: The reason for the infraction.
+ """
- # endregion
+ if duration is None:
+ duration = "N/A"
+ if reason is None:
+ reason = "No reason provided."
+
+ embed = Embed(
+ description=textwrap.dedent(f"""
+ **Type:** {infr_type}
+ **Duration:** {duration}
+ **Reason:** {reason}
+ """),
+ colour=Colour(Colours.soft_red)
+ )
-RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+ icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed)
+ embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL)
+ embed.title = f"Please review our rules over at {RULES_URL}"
+ embed.url = RULES_URL
+ return await self.send_private_embed(user, embed)
-def parse_rfc1123(time_str):
- return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+ async def notify_pardon(
+ self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified
+ ):
+ """
+ Notify a user that an infraction has been lifted.
+ :param user: The user to send the message to.
+ :param title: The title of the embed.
+ :param content: The content of the embed.
+ :param icon_url: URL for the title icon.
+ """
-def _silent_exception(future):
- try:
- future.exception()
- except Exception: # noqa: S110
- pass
+ embed = Embed(
+ description=content,
+ colour=Colour(Colours.soft_green)
+ )
+
+ embed.set_author(name=title, icon_url=icon_url)
+
+ return await self.send_private_embed(user, embed)
+
+ async def send_private_embed(self, user: Union[User, Member], embed: Embed):
+ """
+ A helper method for sending an embed to a user's DMs.
+
+ :param user: The user to send the embed to.
+ :param embed: The embed to send.
+ """
+
+ # sometimes `user` is a `discord.Object`, so let's make it a proper user.
+ user = await self.bot.get_user_info(user.id)
+
+ try:
+ await user.send(embed=embed)
+ return True
+ except (HTTPException, Forbidden):
+ log.debug(
+ f"Infraction-related information could not be sent to user {user} ({user.id}). "
+ "They've probably just disabled private messages."
+ )
+ return False
+
+ async def log_notify_failure(self, target: str, actor: Member, infraction_type: str):
+ await self.mod_log.send_log_message(
+ icon_url=Icons.token_removed,
+ content=actor.mention,
+ colour=Colour(Colours.soft_red),
+ title="Notification Failed",
+ text=f"Direct message was unable to be sent.\nUser: {target.mention}\nType: {infraction_type}"
+ )
+
+ # endregion
+
+ async def __error(self, ctx, error):
+ if isinstance(error, BadUnionArgument):
+ if User in error.converters:
+ await ctx.send(str(error.errors[0]))
def setup(bot):
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index 9c81661ba..905f114c1 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -10,12 +10,16 @@ from discord import (
CategoryChannel, Colour, Embed, File, Guild,
Member, Message, NotFound, RawBulkMessageDeleteEvent,
RawMessageDeleteEvent, RawMessageUpdateEvent, Role,
- TextChannel, User, VoiceChannel)
+ TextChannel, User, VoiceChannel
+)
from discord.abc import GuildChannel
from discord.ext.commands import Bot
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles, URLs
-from bot.constants import Guild as GuildConstant
+from bot.constants import (
+ Channels, Colours, Emojis,
+ Event, Guild as GuildConstant, Icons,
+ Keys, Roles, URLs
+)
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -100,8 +104,9 @@ class ModLog:
self._ignored[event].append(item)
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
+ 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, content: str = None
):
embed = Embed(description=text)
@@ -114,10 +119,11 @@ class ModLog:
if thumbnail is not None:
embed.set_thumbnail(url=thumbnail)
- content = None
-
if ping_everyone:
- content = "@everyone"
+ if content:
+ content = f"@everyone\n{content}"
+ else:
+ content = "@everyone"
await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files)
@@ -609,7 +615,12 @@ class ModLog:
)
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:
+ if (
+ not before.guild
+ or before.guild.id != GuildConstant.id
+ or before.channel.id in GuildConstant.ignored
+ or before.author.bot
+ ):
return
self._cached_edits.append(before.id)
@@ -670,7 +681,12 @@ class ModLog:
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:
+ if (
+ not message.guild
+ or 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
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index fd670b4c6..b22926664 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -84,11 +84,13 @@ class OffTopicNames:
coro = update_names(self.bot, self.headers)
self.updater_task = await self.bot.loop.create_task(coro)
- @group(name='otname', aliases=('otnames', 'otn'))
+ @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
@with_role(Roles.owner, Roles.admin, Roles.moderator)
async def otname_group(self, ctx):
"""Add or list items from the off-topic channel name rotation."""
+ await ctx.invoke(self.bot.get_command("help"), "otname")
+
@otname_group.command(name='add', aliases=('a',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
async def add_command(self, ctx, name: OffTopicName):
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
new file mode 100644
index 000000000..f6ed111dc
--- /dev/null
+++ b/bot/cogs/reminders.py
@@ -0,0 +1,406 @@
+import asyncio
+import datetime
+import logging
+import random
+import textwrap
+
+from aiohttp import ClientResponseError
+from dateutil.relativedelta import relativedelta
+from discord import Colour, Embed
+from discord.ext.commands import Bot, Context, group
+
+from bot.constants import (
+ Channels, Icons, Keys, NEGATIVE_REPLIES,
+ POSITIVE_REPLIES, Roles, URLs
+)
+from bot.pagination import LinePaginator
+from bot.utils.scheduling import Scheduler
+from bot.utils.time import humanize_delta, parse_rfc1123, wait_until
+
+log = logging.getLogger(__name__)
+
+STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+WHITELISTED_CHANNELS = (Channels.bot,)
+MAXIMUM_REMINDERS = 5
+
+
+class Reminders(Scheduler):
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.headers = {"X-API-Key": Keys.site_api}
+ super().__init__()
+
+ async def on_ready(self):
+ # Get all the current reminders for re-scheduling
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_api,
+ headers=self.headers
+ )
+
+ response_data = await response.json()
+
+ # Find the current time, timezone-aware.
+ now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+ loop = asyncio.get_event_loop()
+
+ for reminder in response_data["reminders"]:
+ remind_at = parse_rfc1123(reminder["remind_at"])
+
+ # If the reminder is already overdue ...
+ if remind_at < now:
+ late = relativedelta(now, remind_at)
+ await self.send_reminder(reminder, late)
+
+ else:
+ self.schedule_task(loop, reminder["id"], reminder)
+
+ @staticmethod
+ async def _send_confirmation(ctx: Context, response: dict, on_success: str):
+ """
+ Send an embed confirming whether or not a change was made successfully.
+
+ :return: A Boolean value indicating whether it failed (True) or passed (False)
+ """
+
+ embed = Embed()
+
+ if not response.get("success"):
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = response.get("error_message", "An unexpected error occurred.")
+
+ log.warn(f"Unable to create/edit/delete a reminder. Response: {response}")
+ failed = True
+
+ else:
+ embed.colour = Colour.green()
+ embed.title = random.choice(POSITIVE_REPLIES)
+ embed.description = on_success
+
+ failed = False
+
+ await ctx.send(embed=embed)
+ return failed
+
+ async def _scheduled_task(self, reminder: dict):
+ """
+ A coroutine which sends the reminder once the time is reached.
+
+ :param reminder: the data of the reminder.
+ :return:
+ """
+
+ reminder_id = reminder["id"]
+ reminder_datetime = parse_rfc1123(reminder["remind_at"])
+
+ # Send the reminder message once the desired duration has passed
+ await wait_until(reminder_datetime)
+ await self.send_reminder(reminder)
+
+ log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).")
+ await self._delete_reminder(reminder_id)
+
+ # Now we can begone with it from our schedule list.
+ self.cancel_task(reminder_id)
+
+ async def _delete_reminder(self, reminder_id: str):
+ """
+ Delete a reminder from the database, given its ID.
+
+ :param reminder_id: The ID of the reminder.
+ """
+
+ # The API requires a list, so let's give it one :)
+ json_data = {
+ "reminders": [
+ reminder_id
+ ]
+ }
+
+ await self.bot.http_session.delete(
+ url=URLs.site_reminders_api,
+ headers=self.headers,
+ json=json_data
+ )
+
+ # Now we can remove it from the schedule list
+ self.cancel_task(reminder_id)
+
+ async def _reschedule_reminder(self, reminder):
+ """
+ Reschedule a reminder object.
+
+ :param reminder: The reminder to be rescheduled.
+ """
+
+ loop = asyncio.get_event_loop()
+
+ self.cancel_task(reminder["id"])
+ self.schedule_task(loop, reminder["id"], reminder)
+
+ async def send_reminder(self, reminder, late: relativedelta = None):
+ """
+ Send the reminder.
+
+ :param reminder: The data about the reminder.
+ :param late: How late the reminder is (if at all)
+ """
+
+ channel = self.bot.get_channel(int(reminder["channel_id"]))
+ user = self.bot.get_user(int(reminder["user_id"]))
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.set_author(
+ icon_url=Icons.remind_blurple,
+ name="It has arrived!"
+ )
+
+ embed.description = f"Here's your reminder: `{reminder['content']}`"
+
+ if late:
+ embed.colour = Colour.red()
+ embed.set_author(
+ icon_url=Icons.remind_red,
+ name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
+ )
+
+ await channel.send(
+ content=user.mention,
+ embed=embed
+ )
+ await self._delete_reminder(reminder["id"])
+
+ @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
+ async def remind_group(self, ctx: Context, duration: str, *, content: str):
+ """
+ Commands for managing your reminders.
+ """
+
+ await ctx.invoke(self.new_reminder, duration=duration, content=content)
+
+ @remind_group.command(name="new", aliases=("add", "create"))
+ async def new_reminder(self, ctx: Context, duration: str, *, content: str):
+ """
+ Set yourself a simple reminder.
+ """
+
+ embed = Embed()
+
+ # Make sure the reminder should actually be made.
+ if ctx.author.top_role.id not in STAFF_ROLES:
+
+ # If they don't have permission to set a reminder in this channel
+ if ctx.channel.id not in WHITELISTED_CHANNELS:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = "Sorry, you can't do that here!"
+
+ return await ctx.send(embed=embed)
+
+ # Get their current active reminders
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_user_api.format(user_id=ctx.author.id),
+ headers=self.headers
+ )
+
+ active_reminders = await response.json()
+
+ # Let's limit this, so we don't get 10 000
+ # reminders from kip or something like that :P
+ if len(active_reminders) > MAXIMUM_REMINDERS:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = "You have too many active reminders!"
+
+ return await ctx.send(embed=embed)
+
+ # Now we can attempt to actually set the reminder.
+ try:
+ response = await self.bot.http_session.post(
+ url=URLs.site_reminders_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "duration": duration,
+ "content": content,
+ "channel_id": str(ctx.channel.id)
+ }
+ )
+
+ response_data = await response.json()
+
+ # AFAIK only happens if the user enters, like, a quintillion weeks
+ except ClientResponseError:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = (
+ "An error occurred while adding your reminder to the database. "
+ "Did you enter a reasonable duration?"
+ )
+
+ log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.")
+
+ return await ctx.send(embed=embed)
+
+ # Confirm to the user whether or not it worked.
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="Your reminder has been created successfully!"
+ )
+
+ # If it worked, schedule the reminder.
+ if not failed:
+ loop = asyncio.get_event_loop()
+ reminder = response_data["reminder"]
+
+ self.schedule_task(loop, reminder["id"], reminder)
+
+ @remind_group.command(name="list")
+ async def list_reminders(self, ctx: Context):
+ """
+ View a paginated embed of all reminders for your user.
+ """
+
+ # Get all the user's reminders from the database.
+ response = await self.bot.http_session.get(
+ url=URLs.site_reminders_user_api,
+ params={"user_id": str(ctx.author.id)},
+ headers=self.headers
+ )
+
+ data = await response.json()
+ now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+
+ # Make a list of tuples so it can be sorted by time.
+ reminders = [
+ (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"]
+ ]
+
+ reminders.sort(key=lambda rem: rem[1])
+
+ lines = []
+
+ for index, (content, remind_at, friendly_id) in enumerate(reminders):
+ # Parse and humanize the time, make it pretty :D
+ remind_datetime = parse_rfc1123(remind_at)
+ time = humanize_delta(relativedelta(remind_datetime, now))
+
+ text = textwrap.dedent(f"""
+ **Reminder #{index}:** *expires in {time}* (ID: {friendly_id})
+ {content}
+ """).strip()
+
+ lines.append(text)
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.title = f"Reminders for {ctx.author}"
+
+ # Remind the user that they have no reminders :^)
+ if not lines:
+ embed.description = "No active reminders could be found."
+ return await ctx.send(embed=embed)
+
+ # Construct the embed and paginate it.
+ embed.colour = Colour.blurple()
+
+ await LinePaginator.paginate(
+ lines,
+ ctx, embed,
+ max_lines=3,
+ empty=True
+ )
+
+ @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
+ async def edit_reminder_group(self, ctx: Context):
+ """
+ Commands for modifying your current reminders.
+ """
+
+ await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
+
+ @edit_reminder_group.command(name="duration", aliases=("time",))
+ async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str):
+ """
+ Edit one of your reminders' duration.
+ """
+
+ # Send the request to update the reminder in the database
+ response = await self.bot.http_session.patch(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id,
+ "duration": duration
+ }
+ )
+
+ # Send a confirmation message to the channel
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been edited successfully!"
+ )
+
+ if not failed:
+ await self._reschedule_reminder(response_data["reminder"])
+
+ @edit_reminder_group.command(name="content", aliases=("reason",))
+ async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str):
+ """
+ Edit one of your reminders' content.
+ """
+
+ # Send the request to update the reminder in the database
+ response = await self.bot.http_session.patch(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id,
+ "content": content
+ }
+ )
+
+ # Send a confirmation message to the channel
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been edited successfully!"
+ )
+
+ if not failed:
+ await self._reschedule_reminder(response_data["reminder"])
+
+ @remind_group.command("delete", aliases=("remove",))
+ async def delete_reminder(self, ctx: Context, friendly_id: str):
+ """
+ Delete one of your active reminders.
+ """
+
+ # Send the request to delete the reminder from the database
+ response = await self.bot.http_session.delete(
+ url=URLs.site_reminders_user_api,
+ headers=self.headers,
+ json={
+ "user_id": str(ctx.author.id),
+ "friendly_id": friendly_id
+ }
+ )
+
+ response_data = await response.json()
+ failed = await self._send_confirmation(
+ ctx, response_data,
+ on_success="That reminder has been deleted successfully!"
+ )
+
+ if not failed:
+ self.cancel_reminder(response_data["reminder_id"])
+
+
+def setup(bot: Bot):
+ bot.add_cog(Reminders(bot))
+ log.info("Cog loaded: Reminders")
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index e5fd645fb..442e80cd2 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -92,6 +92,22 @@ class Site:
await ctx.send(embed=embed)
+ @site_group.command(name="rules")
+ async def site_rules(self, ctx: Context):
+ """Info about the server's rules."""
+
+ url = f"{URLs.site_schema}{URLs.site}/about/rules"
+
+ embed = Embed(title="Rules")
+ embed.set_footer(text=url)
+ embed.colour = Colour.blurple()
+ embed.description = (
+ f"The rules and guidelines that apply to this community can be found on our [rules page]({url}). "
+ "We expect all members of the community to have read and understood these."
+ )
+
+ await ctx.send(embed=embed)
+
def setup(bot):
bot.add_cog(Site(bot))
diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py
index f83f8e354..d74380259 100644
--- a/bot/cogs/snakes.py
+++ b/bot/cogs/snakes.py
@@ -462,10 +462,12 @@ class Snakes:
# endregion
# region: Commands
- @group(name='snakes', aliases=('snake',))
+ @group(name='snakes', aliases=('snake',), invoke_without_command=True)
async def snakes_group(self, ctx: Context):
"""Commands from our first code jam."""
+ await ctx.invoke(self.bot.get_command("help"), "snake")
+
@bot_has_permissions(manage_messages=True)
@snakes_group.command(name='antidote')
@locked()
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index fb9164194..cb0454249 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -6,12 +6,14 @@ import textwrap
from discord import Colour, Embed
from discord.ext.commands import (
- Bot, CommandError, Context, MissingPermissions,
- NoPrivateMessage, check, command, guild_only
+ Bot, CommandError, Context, NoPrivateMessage, command, guild_only
)
from bot.cogs.rmq import RMQ
from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs
+from bot.decorators import InChannelCheckFailure, in_channel
+from bot.utils.messages import wait_for_deletion
+
log = logging.getLogger(__name__)
@@ -49,21 +51,8 @@ RAW_CODE_REGEX = re.compile(
r"\s*$", # any trailing whitespace until the end of the string
re.DOTALL # "." also matches newlines
)
-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
+BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
class Snekbox:
@@ -81,7 +70,7 @@ class Snekbox:
@command(name='eval', aliases=('e',))
@guild_only()
- @check(channel_is_whitelisted_or_author_can_bypass)
+ @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None):
"""
Run some code. get the result back. We've done our best to make this safe, but do let us know if you
@@ -180,7 +169,9 @@ class Snekbox:
else:
msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```"
- await ctx.send(msg)
+ response = await ctx.send(msg)
+ self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot))
+
else:
await ctx.send(
f"{ctx.author.mention} Your eval job has completed.\n\n```py\n[No output]\n```"
@@ -200,9 +191,9 @@ class Snekbox:
embed.description = "You're not allowed to use this command in private messages."
await ctx.send(embed=embed)
- elif isinstance(error, MissingPermissions):
+ elif isinstance(error, InChannelCheckFailure):
embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = f"Sorry, but you may only use this command within {WHITELISTED_CHANNELS_STRING}."
+ embed.description = str(error)
await ctx.send(embed=embed)
else:
diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py
new file mode 100644
index 000000000..84467bd8c
--- /dev/null
+++ b/bot/cogs/superstarify.py
@@ -0,0 +1,285 @@
+import logging
+import random
+
+from discord import Colour, Embed, Member
+from discord.errors import Forbidden
+from discord.ext.commands import Bot, Context, command
+
+from bot.cogs.moderation import Moderation
+from bot.cogs.modlog import ModLog
+from bot.constants import (
+ Icons, Keys,
+ NEGATIVE_REPLIES, POSITIVE_REPLIES,
+ Roles, URLs
+)
+from bot.decorators import with_role
+
+log = logging.getLogger(__name__)
+NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy"
+
+
+class Superstarify:
+ """
+ A set of commands to moderate terrible nicknames.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.headers = {"X-API-KEY": Keys.site_api}
+
+ @property
+ def moderation(self) -> Moderation:
+ return self.bot.get_cog("Moderation")
+
+ @property
+ def modlog(self) -> ModLog:
+ return self.bot.get_cog("ModLog")
+
+ async def on_member_update(self, before: Member, after: Member):
+ """
+ This event will trigger when someone changes their name.
+ At this point we will look up the user in our database and check
+ whether they are allowed to change their names, or if they are in
+ superstar-prison. If they are not allowed, we will change it back.
+ """
+
+ if before.display_name == after.display_name:
+ return # User didn't change their nickname. Abort!
+
+ log.debug(
+ f"{before.display_name} is trying to change their nickname to {after.display_name}. "
+ "Checking if the user is in superstar-prison..."
+ )
+
+ response = await self.bot.http_session.get(
+ URLs.site_superstarify_api,
+ headers=self.headers,
+ params={"user_id": str(before.id)}
+ )
+
+ response = await response.json()
+
+ if response and response.get("end_timestamp") and not response.get("error_code"):
+ if after.display_name == response.get("forced_nick"):
+ return # Nick change was triggered by this event. Ignore.
+
+ log.debug(
+ f"{after.display_name} is currently in superstar-prison. "
+ f"Changing the nick back to {before.display_name}."
+ )
+ await after.edit(nick=response.get("forced_nick"))
+ try:
+ await after.send(
+ "You have tried to change your nickname on the **Python Discord** server "
+ f"from **{before.display_name}** to **{after.display_name}**, but as you "
+ "are currently in superstar-prison, you do not have permission to do so. "
+ "You will be allowed to change your nickname again at the following time:\n\n"
+ f"**{response.get('end_timestamp')}**."
+ )
+ except Forbidden:
+ log.warning(
+ "The user tried to change their nickname while in superstar-prison. "
+ "This led to the bot trying to DM the user to let them know they cannot do that, "
+ "but the user had either blocked the bot or disabled DMs, so it was not possible "
+ "to DM them, and a discord.errors.Forbidden error was incurred."
+ )
+
+ async def on_member_join(self, member: Member):
+ """
+ This event will trigger when someone (re)joins the server.
+ At this point we will look up the user in our database and check
+ whether they are in superstar-prison. If so, we will change their name
+ back to the forced nickname.
+ """
+
+ response = await self.bot.http_session.get(
+ URLs.site_superstarify_api,
+ headers=self.headers,
+ params={"user_id": str(member.id)}
+ )
+
+ response = await response.json()
+
+ if response and response.get("end_timestamp") and not response.get("error_code"):
+ forced_nick = response.get("forced_nick")
+ end_timestamp = response.get("end_timestamp")
+ log.debug(
+ f"{member.name} rejoined but is currently in superstar-prison. "
+ f"Changing the nick back to {forced_nick}."
+ )
+
+ await member.edit(nick=forced_nick)
+ try:
+ await member.send(
+ "You have left and rejoined the **Python Discord** server, effectively resetting "
+ f"your nickname from **{forced_nick}** to **{member.name}**, "
+ "but as you are currently in superstar-prison, you do not have permission to do so. "
+ "Therefore your nickname was automatically changed back. You will be allowed to "
+ "change your nickname again at the following time:\n\n"
+ f"**{end_timestamp}**."
+ )
+ except Forbidden:
+ log.warning(
+ "The user left and rejoined the server while in superstar-prison. "
+ "This led to the bot trying to DM the user to let them know their name was restored, "
+ "but the user had either blocked the bot or disabled DMs, so it was not possible "
+ "to DM them, and a discord.errors.Forbidden error was incurred."
+ )
+
+ # Log to the mod_log channel
+ log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
+ mod_log_message = (
+ f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n"
+ f"Superstarified member potentially tried to escape the prison.\n"
+ f"Restored enforced nickname: `{forced_nick}`\n"
+ f"Superstardom ends: **{end_timestamp}**"
+ )
+ await self.modlog.send_log_message(
+ icon_url=Icons.user_update,
+ colour=Colour.gold(),
+ title="Superstar member rejoined server",
+ text=mod_log_message,
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ @command(name='superstarify', aliases=('force_nick', 'star'))
+ @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None):
+ """
+ This command will force a random superstar name (like Taylor Swift) to be the user's
+ nickname for a specified duration. If a forced_nick is provided, it will use that instead.
+
+ :param ctx: Discord message context
+ :param ta:
+ If provided, this function shows data for that specific tag.
+ If not provided, this function shows the caller a list of all tags.
+ """
+
+ log.debug(
+ f"Attempting to superstarify {member.display_name} for {duration}. "
+ f"forced_nick is set to {forced_nick}."
+ )
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+
+ params = {
+ "user_id": str(member.id),
+ "duration": duration
+ }
+
+ if forced_nick:
+ params["forced_nick"] = forced_nick
+
+ response = await self.bot.http_session.post(
+ URLs.site_superstarify_api,
+ headers=self.headers,
+ json=params
+ )
+
+ response = await response.json()
+
+ if "error_message" in response:
+ log.warning(
+ "Encountered the following error when trying to superstarify the user:\n"
+ f"{response.get('error_message')}"
+ )
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = response.get("error_message")
+ return await ctx.send(embed=embed)
+
+ else:
+ forced_nick = response.get('forced_nick')
+ end_time = response.get("end_timestamp")
+ image_url = response.get("image_url")
+ old_nick = member.display_name
+
+ embed.title = "Congratulations!"
+ embed.description = (
+ f"Your previous nickname, **{old_nick}**, was so bad that we have decided to change it. "
+ f"Your new nickname will be **{forced_nick}**.\n\n"
+ f"You will be unable to change your nickname until \n**{end_time}**.\n\n"
+ "If you're confused by this, please read our "
+ f"[official nickname policy]({NICKNAME_POLICY_URL})."
+ )
+ embed.set_image(url=image_url)
+
+ # Log to the mod_log channel
+ log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
+ mod_log_message = (
+ f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n"
+ f"Superstarified by **{ctx.author.name}**\n"
+ f"Old nickname: `{old_nick}`\n"
+ f"New nickname: `{forced_nick}`\n"
+ f"Superstardom ends: **{end_time}**"
+ )
+ await self.modlog.send_log_message(
+ icon_url=Icons.user_update,
+ colour=Colour.gold(),
+ title="Member Achieved Superstardom",
+ text=mod_log_message,
+ thumbnail=member.avatar_url_as(static_format="png")
+ )
+
+ await self.moderation.notify_infraction(
+ user=member,
+ infr_type="Superstarify",
+ duration=duration,
+ reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
+ )
+
+ # Change the nick and return the embed
+ log.debug("Changing the users nickname and sending the embed.")
+ await member.edit(nick=forced_nick)
+ await ctx.send(embed=embed)
+
+ @command(name='unsuperstarify', aliases=('release_nick', 'unstar'))
+ @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ async def unsuperstarify(self, ctx: Context, member: Member):
+ """
+ This command will remove the entry from our database, allowing the user
+ to once again change their nickname.
+
+ :param ctx: Discord message context
+ :param member: The member to unsuperstarify
+ """
+
+ log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}")
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+
+ response = await self.bot.http_session.delete(
+ URLs.site_superstarify_api,
+ headers=self.headers,
+ json={"user_id": str(member.id)}
+ )
+
+ response = await response.json()
+ embed.description = "User has been released from superstar-prison."
+ embed.title = random.choice(POSITIVE_REPLIES)
+
+ if "error_message" in response:
+ embed.colour = Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = response.get("error_message")
+ log.warning(
+ f"Error encountered when trying to unsuperstarify {member.display_name}:\n"
+ f"{response}"
+ )
+
+ else:
+ await self.moderation.notify_pardon(
+ user=member,
+ title="You are no longer superstarified.",
+ content="You may now change your nickname on the server."
+ )
+
+ log.debug(f"{member.display_name} was successfully released from superstar-prison.")
+ await ctx.send(embed=embed)
+
+
+def setup(bot):
+ bot.add_cog(Superstarify(bot))
+ log.info("Cog loaded: Superstarify")
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index 61b8eee57..5a0198db8 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -4,10 +4,11 @@ import time
from discord import Colour, Embed
from discord.ext.commands import (
BadArgument, Bot,
- Context, Converter, group
+ Context, group
)
from bot.constants import Channels, Cooldowns, Keys, Roles
+from bot.converters import TagContentConverter, TagNameConverter, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -21,59 +22,6 @@ TEST_CHANNELS = (
)
-class TagNameConverter(Converter):
- @staticmethod
- async def convert(ctx: Context, tag_name: str):
- def is_number(value):
- try:
- float(value)
- except ValueError:
- return False
- return True
-
- tag_name = tag_name.lower().strip()
-
- # The tag name has at least one invalid character.
- if ascii(tag_name)[1:-1] != tag_name:
- log.warning(f"{ctx.author} tried to put an invalid character in a tag name. "
- "Rejecting the request.")
- raise BadArgument("Don't be ridiculous, you can't use that character!")
-
- # The tag name is either empty, or consists of nothing but whitespace.
- elif not tag_name:
- log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. "
- "Rejecting the request.")
- raise BadArgument("Tag names should not be empty, or filled with whitespace.")
-
- # The tag name is a number of some kind, we don't allow that.
- elif is_number(tag_name):
- log.warning(f"{ctx.author} tried to create a tag with a digit as its name. "
- "Rejecting the request.")
- raise BadArgument("Tag names can't be numbers.")
-
- # The tag name is longer than 127 characters.
- elif len(tag_name) > 127:
- log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. "
- "Rejecting the request.")
- raise BadArgument("Are you insane? That's way too long!")
-
- return tag_name
-
-
-class TagContentConverter(Converter):
- @staticmethod
- async def convert(ctx: Context, tag_content: str):
- tag_content = tag_content.strip()
-
- # The tag contents should not be empty, or filled with whitespace.
- if not tag_content:
- log.warning(f"{ctx.author} tried to create a tag containing only whitespace. "
- "Rejecting the request.")
- raise BadArgument("Tag contents should not be empty, or filled with whitespace.")
-
- return tag_content
-
-
class Tags:
"""
Save new tags and fetch existing tags.
@@ -85,13 +33,13 @@ class Tags:
self.headers = {"Authorization": f"Token {Keys.site_api}"}
@group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True)
- async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter=None):
+ async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None):
"""Show all known tags, a single tag, or run a subcommand."""
await ctx.invoke(self.get_command, tag_name=tag_name)
@tags_group.command(name='get', aliases=('show', 'g'))
- async def get_command(self, ctx: Context, *, tag_name: TagNameConverter=None):
+ async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None):
"""
Get a list of all tags or a specified tag.
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index b101b8816..65c729414 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -1,16 +1,20 @@
import logging
+import random
+import re
+import unicodedata
from email.parser import HeaderParser
from io import StringIO
-
from discord import Colour, Embed
from discord.ext.commands import AutoShardedBot, Context, command
-from bot.constants import Roles
-from bot.decorators import with_role
+from bot.constants import Channels, NEGATIVE_REPLIES, Roles
+from bot.decorators import InChannelCheckFailure, in_channel
log = logging.getLogger(__name__)
+BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+
class Utils:
"""
@@ -24,7 +28,6 @@ class Utils:
self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-"
@command(name='pep', aliases=('get_pep', 'p'))
- @with_role(Roles.verified)
async def pep_command(self, ctx: Context, pep_number: str):
"""
Fetches information about a PEP and sends it to the channel.
@@ -87,6 +90,58 @@ class Utils:
await ctx.message.channel.send(embed=pep_embed)
+ @command()
+ @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
+ async def charinfo(self, ctx, *, characters: str):
+ """
+ Shows you information on up to 25 unicode characters.
+ """
+
+ match = re.match(r"<(a?):(\w+):(\d+)>", characters)
+ if match:
+ embed = Embed(
+ title="Non-Character Detected",
+ description=(
+ "Only unicode characters can be processed, but a custom Discord emoji "
+ "was found. Please remove it and try again."
+ )
+ )
+ embed.colour = Colour.red()
+ return await ctx.send(embed=embed)
+
+ if len(characters) > 25:
+ embed = Embed(title=f"Too many characters ({len(characters)}/25)")
+ embed.colour = Colour.red()
+ return await ctx.send(embed=embed)
+
+ def get_info(char):
+ digit = f"{ord(char):x}"
+ if len(digit) <= 4:
+ u_code = f"\\u{digit:>04}"
+ else:
+ u_code = f"\\U{digit:>08}"
+ url = f"https://www.compart.com/en/unicode/U+{digit:>04}"
+ name = f"[{unicodedata.name(char, '')}]({url})"
+ info = f"`{u_code.ljust(10)}`: {name} - {char}"
+ return info, u_code
+
+ charlist, rawlist = zip(*(get_info(c) for c in characters))
+
+ embed = Embed(description="\n".join(charlist))
+ embed.set_author(name="Character Info")
+
+ if len(characters) > 1:
+ embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False)
+
+ await ctx.send(embed=embed)
+
+ async def __error(self, ctx, error):
+ embed = Embed(colour=Colour.red())
+ if isinstance(error, InChannelCheckFailure):
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = str(error)
+ await ctx.send(embed=embed)
+
def setup(bot):
bot.add_cog(Utils(bot))
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 8d29a4bee..56fcd63eb 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -151,6 +151,17 @@ class Verification:
f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications."
)
+ @staticmethod
+ def __global_check(ctx: Context):
+ """
+ Block any command within the verification channel that is not !accept.
+ """
+
+ if ctx.channel.id == Channels.verification:
+ return ctx.command.name == "accept"
+ else:
+ return True
+
def setup(bot):
bot.add_cog(Verification(bot))
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
new file mode 100644
index 000000000..c36ef6075
--- /dev/null
+++ b/bot/cogs/wolfram.py
@@ -0,0 +1,289 @@
+import logging
+from io import BytesIO
+from typing import List, Optional, Tuple
+from urllib import parse
+
+import discord
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import BucketType, Context, check, group
+
+from bot.constants import Colours, Roles, Wolfram
+from bot.pagination import ImagePaginator
+
+log = logging.getLogger(__name__)
+
+APPID = Wolfram.key
+DEFAULT_OUTPUT_FORMAT = "JSON"
+QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
+WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
+
+COOLDOWN_IGNORERS = Roles.moderator, Roles.owner, Roles.admin, Roles.helpers
+MAX_PODS = 20
+
+# Allows for 10 wolfram calls pr user pr day
+usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user)
+
+# Allows for max api requests / days in month per day for the entire guild (Temporary)
+guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild)
+
+
+async def send_embed(
+ ctx: Context,
+ message_txt: str,
+ colour: int = Colours.soft_red,
+ footer: str = None,
+ img_url: str = None,
+ f: discord.File = None
+) -> None:
+ """
+ Generates an embed with wolfram as the author, with message_txt as description,
+ adds custom colour if specified, a footer and image (could be a file with f param) and sends
+ the embed through ctx
+ :param ctx: Context
+ :param message_txt: str - Message to be sent
+ :param colour: int - Default: Colours.soft_red - Colour of embed
+ :param footer: str - Default: None - Adds a footer to the embed
+ :param img_url:str - Default: None - Adds an image to the embed
+ :param f: discord.File - Default: None - Add a file to the msg, often attached as image to embed
+ """
+
+ embed = Embed(colour=colour)
+ embed.description = message_txt
+ embed.set_author(name="Wolfram Alpha",
+ icon_url=WOLF_IMAGE,
+ url="https://www.wolframalpha.com/")
+ if footer:
+ embed.set_footer(text=footer)
+
+ if img_url:
+ embed.set_image(url=img_url)
+
+ await ctx.send(embed=embed, file=f)
+
+
+def custom_cooldown(*ignore: List[int]) -> check:
+ """
+ Custom cooldown mapping that applies a specific requests per day to users.
+ Staff is ignored by the user cooldown, however the cooldown implements a
+ total amount of uses per day for the entire guild. (Configurable in configs)
+
+ :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown
+ :return: check
+ """
+
+ async def predicate(ctx: Context) -> bool:
+ user_bucket = usercd.get_bucket(ctx.message)
+
+ if ctx.author.top_role.id not in ignore:
+ user_rate = user_bucket.update_rate_limit()
+
+ if user_rate:
+ # Can't use api; cause: member limit
+ message = (
+ "You've used up your limit for Wolfram|Alpha requests.\n"
+ f"Cooldown: {int(user_rate)}"
+ )
+ await send_embed(ctx, message)
+ return False
+
+ guild_bucket = guildcd.get_bucket(ctx.message)
+ guild_rate = guild_bucket.update_rate_limit()
+
+ # Repr has a token attribute to read requests left
+ log.debug(guild_bucket)
+
+ if guild_rate:
+ # Can't use api; cause: guild limit
+ message = (
+ "The max limit of requests for the server has been reached for today.\n"
+ f"Cooldown: {int(guild_rate)}"
+ )
+ await send_embed(ctx, message)
+ return False
+
+ return True
+ return check(predicate)
+
+
+async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
+ # Give feedback that the bot is working.
+ async with ctx.channel.typing():
+ url_str = parse.urlencode({
+ "input": query,
+ "appid": APPID,
+ "output": DEFAULT_OUTPUT_FORMAT,
+ "format": "image,plaintext"
+ })
+ request_url = QUERY.format(request="query", data=url_str)
+
+ async with bot.http_session.get(request_url) as response:
+ json = await response.json(content_type='text/plain')
+
+ result = json["queryresult"]
+
+ if not result["success"]:
+ message = f"I couldn't find anything for {query}."
+ await send_embed(ctx, message)
+ return
+
+ if result["error"]:
+ message = "Something went wrong internally with your request, please notify staff!"
+ log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
+ await send_embed(ctx, message)
+ return
+
+ if not result["numpods"]:
+ message = "Could not find any results."
+ await send_embed(ctx, message)
+ return
+
+ pods = result["pods"]
+ pages = []
+ for pod in pods[:MAX_PODS]:
+ subs = pod.get("subpods")
+
+ for sub in subs:
+ title = sub.get("title") or sub.get("plaintext") or sub.get("id", "")
+ img = sub["img"]["src"]
+ pages.append((title, img))
+ return pages
+
+
+class Wolfram:
+ """
+ Commands for interacting with the Wolfram|Alpha API.
+ """
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
+ @custom_cooldown(*COOLDOWN_IGNORERS)
+ async def wolfram_command(self, ctx: Context, *, query: str) -> None:
+ """
+ Requests all answers on a single image,
+ sends an image of all related pods
+
+ :param ctx: Context
+ :param query: str - string request to api
+ """
+
+ url_str = parse.urlencode({
+ "i": query,
+ "appid": APPID,
+ })
+ query = QUERY.format(request="simple", data=url_str)
+
+ # Give feedback that the bot is working.
+ async with ctx.channel.typing():
+ async with self.bot.http_session.get(query) as response:
+ status = response.status
+ image_bytes = await response.read()
+
+ f = discord.File(BytesIO(image_bytes), filename="image.png")
+ image_url = "attachment://image.png"
+
+ if status == 501:
+ message = "Failed to get response"
+ footer = ""
+ color = Colours.soft_red
+ elif status == 400:
+ message = "No input found"
+ footer = ""
+ color = Colours.soft_red
+ else:
+ message = ""
+ footer = "View original for a bigger picture."
+ color = Colours.soft_orange
+
+ # Sends a "blank" embed if no request is received, unsure how to fix
+ await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
+
+ @wolfram_command.command(name="page", aliases=("pa", "p"))
+ @custom_cooldown(*COOLDOWN_IGNORERS)
+ async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
+ """
+ Requests a drawn image of given query
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc
+
+ :param ctx: Context
+ :param query: str - string request to api
+ """
+
+ pages = await get_pod_pages(ctx, self.bot, query)
+
+ if not pages:
+ return
+
+ embed = Embed()
+ embed.set_author(name="Wolfram Alpha",
+ icon_url=WOLF_IMAGE,
+ url="https://www.wolframalpha.com/")
+ embed.colour = Colours.soft_orange
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @wolfram_command.command(name="cut", aliases=("c",))
+ @custom_cooldown(*COOLDOWN_IGNORERS)
+ async def wolfram_cut_command(self, ctx, *, query: str) -> None:
+ """
+ Requests a drawn image of given query
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc
+
+ :param ctx: Context
+ :param query: str - string request to api
+ """
+
+ pages = await get_pod_pages(ctx, self.bot, query)
+
+ if not pages:
+ return
+
+ if len(pages) >= 2:
+ page = pages[1]
+ else:
+ page = pages[0]
+
+ await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
+
+ @wolfram_command.command(name="short", aliases=("sh", "s"))
+ @custom_cooldown(*COOLDOWN_IGNORERS)
+ async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
+ """
+ Requests an answer to a simple question
+ Responds in plaintext
+
+ :param ctx: Context
+ :param query: str - string request to api
+ """
+
+ url_str = parse.urlencode({
+ "i": query,
+ "appid": APPID,
+ })
+ query = QUERY.format(request="result", data=url_str)
+
+ # Give feedback that the bot is working.
+ async with ctx.channel.typing():
+ async with self.bot.http_session.get(query) as response:
+ status = response.status
+ response_text = await response.text()
+
+ if status == 501:
+ message = "Failed to get response"
+ color = Colours.soft_red
+
+ elif status == 400:
+ message = "No input found"
+ color = Colours.soft_red
+ else:
+ message = response_text
+ color = Colours.soft_orange
+
+ await send_embed(ctx, message, color)
+
+
+def setup(bot: commands.Bot) -> None:
+ bot.add_cog(Wolfram(bot))
+ log.info("Cog loaded: Wolfram")
diff --git a/bot/constants.py b/bot/constants.py
index 68fbc2bc4..99ef98da2 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -18,36 +18,10 @@ from pathlib import Path
from typing import Dict, List
import yaml
-from yaml.constructor import ConstructorError
log = logging.getLogger(__name__)
-def _required_env_var_constructor(loader, node):
- """
- Implements a custom YAML tag for loading required environment
- variables. If the environment variable is set, this function
- will simply return it. Otherwise, a `CRITICAL` log message is
- given and the `KeyError` is re-raised.
-
- Example usage in the YAML configuration:
-
- bot:
- token: !REQUIRED_ENV 'BOT_TOKEN'
- """
-
- value = loader.construct_scalar(node)
-
- try:
- return os.environ[value]
- except KeyError:
- log.critical(
- f"Environment variable `{value}` is required, but was not set. "
- "Set it in your environment or override the option using it in your `config.yml`."
- )
- raise
-
-
def _env_var_constructor(loader, node):
"""
Implements a custom YAML tag for loading optional environment
@@ -63,8 +37,12 @@ def _env_var_constructor(loader, node):
default = None
- try:
- # Try to construct a list from this YAML node
+ # Check if the node is a plain string value
+ if node.id == 'scalar':
+ value = loader.construct_scalar(node)
+ key = str(value)
+ else:
+ # The node value is a list
value = loader.construct_sequence(node)
if len(value) >= 2:
@@ -74,11 +52,6 @@ def _env_var_constructor(loader, node):
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)
@@ -96,7 +69,9 @@ def _join_var_constructor(loader, node):
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)
+
+# Pointing old tag to !ENV constructor to avoid breaking existing configs
+yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _env_var_constructor)
with open("config-default.yml", encoding="UTF-8") as f:
@@ -129,6 +104,34 @@ if Path("config.yml").exists():
_recursive_update(_CONFIG_YAML, user_config)
+def check_required_keys(keys):
+ """
+ Verifies that keys that are set to be required are present in the
+ loaded configuration.
+ """
+ for key_path in keys:
+ lookup = _CONFIG_YAML
+ try:
+ for key in key_path.split('.'):
+ lookup = lookup[key]
+ if lookup is None:
+ raise KeyError(key)
+ except KeyError:
+ log.critical(
+ f"A configuration for `{key_path}` is required, but was not found. "
+ "Please set it in `config.yml` or setup an environment variable and try again."
+ )
+ raise
+
+
+try:
+ required_keys = _CONFIG_YAML['config']['required_keys']
+except KeyError:
+ pass
+else:
+ check_required_keys(required_keys)
+
+
class YAMLGetter(type):
"""
Implements a custom metaclass used for accessing
@@ -188,7 +191,7 @@ class YAMLGetter(type):
class Bot(metaclass=YAMLGetter):
section = "bot"
- help_prefix: str
+ prefix: str
token: str
@@ -224,6 +227,7 @@ class Colours(metaclass=YAMLGetter):
soft_red: int
soft_green: int
+ soft_orange: int
class Emojis(metaclass=YAMLGetter):
@@ -237,7 +241,7 @@ class Emojis(metaclass=YAMLGetter):
green_chevron: str
red_chevron: str
white_chevron: str
- lemoneye2: str
+ bb_message: str
status_online: str
status_offline: str
@@ -286,9 +290,18 @@ class Icons(metaclass=YAMLGetter):
user_mute: str
user_unmute: str
+ user_verified: str
+
+ user_warn: str
pencil: str
+ remind_blurple: str
+ remind_green: str
+ remind_red: str
+
+ questionmark: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -342,7 +355,7 @@ class Roles(metaclass=YAMLGetter):
muted: int
owner: int
verified: int
- muted: int
+ helpers: int
class Guild(metaclass=YAMLGetter):
@@ -390,12 +403,14 @@ class URLs(metaclass=YAMLGetter):
site_api: str
site_facts_api: str
site_clean_api: str
- site_hiphopify_api: str
+ site_superstarify_api: str
site_idioms_api: str
site_logs_api: str
site_logs_view: str
site_names_api: str
site_quiz_api: str
+ site_reminders_api: str
+ site_reminders_user_api: str
site_schema: str
site_settings_api: str
site_special_api: str
@@ -418,6 +433,14 @@ class Reddit(metaclass=YAMLGetter):
subreddits: list
+class Wolfram(metaclass=YAMLGetter):
+ section = "wolfram"
+
+ user_limit_day: int
+ guild_limit_day: int
+ key: str
+
+
class AntiSpam(metaclass=YAMLGetter):
section = 'anti_spam'
@@ -428,6 +451,13 @@ class AntiSpam(metaclass=YAMLGetter):
rules: Dict[str, Dict[str, int]]
+class BigBrother(metaclass=YAMLGetter):
+ section = 'big_brother'
+
+ log_delay: int
+ header_message_limit: int
+
+
# Debug mode
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
diff --git a/bot/converters.py b/bot/converters.py
index 3def4b07a..069e841f9 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,16 +1,20 @@
+import logging
import random
import socket
from ssl import CertificateError
import discord
from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector
-from discord.ext.commands import BadArgument, Converter, UserConverter
+from discord.ext.commands import BadArgument, Context, Converter
from fuzzywuzzy import fuzz
from bot.constants import DEBUG_MODE, Keys, URLs
from bot.utils import disambiguate
+log = logging.getLogger(__name__)
+
+
class Snake(Converter):
snakes = None
special_cases = None
@@ -167,11 +171,10 @@ class InfractionSearchQuery(Converter):
@staticmethod
async def convert(ctx, arg):
try:
- user_converter = UserConverter()
- user = await user_converter.convert(ctx, arg)
- except Exception:
+ maybe_snowflake = arg.strip("<@!>")
+ return await ctx.bot.get_user_info(maybe_snowflake)
+ except (discord.NotFound, discord.HTTPException):
return arg
- return user or arg
class Subreddit(Converter):
@@ -198,3 +201,56 @@ class Subreddit(Converter):
)
return sub
+
+
+class TagNameConverter(Converter):
+ @staticmethod
+ async def convert(ctx: Context, tag_name: str):
+ def is_number(value):
+ try:
+ float(value)
+ except ValueError:
+ return False
+ return True
+
+ tag_name = tag_name.lower().strip()
+
+ # The tag name has at least one invalid character.
+ if ascii(tag_name)[1:-1] != tag_name:
+ log.warning(f"{ctx.author} tried to put an invalid character in a tag name. "
+ "Rejecting the request.")
+ raise BadArgument("Don't be ridiculous, you can't use that character!")
+
+ # The tag name is either empty, or consists of nothing but whitespace.
+ elif not tag_name:
+ log.warning(f"{ctx.author} tried to create a tag with a name consisting only of whitespace. "
+ "Rejecting the request.")
+ raise BadArgument("Tag names should not be empty, or filled with whitespace.")
+
+ # The tag name is a number of some kind, we don't allow that.
+ elif is_number(tag_name):
+ log.warning(f"{ctx.author} tried to create a tag with a digit as its name. "
+ "Rejecting the request.")
+ raise BadArgument("Tag names can't be numbers.")
+
+ # The tag name is longer than 127 characters.
+ elif len(tag_name) > 127:
+ log.warning(f"{ctx.author} tried to request a tag name with over 127 characters. "
+ "Rejecting the request.")
+ raise BadArgument("Are you insane? That's way too long!")
+
+ return tag_name
+
+
+class TagContentConverter(Converter):
+ @staticmethod
+ async def convert(ctx: Context, tag_content: str):
+ tag_content = tag_content.strip()
+
+ # The tag contents should not be empty, or filled with whitespace.
+ if not tag_content:
+ log.warning(f"{ctx.author} tried to create a tag containing only whitespace. "
+ "Rejecting the request.")
+ raise BadArgument("Tag contents should not be empty, or filled with whitespace.")
+
+ return tag_content
diff --git a/bot/decorators.py b/bot/decorators.py
index fe974cbd3..87877ecbf 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,18 +1,51 @@
import logging
import random
+import typing
from asyncio import Lock
from functools import wraps
from weakref import WeakValueDictionary
from discord import Colour, Embed
from discord.ext import commands
-from discord.ext.commands import Context
+from discord.ext.commands import CheckFailure, Context
from bot.constants import ERROR_REPLIES
log = logging.getLogger(__name__)
+class InChannelCheckFailure(CheckFailure):
+ pass
+
+
+def in_channel(*channels: int, bypass_roles: typing.Container[int] = None):
+ """
+ Checks that the message is in a whitelisted channel or optionally has a bypass role.
+ """
+ def predicate(ctx: Context):
+ if ctx.channel.id in channels:
+ log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The command was used in a whitelisted channel.")
+ return True
+
+ if bypass_roles:
+ if any(r.id in bypass_roles for r in ctx.author.roles):
+ log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The command was not used in a whitelisted channel, "
+ f"but the author had a role to bypass the in_channel check.")
+ return True
+
+ log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The in_channel check failed.")
+
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ raise InChannelCheckFailure(
+ f"Sorry, but you may only use this command within {channels_str}."
+ )
+
+ return commands.check(predicate)
+
+
def with_role(*role_ids: int):
async def predicate(ctx: Context):
if not ctx.guild: # Return False in a DM
@@ -46,15 +79,6 @@ def without_role(*role_ids: int):
return commands.check(predicate)
-def in_channel(channel_id):
- async def predicate(ctx: Context):
- check = ctx.channel.id == channel_id
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the in_channel check was {check}.")
- return check
- return commands.check(predicate)
-
-
def locked():
"""
Allows the user to only run one instance of the decorated command at a time.
diff --git a/bot/pagination.py b/bot/pagination.py
index 9319a5b60..0d8e8aaa3 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -1,16 +1,16 @@
import asyncio
import logging
-from typing import Iterable, Optional
+from typing import Iterable, List, Optional, Tuple
from discord import Embed, Member, Reaction
from discord.abc import User
from discord.ext.commands import Context, Paginator
-LEFT_EMOJI = "\u2B05"
-RIGHT_EMOJI = "\u27A1"
-DELETE_EMOJI = "\u274c"
-FIRST_EMOJI = "\u23EE"
-LAST_EMOJI = "\u23ED"
+FIRST_EMOJI = "\u23EE" # [:track_previous:]
+LEFT_EMOJI = "\u2B05" # [:arrow_left:]
+RIGHT_EMOJI = "\u27A1" # [:arrow_right:]
+LAST_EMOJI = "\u23ED" # [:track_next:]
+DELETE_EMOJI = "\u274c" # [:x:]
PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI]
@@ -95,7 +95,7 @@ class LinePaginator(Paginator):
@classmethod
async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed,
prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500,
- empty: bool = True, restrict_to_user: User = None, timeout: int=300,
+ empty: bool = True, restrict_to_user: User = None, timeout: int = 300,
footer_text: str = None):
"""
Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to
@@ -129,9 +129,9 @@ class LinePaginator(Paginator):
no_restrictions = (
# Pagination is not restricted
- not restrict_to_user or
+ not restrict_to_user
# The reaction was by a whitelisted user
- user_.id == restrict_to_user.id
+ or user_.id == restrict_to_user.id
)
return (
@@ -275,3 +275,176 @@ class LinePaginator(Paginator):
log.debug("Ending pagination and removing all reactions...")
await message.clear_reactions()
+
+
+class ImagePaginator(Paginator):
+ """
+ Helper class that paginates images for embeds in messages.
+ Close resemblance to LinePaginator, except focuses on images over text.
+
+ Refer to ImagePaginator.paginate for documentation on how to use.
+ """
+
+ def __init__(self, prefix="", suffix=""):
+ super().__init__(prefix, suffix)
+ self._current_page = [prefix]
+ self.images = []
+ self._pages = []
+
+ def add_line(self, line: str = '', *, empty: bool = False) -> None:
+ """
+ Adds a line to each page, usually just 1 line in this context
+ :param line: str to be page content / title
+ :param empty: if there should be new lines between entries
+ """
+
+ if line:
+ self._count = len(line)
+ else:
+ self._count = 0
+ self._current_page.append(line)
+ self.close_page()
+
+ def add_image(self, image: str = None) -> None:
+ """
+ Adds an image to a page
+ :param image: image url to be appended
+ """
+
+ self.images.append(image)
+
+ @classmethod
+ async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed,
+ prefix: str = "", suffix: str = "", timeout: int = 300):
+ """
+ Use a paginator and set of reactions to provide
+ pagination over a set of title/image pairs.The reactions are
+ used to switch page, or to finish with pagination.
+
+ When used, this will send a message using `ctx.send()` and
+ apply a set of reactions to it. These reactions may
+ be used to change page, or to remove pagination from the message.
+
+ Note: Pagination will be removed automatically
+ if no reaction is added for five minutes (300 seconds).
+
+ >>> embed = Embed()
+ >>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
+ >>> await ImagePaginator.paginate(pages, ctx, embed)
+
+ Parameters
+ -----------
+ :param pages: An iterable of tuples with title for page, and img url
+ :param ctx: ctx for message
+ :param embed: base embed to modify
+ :param prefix: prefix of message
+ :param suffix: suffix of message
+ :param timeout: timeout for when reactions get auto-removed
+ """
+
+ def check_event(reaction_: Reaction, member: Member) -> bool:
+ """
+ Checks each reaction added, if it matches our conditions pass the wait_for
+ :param reaction_: reaction added
+ :param member: reaction added by member
+ """
+
+ return all((
+ # Reaction is on the same message sent
+ reaction_.message.id == message.id,
+ # The reaction is part of the navigation menu
+ reaction_.emoji in PAGINATION_EMOJI,
+ # The reactor is not a bot
+ not member.bot
+ ))
+
+ paginator = cls(prefix=prefix, suffix=suffix)
+ current_page = 0
+
+ for text, image_url in pages:
+ paginator.add_line(text)
+ paginator.add_image(image_url)
+
+ embed.description = paginator.pages[current_page]
+ image = paginator.images[current_page]
+
+ if image:
+ embed.set_image(url=image)
+
+ if len(paginator.pages) <= 1:
+ return await ctx.send(embed=embed)
+
+ embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
+ message = await ctx.send(embed=embed)
+
+ for emoji in PAGINATION_EMOJI:
+ await message.add_reaction(emoji)
+
+ while True:
+ # Start waiting for reactions
+ try:
+ reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event)
+ except asyncio.TimeoutError:
+ log.debug("Timed out waiting for a reaction")
+ break # We're done, no reactions for the last 5 minutes
+
+ # Deletes the users reaction
+ await message.remove_reaction(reaction.emoji, user)
+
+ # Delete reaction press - [:x:]
+ if reaction.emoji == DELETE_EMOJI:
+ log.debug("Got delete reaction")
+ break
+
+ # First reaction press - [:track_previous:]
+ if reaction.emoji == FIRST_EMOJI:
+ if current_page == 0:
+ log.debug("Got first page reaction, but we're on the first page - ignoring")
+ continue
+
+ current_page = 0
+ reaction_type = "first"
+
+ # Last reaction press - [:track_next:]
+ if reaction.emoji == LAST_EMOJI:
+ if current_page >= len(paginator.pages) - 1:
+ log.debug("Got last page reaction, but we're on the last page - ignoring")
+ continue
+
+ current_page = len(paginator.pages - 1)
+ reaction_type = "last"
+
+ # Previous reaction press - [:arrow_left: ]
+ if reaction.emoji == LEFT_EMOJI:
+ if current_page <= 0:
+ log.debug("Got previous page reaction, but we're on the first page - ignoring")
+ continue
+
+ current_page -= 1
+ reaction_type = "previous"
+
+ # Next reaction press - [:arrow_right:]
+ if reaction.emoji == RIGHT_EMOJI:
+ if current_page >= len(paginator.pages) - 1:
+ log.debug("Got next page reaction, but we're on the last page - ignoring")
+ continue
+
+ current_page += 1
+ reaction_type = "next"
+
+ # Magic happens here, after page and reaction_type is set
+ embed.description = ""
+ await message.edit(embed=embed)
+ embed.description = paginator.pages[current_page]
+
+ image = paginator.images[current_page]
+ if image:
+ embed.set_image(url=image)
+
+ embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
+ log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
+
+ await message.edit(embed=embed)
+
+ log.debug("Ending pagination and removing all reactions...")
+ await message.clear_reactions()
diff --git a/bot/rules/links.py b/bot/rules/links.py
index dfeb38c61..fa4043fcb 100644
--- a/bot/rules/links.py
+++ b/bot/rules/links.py
@@ -20,9 +20,19 @@ async def apply(
for msg in recent_messages
if msg.author == last_message.author
)
- total_links = sum(len(LINK_RE.findall(msg.content)) for msg in relevant_messages)
-
- if total_links > config['max']:
+ total_links = 0
+ messages_with_links = 0
+
+ for msg in relevant_messages:
+ total_matches = len(LINK_RE.findall(msg.content))
+ if total_matches:
+ messages_with_links += 1
+ total_links += total_matches
+
+ # Only apply the filter if we found more than one message with
+ # links to prevent wrongfully firing the rule on users posting
+ # e.g. an installation log of pip packages from GitHub.
+ if total_links > config['max'] and messages_with_links > 1:
return (
f"sent {total_links} links in {config['interval']}s",
(last_message.author,),
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 1a902b68c..87351eaf3 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -35,9 +35,9 @@ async def disambiguate(
choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1))
def check(message):
- return (message.content.isdigit() and
- message.author == ctx.author and
- message.channel == ctx.channel)
+ return (message.content.isdigit()
+ and message.author == ctx.author
+ and message.channel == ctx.channel)
try:
if embed is None:
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
new file mode 100644
index 000000000..fc38b0127
--- /dev/null
+++ b/bot/utils/messages.py
@@ -0,0 +1,112 @@
+import asyncio
+import contextlib
+from io import BytesIO
+from typing import Sequence
+
+from discord import Embed, File, Message, TextChannel
+from discord.abc import Snowflake
+from discord.errors import HTTPException
+
+from bot.constants import Emojis
+
+MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes
+
+
+async def wait_for_deletion(
+ message: Message,
+ user_ids: Sequence[Snowflake],
+ deletion_emojis: Sequence[str] = (Emojis.cross_mark,),
+ timeout: float = 60 * 5,
+ attach_emojis=True,
+ client=None
+):
+ """
+ Waits for up to `timeout` seconds for a reaction by
+ any of the specified `user_ids` to delete the message.
+
+ Args:
+ message (Message):
+ The message that should be monitored for reactions
+ and possibly deleted. Must be a message sent on a
+ guild since access to the bot instance is required.
+
+ user_ids (Sequence[Snowflake]):
+ A sequence of users that are allowed to delete
+ this message.
+
+ Kwargs:
+ deletion_emojis (Sequence[str]):
+ A sequence of emojis that are considered deletion
+ emojis.
+
+ timeout (float):
+ A positive float denoting the maximum amount of
+ time to wait for a deletion reaction.
+
+ attach_emojis (bool):
+ Whether to attach the given `deletion_emojis`
+ to the message in the given `context`
+
+ client (Optional[discord.Client]):
+ The client instance handling the original command.
+ If not given, will take the client from the guild
+ of the message.
+ """
+
+ if message.guild is None and client is None:
+ raise ValueError("Message must be sent on a guild")
+
+ bot = client or message.guild.me
+
+ if attach_emojis:
+ for emoji in deletion_emojis:
+ await message.add_reaction(emoji)
+
+ def check(reaction, user):
+ return (
+ reaction.message.id == message.id
+ and reaction.emoji in deletion_emojis
+ and user.id in user_ids
+ )
+
+ with contextlib.suppress(asyncio.TimeoutError):
+ await bot.wait_for(
+ 'reaction_add',
+ check=check,
+ timeout=timeout
+ )
+ await message.delete()
+
+
+async def send_attachments(message: Message, destination: TextChannel):
+ """
+ Re-uploads each attachment in a message to the given channel.
+
+ Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit.
+ If attachments are too large, they are instead grouped into a single embed which links to them.
+
+ :param message: the message whose attachments to re-upload
+ :param destination: the channel in which to re-upload the attachments
+ """
+
+ large = []
+ for attachment in message.attachments:
+ try:
+ # This should avoid most files that are too large, but some may get through hence the try-catch.
+ # Allow 512 bytes of leeway for the rest of the request.
+ if attachment.size <= MAX_SIZE - 512:
+ with BytesIO() as file:
+ await attachment.save(file)
+ await destination.send(file=File(file, filename=attachment.filename))
+ else:
+ large.append(attachment)
+ except HTTPException as e:
+ if e.status == 413:
+ large.append(attachment)
+ else:
+ raise
+
+ if large:
+ embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large))
+ embed.set_footer(text="Attachments exceed upload size limit.")
+ await destination.send(embed=embed)
diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py
new file mode 100644
index 000000000..724b455bc
--- /dev/null
+++ b/bot/utils/moderation.py
@@ -0,0 +1,45 @@
+import logging
+from typing import Union
+
+from aiohttp import ClientError
+from discord import Member, Object, User
+from discord.ext.commands import Context
+
+from bot.constants import Keys, URLs
+
+log = logging.getLogger(__name__)
+
+HEADERS = {"X-API-KEY": Keys.site_api}
+
+
+async def post_infraction(
+ ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False
+):
+
+ payload = {
+ "type": type,
+ "reason": reason,
+ "user_id": str(user.id),
+ "actor_id": str(ctx.message.author.id),
+ "hidden": hidden
+ }
+ if duration:
+ payload['duration'] = duration
+
+ try:
+ response = await ctx.bot.http_session.post(
+ URLs.site_infractions,
+ headers=HEADERS,
+ json=payload
+ )
+ except ClientError:
+ log.exception("There was an error adding an infraction.")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ response_object = await response.json()
+ if "error_code" in response_object:
+ await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}")
+ return
+
+ return response_object
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
new file mode 100644
index 000000000..ded6401b0
--- /dev/null
+++ b/bot/utils/scheduling.py
@@ -0,0 +1,77 @@
+import asyncio
+import contextlib
+import logging
+from abc import ABC, abstractmethod
+from typing import Dict
+
+log = logging.getLogger(__name__)
+
+
+class Scheduler(ABC):
+
+ def __init__(self):
+
+ self.cog_name = self.__class__.__name__ # keep track of the child cog's name so the logs are clear.
+ self.scheduled_tasks: Dict[str, asyncio.Task] = {}
+
+ @abstractmethod
+ async def _scheduled_task(self, task_object: dict):
+ """
+ A coroutine which handles the scheduling. This is added to the scheduled tasks,
+ and should wait the task duration, execute the desired code, and clean up the task.
+ For example, in Reminders this will wait for the reminder duration, send the reminder,
+ then make a site API request to delete the reminder from the database.
+
+ :param task_object:
+ """
+
+ def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict):
+ """
+ Schedules a task.
+ :param loop: the asyncio event loop
+ :param task_id: the ID of the task.
+ :param task_data: the data of the task, passed to `Scheduler._scheduled_expiration`.
+ """
+
+ if task_id in self.scheduled_tasks:
+ return
+
+ task: asyncio.Task = create_task(loop, self._scheduled_task(task_data))
+
+ self.scheduled_tasks[task_id] = task
+
+ def cancel_task(self, task_id: str):
+ """
+ Un-schedules a task.
+ :param task_id: the ID of the infraction in question
+ """
+
+ task = self.scheduled_tasks.get(task_id)
+
+ if task is None:
+ log.warning(f"{self.cog_name}: Failed to unschedule {task_id} (no task found).")
+ return
+
+ task.cancel()
+ log.debug(f"{self.cog_name}: Unscheduled {task_id}.")
+ del self.scheduled_tasks[task_id]
+
+
+def create_task(loop: asyncio.AbstractEventLoop, coro_or_future):
+ """
+ Creates an asyncio.Task object from a coroutine or future object.
+
+ :param loop: the asyncio event loop.
+ :param coro_or_future: the coroutine or future object to be scheduled.
+ """
+
+ task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop)
+
+ # Silently ignore exceptions in a callback (handles the CancelledError nonsense)
+ task.add_done_callback(_silent_exception)
+ return task
+
+
+def _silent_exception(future):
+ with contextlib.suppress(Exception):
+ future.exception()
diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py
index c37ac0f50..b9d29583f 100644
--- a/bot/utils/snakes/hatching.py
+++ b/bot/utils/snakes/hatching.py
@@ -1,36 +1,36 @@
-h1 = '''```
+h1 = r'''```
----
------
- /--------\\
+ /--------\
|--------|
|--------|
\------/
----```'''
-h2 = '''```
+h2 = r'''```
----
------
- /---\\-/--\\
- |-----\\--|
+ /---\-/--\
+ |-----\--|
|--------|
\------/
----```'''
-h3 = '''```
+h3 = r'''```
----
------
- /---\\-/--\\
- |-----\\--|
+ /---\-/--\
+ |-----\--|
|-----/--|
- \----\\-/
+ \----\-/
----```'''
-h4 = '''```
+h4 = r'''```
-----
- ----- \\
- /--| /---\\
- |--\\ -\\---|
- |--\\--/-- /
+ ----- \
+ /--| /---\
+ |--\ -\---|
+ |--\--/-- /
\------- /
------```'''
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 77cef4670..8e5d4e1bd 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,7 +1,10 @@
+import asyncio
import datetime
from dateutil.relativedelta import relativedelta
+RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+
def _stringify_time_unit(value: int, unit: str):
"""
@@ -89,3 +92,22 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max
humanized = humanize_delta(delta, precision, max_units)
return f"{humanized} ago"
+
+
+def parse_rfc1123(time_str):
+ return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+
+
+# Hey, this could actually be used in the off_topic_names and reddit cogs :)
+async def wait_until(time: datetime.datetime):
+ """
+ Wait until a given time.
+
+ :param time: A datetime.datetime object to wait until.
+ """
+
+ delay = time - datetime.datetime.now(tz=datetime.timezone.utc)
+ delay_seconds = delay.total_seconds()
+
+ if delay_seconds > 1.0:
+ await asyncio.sleep(delay_seconds)
diff --git a/config-default.yml b/config-default.yml
index ce7639186..3a1ad8052 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,6 +1,6 @@
bot:
- help_prefix: "bot."
- token: !REQUIRED_ENV "BOT_TOKEN"
+ prefix: "!"
+ token: !ENV "BOT_TOKEN"
cooldowns:
# Per channel, per tag.
@@ -15,16 +15,17 @@ style:
colours:
soft_red: 0xcd6d6d
soft_green: 0x68c290
+ soft_orange: 0xf9cb54
emojis:
defcon_disabled: "<:defcondisabled:470326273952972810>"
- defcon_enabled: "<:defconenabled:470326274213150730>"
- defcon_updated: "<:defconsettingsupdated:470326274082996224>"
+ defcon_enabled: "<:defconenabled:470326274213150730>"
+ defcon_updated: "<:defconsettingsupdated:470326274082996224>"
green_chevron: "<:greenchevron:418104310329769993>"
red_chevron: "<:redchevron:418112778184818698>"
white_chevron: "<:whitechevron:418110396973711363>"
- lemoneye2: "<:lemoneye2:435193765582340098>"
+ bb_message: "<:bbmessage:472476937504423936>"
status_online: "<:status_online:470326272351010816>"
status_idle: "<:status_idle:470326266625785866>"
@@ -41,7 +42,7 @@ style:
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/472475292078964738.png"
+ defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.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/472472638342561793.png"
@@ -67,11 +68,19 @@ style:
user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png"
user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png"
- user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png"
- user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png"
+ user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png"
+ user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png"
+ user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png"
+
+ user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png"
pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png"
+ remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png"
+ remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png"
+ remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png"
+
+ questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png"
guild:
id: 267624335836053506
@@ -124,7 +133,7 @@ guild:
filter:
# What do we filter?
- filter_zalgo: true
+ filter_zalgo: false
filter_invites: true
filter_domains: true
watch_words: true
@@ -213,7 +222,7 @@ urls:
site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"]
site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"]
site_facts_api: !JOIN [*SCHEMA, *API, "/bot/snake_facts"]
- site_hiphopify_api: !JOIN [*SCHEMA, *API, "/bot/hiphopify"]
+ site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"]
site_idioms_api: !JOIN [*SCHEMA, *API, "/bot/snake_idioms"]
site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"]
site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"]
@@ -225,6 +234,8 @@ urls:
site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"]
site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"]
+ site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
+ site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"]
site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"]
site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"]
@@ -281,7 +292,7 @@ anti_spam:
links:
interval: 10
- max: 20
+ max: 10
mentions:
interval: 10
@@ -300,3 +311,19 @@ reddit:
request_delay: 60
subreddits:
- 'r/Python'
+
+
+wolfram:
+ # Max requests per day.
+ user_limit_day: 10
+ guild_limit_day: 67
+ key: !ENV "WOLFRAM_API_KEY"
+
+
+big_brother:
+ log_delay: 15
+ header_message_limit: 15
+
+
+config:
+ required_keys: ['bot.token']
diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile
index de2c68c13..e46db756a 100644
--- a/docker/base.Dockerfile
+++ b/docker/base.Dockerfile
@@ -8,20 +8,10 @@ RUN apk add --update jpeg-dev
RUN apk add --update libxml2 libxml2-dev libxslt-dev
RUN apk add --update zlib-dev
RUN apk add --update freetype-dev
-
-RUN pip install pipenv
-
-RUN mkdir /bot
-COPY Pipfile /bot
-COPY Pipfile.lock /bot
-WORKDIR /bot
+RUN apk add --update git
ENV LIBRARY_PATH=/lib:/usr/lib
ENV PIPENV_VENV_IN_PROJECT=1
ENV PIPENV_IGNORE_VIRTUALENVS=1
ENV PIPENV_NOSPIN=1
ENV PIPENV_HIDE_EMOJIS=1
-
-RUN pipenv install --deploy --system
-
-# usage: FROM pythondiscord/bot-base:latest
diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile
index 4713e1f0e..5a07a612b 100644
--- a/docker/bot.Dockerfile
+++ b/docker/bot.Dockerfile
@@ -5,10 +5,13 @@ ENV PIPENV_IGNORE_VIRTUALENVS=1
ENV PIPENV_NOSPIN=1
ENV PIPENV_HIDE_EMOJIS=1
+RUN pip install -U pipenv
+
+RUN mkdir -p /bot
COPY . /bot
WORKDIR /bot
-RUN pipenv install --deploy --system
+RUN pipenv install --deploy
ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["python", "-m", "bot"]
+CMD ["pipenv", "run", "start"]
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
new file mode 100644
index 000000000..6b3dea508
--- /dev/null
+++ b/scripts/deploy-azure.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+cd ..
+
+# Build and deploy on master branch, only if not a pull request
+if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
+ changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l)
+
+ if [ $changed_lines != '0' ]; then
+ echo "base.Dockerfile was changed"
+
+ echo "Building bot base"
+ docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile .
+
+ echo "Pushing image to Docker Hub"
+ docker push pythondiscord/bot-base:latest
+ else
+ echo "base.Dockerfile was not changed, not building"
+ fi
+
+ echo "Building image"
+ docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile .
+
+ echo "Pushing image"
+ docker push pythondiscord/bot:latest
+
+ echo "Deploying container"
+ curl -H "token: $1" $2
+else
+ echo "Skipping deploy"
+fi \ No newline at end of file
diff --git a/tox.ini b/tox.ini
index fb2176741..c6fa513f4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
[flake8]
max-line-length=120
application_import_names=bot
-exclude=.venv
+exclude=.cache,.venv
ignore=B311,W503,E226,S311
import-order-style=pycharm