aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Akarys42 <[email protected]>2019-09-23 16:16:10 +0200
committerGravatar Akarys42 <[email protected]>2019-09-23 16:16:10 +0200
commitf4f3b2a15ecdce5291ef2d2d98b0af6d77fbc228 (patch)
tree250036c9d5162c6ee62d1a7bd6c999a03a2caad5
parentChange log.error to log.exception (diff)
parentMerge branch 'master' of https://github.com/python-discord/bot into python-di... (diff)
Merge branch 'python-discord-master'
-rw-r--r--.gitignore3
-rw-r--r--.pre-commit-config.yaml18
-rw-r--r--CONTRIBUTING.md3
-rw-r--r--Dockerfile27
-rw-r--r--Pipfile24
-rw-r--r--Pipfile.lock554
-rw-r--r--azure-pipelines.yml28
-rw-r--r--bot/__init__.py4
-rw-r--r--bot/__main__.py39
-rw-r--r--bot/api.py180
-rw-r--r--bot/cogs/alias.py145
-rw-r--r--bot/cogs/antispam.py225
-rw-r--r--bot/cogs/bigbrother.py501
-rw-r--r--bot/cogs/bot.py107
-rw-r--r--bot/cogs/clean.py106
-rw-r--r--bot/cogs/cogs.py65
-rw-r--r--bot/cogs/defcon.py258
-rw-r--r--bot/cogs/deployment.py90
-rw-r--r--bot/cogs/doc.py285
-rw-r--r--bot/cogs/error_handler.py148
-rw-r--r--bot/cogs/eval.py26
-rw-r--r--bot/cogs/events.py311
-rw-r--r--bot/cogs/filtering.py77
-rw-r--r--bot/cogs/free.py11
-rw-r--r--bot/cogs/fun.py53
-rw-r--r--bot/cogs/help.py405
-rw-r--r--bot/cogs/information.py105
-rw-r--r--bot/cogs/jams.py36
-rw-r--r--bot/cogs/logging.py22
-rw-r--r--bot/cogs/moderation.py816
-rw-r--r--bot/cogs/modlog.py176
-rw-r--r--bot/cogs/off_topic_names.py133
-rw-r--r--bot/cogs/reddit.py85
-rw-r--r--bot/cogs/reminders.py328
-rw-r--r--bot/cogs/rmq.py229
-rw-r--r--bot/cogs/rules.py104
-rw-r--r--bot/cogs/security.py21
-rw-r--r--bot/cogs/site.py81
-rw-r--r--bot/cogs/snekbox.py304
-rw-r--r--bot/cogs/superstarify.py285
-rw-r--r--bot/cogs/superstarify/__init__.py269
-rw-r--r--bot/cogs/superstarify/stars.py87
-rw-r--r--bot/cogs/sync/__init__.py13
-rw-r--r--bot/cogs/sync/cog.py200
-rw-r--r--bot/cogs/sync/syncers.py234
-rw-r--r--bot/cogs/tags.py305
-rw-r--r--bot/cogs/token_remover.py28
-rw-r--r--bot/cogs/utils.py48
-rw-r--r--bot/cogs/verification.py64
-rw-r--r--bot/cogs/watchchannels/__init__.py18
-rw-r--r--bot/cogs/watchchannels/bigbrother.py100
-rw-r--r--bot/cogs/watchchannels/talentpool.py231
-rw-r--r--bot/cogs/watchchannels/watchchannel.py357
-rw-r--r--bot/cogs/wolfram.py106
-rw-r--r--bot/constants.py36
-rw-r--r--bot/converters.py94
-rw-r--r--bot/decorators.py68
-rw-r--r--bot/interpreter.py14
-rw-r--r--bot/pagination.py169
-rw-r--r--bot/patches/__init__.py6
-rw-r--r--bot/patches/message_edited_at.py32
-rw-r--r--bot/rules/attachments.py12
-rw-r--r--bot/rules/burst.py8
-rw-r--r--bot/rules/burst_shared.py8
-rw-r--r--bot/rules/chars.py8
-rw-r--r--bot/rules/discord_emojis.py8
-rw-r--r--bot/rules/duplicates.py8
-rw-r--r--bot/rules/links.py8
-rw-r--r--bot/rules/mentions.py8
-rw-r--r--bot/rules/newlines.py8
-rw-r--r--bot/rules/role_mentions.py8
-rw-r--r--bot/utils/__init__.py46
-rw-r--r--bot/utils/checks.py20
-rw-r--r--bot/utils/messages.py85
-rw-r--r--bot/utils/moderation.py79
-rw-r--r--bot/utils/scheduling.py49
-rw-r--r--bot/utils/service_discovery.py22
-rw-r--r--bot/utils/time.py46
-rw-r--r--config-default.yml32
-rw-r--r--docker/base.Dockerfile17
-rw-r--r--docker/bot.Dockerfile17
-rw-r--r--scripts/deploy-azure.sh23
-rw-r--r--scripts/deploy.sh32
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/cogs/__init__.py0
-rw-r--r--tests/cogs/sync/__init__.py0
-rw-r--r--tests/cogs/sync/test_roles.py103
-rw-r--r--tests/cogs/sync/test_users.py69
-rw-r--r--tests/cogs/test_antispam.py30
-rw-r--r--tests/cogs/test_information.py163
-rw-r--r--tests/cogs/test_security.py54
-rw-r--r--tests/cogs/test_token_remover.py133
-rw-r--r--tests/conftest.py32
-rw-r--r--tests/helpers.py29
-rw-r--r--tests/rules/__init__.py0
-rw-r--r--tests/rules/test_attachments.py52
-rw-r--r--tests/test_api.py106
-rw-r--r--tests/test_constants.py23
-rw-r--r--tests/test_converters.py93
-rw-r--r--tests/test_pagination.py29
-rw-r--r--tests/test_resources.py18
-rw-r--r--tests/utils/__init__.py0
-rw-r--r--tests/utils/test_checks.py66
-rw-r--r--tox.ini19
104 files changed, 5537 insertions, 4931 deletions
diff --git a/.gitignore b/.gitignore
index be4f43c7f..cda3aeb9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,3 +114,6 @@ log.*
# Custom user configuration
config.yml
+
+# JUnit XML reports from pytest
+junit.xml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4776bc63b..860357868 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,10 @@
repos:
-- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.2.3
+- repo: local
hooks:
- - id: flake8
- additional_dependencies: [
- "flake8-bugbear",
- "flake8-import-order",
- "flake8-tidy-imports",
- "flake8-todo",
- "flake8-string-format"
- ] \ No newline at end of file
+ - id: flake8
+ name: Flake8
+ description: This hook runs flake8 within our project's pipenv environment.
+ entry: pipenv run lint
+ language: python
+ types: [python]
+ require_serial: true \ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6648ce1f0..a0a1200ec 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -9,6 +9,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
1. **No force-pushes** or modifying the Git history in any way.
2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there.
* It's common practice for a repository to reject direct pushes to `master`, so make branching a habit!
+ * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html).
* Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint.
* [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors.
@@ -100,6 +101,8 @@ Github [has introduced a new PR feature](https://github.blog/2019-02-14-introduc
This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
+As stated earlier, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process.
+
## Footnotes
This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md).
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..aa6333380
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,27 @@
+FROM python:3.7-alpine3.7
+
+RUN apk add --no-cache \
+ build-base \
+ freetype-dev \
+ git \
+ jpeg-dev \
+ libffi-dev \
+ libxml2 \
+ libxml2-dev \
+ libxslt-dev \
+ tini \
+ zlib \
+ zlib-dev
+
+ENV \
+ LIBRARY_PATH=/lib:/usr/lib
+
+RUN pip install -U pipenv
+
+WORKDIR /bot
+COPY . .
+
+RUN pipenv install --deploy --system
+
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["python3", "-m", "bot"]
diff --git a/Pipfile b/Pipfile
index 494a8a6ff..6a58054c1 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
-discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true}
+discord-py = "~=1.2"
aiodns = "*"
logmatic-python = "*"
aiohttp = "*"
@@ -17,21 +17,27 @@ aio-pika = "*"
python-dateutil = "*"
deepdiff = "*"
requests = "*"
+dateparser = "*"
+more_itertools = "~=7.2"
urllib3 = ">=1.24.2,<1.25"
[dev-packages]
-"flake8" = ">=3.6"
-"flake8-bugbear" = "*"
-"flake8-import-order" = "*"
-"flake8-tidy-imports" = "*"
-"flake8-todo" = "*"
-"flake8-string-format" = "*"
+flake8 = "~=3.7"
+flake8-annotations = "~=1.0"
+flake8-bugbear = "~=19.8"
+flake8-docstrings = "~=1.4"
+flake8-import-order = "~=0.18"
+flake8-string-format = "~=0.2"
+flake8-tidy-imports = "~=2.0"
+flake8-todo = "~=0.7"
+pre-commit = "~=1.18"
safety = "*"
dodgy = "*"
-pre-commit = "*"
+pytest = "*"
+pytest-cov = "*"
[requires]
-python_version = "3.6"
+python_version = "3.7"
[scripts]
start = "python -m bot"
diff --git a/Pipfile.lock b/Pipfile.lock
index 735d7cd96..9bdcff923 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
- "sha256": "ab3b63b74dbf35fb960913a91e10282121a2776e935d98f0b4c3d780715f7a6b"
+ "sha256": "29aaaa90a070d544e5b39fb6033410daa9bb7f658077205e44099f3175f6822b"
},
"pipfile-spec": 6,
"requires": {
- "python_version": "3.6"
+ "python_version": "3.7"
},
"sources": [
{
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a",
- "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b"
+ "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26",
+ "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50"
],
"index": "pypi",
- "version": "==5.5.2"
+ "version": "==6.1.2"
},
"aiodns": {
"hashes": [
@@ -34,38 +34,38 @@
},
"aiohttp": {
"hashes": [
- "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"
- ],
- "index": "pypi",
- "version": "==3.4.4"
+ "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55",
+ "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed",
+ "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10",
+ "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5",
+ "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1",
+ "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939",
+ "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390",
+ "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa",
+ "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc",
+ "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5",
+ "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d",
+ "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf",
+ "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6",
+ "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72",
+ "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12",
+ "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366",
+ "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4",
+ "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300",
+ "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d",
+ "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303",
+ "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6",
+ "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889"
+ ],
+ "index": "pypi",
+ "version": "==3.5.4"
},
"aiormq": {
"hashes": [
- "sha256:79b41e51481fb7617279414e4428a644a944beb4dea8ea0febd67a8902976250",
- "sha256:f134cc91ac111b0135c97539272579b1d15b69f25c75a935f6ee39e5194df231"
+ "sha256:0b755b748d87d5ec63b4b7f162102333bf0901caf1f8a2bf29467bbdd54e637d",
+ "sha256:f8eef1f98bc331a266404d925745fac589dab30412688564d740754d6d643863"
],
- "version": "==2.5.2"
+ "version": "==2.7.5"
},
"alabaster": {
"hashes": [
@@ -90,25 +90,25 @@
},
"babel": {
"hashes": [
- "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
- "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
+ "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
+ "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
],
- "version": "==2.6.0"
+ "version": "==2.7.0"
},
"beautifulsoup4": {
"hashes": [
- "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858",
- "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348",
- "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718"
+ "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612",
+ "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b",
+ "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469"
],
- "version": "==4.7.1"
+ "version": "==4.8.0"
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
- "version": "==2019.3.9"
+ "version": "==2019.9.11"
},
"cffi": {
"hashes": [
@@ -150,29 +150,36 @@
],
"version": "==3.0.4"
},
+ "dateparser": {
+ "hashes": [
+ "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665",
+ "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b"
+ ],
+ "index": "pypi",
+ "version": "==0.7.2"
+ },
"deepdiff": {
"hashes": [
- "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56",
- "sha256:856966b80109df002a1ee406ba21cd66e64746167b2ea8f5353d692762326ac9"
+ "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476",
+ "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127"
],
"index": "pypi",
- "version": "==4.0.6"
+ "version": "==4.0.7"
},
"discord-py": {
- "editable": true,
- "extras": [
- "voice"
+ "hashes": [
+ "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
],
- "git": "https://github.com/Rapptz/discord.py.git",
- "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb"
+ "index": "pypi",
+ "version": "==1.2.3"
},
"docutils": {
"hashes": [
- "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
- "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
- "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
+ "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
+ "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
+ "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
],
- "version": "==0.14"
+ "version": "==0.15.2"
},
"fuzzywuzzy": {
"hashes": [
@@ -205,10 +212,10 @@
},
"jsonpickle": {
"hashes": [
- "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0",
- "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1"
+ "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2",
+ "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b"
],
- "version": "==1.1"
+ "version": "==1.2"
},
"logmatic-python": {
"hashes": [
@@ -219,35 +226,31 @@
},
"lxml": {
"hashes": [
- "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5",
- "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f",
- "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b",
- "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616",
- "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b",
- "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294",
- "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3",
- "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90",
- "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e",
- "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314",
- "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f",
- "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d",
- "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8",
- "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3",
- "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33",
- "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317",
- "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63",
- "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd",
- "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f",
- "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a",
- "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f",
- "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22",
- "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d",
- "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b",
- "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f",
- "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86"
- ],
- "index": "pypi",
- "version": "==4.3.3"
+ "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4",
+ "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc",
+ "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1",
+ "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046",
+ "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36",
+ "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5",
+ "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d",
+ "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916",
+ "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0",
+ "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27",
+ "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc",
+ "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7",
+ "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38",
+ "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5",
+ "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832",
+ "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a",
+ "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f",
+ "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9",
+ "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692",
+ "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84",
+ "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79",
+ "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681"
+ ],
+ "index": "pypi",
+ "version": "==4.4.1"
},
"markdownify": {
"hashes": [
@@ -289,6 +292,14 @@
],
"version": "==1.1.1"
},
+ "more-itertools": {
+ "hashes": [
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
"multidict": {
"hashes": [
"sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
@@ -331,10 +342,10 @@
},
"packaging": {
"hashes": [
- "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
- "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
+ "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9",
+ "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"
],
- "version": "==19.0"
+ "version": "==19.1"
},
"pamqp": {
"hashes": [
@@ -369,53 +380,17 @@
},
"pygments": {
"hashes": [
- "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
- "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
- ],
- "version": "==2.3.1"
- },
- "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"
+ "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
+ "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
+ ],
+ "version": "==2.4.2"
},
"pyparsing": {
"hashes": [
- "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a",
- "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"
+ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
+ "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
],
- "version": "==2.4.0"
+ "version": "==2.4.2"
},
"python-dateutil": {
"hashes": [
@@ -433,35 +408,53 @@
},
"pytz": {
"hashes": [
- "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda",
- "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"
+ "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32",
+ "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7"
],
- "version": "==2019.1"
+ "version": "==2019.2"
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
+ "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
+ "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
+ "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
+ "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
+ "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
+ "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
+ "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
+ "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
+ "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
+ "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
+ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
+ "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
],
"index": "pypi",
- "version": "==5.1"
+ "version": "==5.1.2"
+ },
+ "regex": {
+ "hashes": [
+ "sha256:1e9f9bc44ca195baf0040b1938e6801d2f3409661c15fe57f8164c678cfc663f",
+ "sha256:587b62d48ca359d2d4f02d486f1f0aa9a20fbaf23a9d4198c4bed72ab2f6c849",
+ "sha256:835ccdcdc612821edf132c20aef3eaaecfb884c9454fdc480d5887562594ac61",
+ "sha256:93f6c9da57e704e128d90736430c5c59dd733327882b371b0cae8833106c2a21",
+ "sha256:a46f27d267665016acb3ec8c6046ec5eae8cf80befe85ba47f43c6f5ec636dcd",
+ "sha256:c5c8999b3a341b21ac2c6ec704cfcccbc50f1fedd61b6a8ee915ca7fd4b0a557",
+ "sha256:d4d1829cf97632673aa49f378b0a2c3925acd795148c5ace8ef854217abbee89",
+ "sha256:d96479257e8e4d1d7800adb26bf9c5ca5bab1648a1eddcac84d107b73dc68327",
+ "sha256:f20f4912daf443220436759858f96fefbfc6c6ba9e67835fd6e4e9b73582791a",
+ "sha256:f2b37b5b2c2a9d56d9e88efef200ec09c36c7f323f9d58d0b985a90923df386d",
+ "sha256:fe765b809a1f7ce642c2edeee351e7ebd84391640031ba4b60af8d91a9045890"
+ ],
+ "version": "==2019.8.19"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"six": {
"hashes": [
@@ -472,25 +465,24 @@
},
"snowballstemmer": {
"hashes": [
- "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
- "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
+ "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e"
],
- "version": "==1.2.1"
+ "version": "==1.9.1"
},
"soupsieve": {
"hashes": [
- "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece",
- "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca"
+ "sha256:8662843366b8d8779dec4e2f921bebec9afd856a5ff2e82cd419acc5054a1a92",
+ "sha256:a5a6166b4767725fd52ae55fee8c8b6137d9a51e9f1edea461a062a759160118"
],
- "version": "==1.9.1"
+ "version": "==1.9.3"
},
"sphinx": {
"hashes": [
- "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b",
- "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce"
+ "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845",
+ "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069"
],
"index": "pypi",
- "version": "==2.0.1"
+ "version": "==2.2.0"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -534,13 +526,20 @@
],
"version": "==1.1.3"
},
+ "tzlocal": {
+ "hashes": [
+ "sha256:11c9f16e0a633b4b60e1eede97d8a46340d042e67b670b290ca526576e039048",
+ "sha256:949b9dd5ba4be17190a80c0268167d7e6c92c62b30026cf9764caf3e308e5590"
+ ],
+ "version": "==2.0.0"
+ },
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
],
"index": "pypi",
- "version": "==1.24.2"
+ "version": "==1.24.3"
},
"websockets": {
"hashes": [
@@ -588,10 +587,17 @@
"develop": {
"aspy.yaml": {
"hashes": [
- "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3",
- "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482"
+ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc",
+ "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"
],
- "version": "==1.2.0"
+ "version": "==1.3.0"
+ },
+ "atomicwrites": {
+ "hashes": [
+ "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
+ "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
+ ],
+ "version": "==1.3.0"
},
"attrs": {
"hashes": [
@@ -602,17 +608,17 @@
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
- "version": "==2019.3.9"
+ "version": "==2019.9.11"
},
"cfgv": {
"hashes": [
- "sha256:6e9f2feea5e84bc71e56abd703140d7a2c250fc5ba38b8702fd6a68ed4e3b2ef",
- "sha256:e7f186d4a36c099a9e20b04ac3108bd8bb9b9257e692ce18c8c3764d5cb12172"
+ "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144",
+ "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289"
],
- "version": "==1.6.0"
+ "version": "==2.0.1"
},
"chardet": {
"hashes": [
@@ -628,6 +634,43 @@
],
"version": "==7.0"
},
+ "coverage": {
+ "hashes": [
+ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6",
+ "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650",
+ "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5",
+ "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d",
+ "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351",
+ "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755",
+ "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef",
+ "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca",
+ "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca",
+ "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9",
+ "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc",
+ "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5",
+ "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f",
+ "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe",
+ "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888",
+ "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5",
+ "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce",
+ "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5",
+ "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e",
+ "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e",
+ "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9",
+ "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437",
+ "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1",
+ "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c",
+ "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24",
+ "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47",
+ "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2",
+ "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28",
+ "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c",
+ "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7",
+ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0",
+ "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"
+ ],
+ "version": "==4.5.4"
+ },
"dodgy": {
"hashes": [
"sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c"
@@ -651,19 +694,35 @@
},
"flake8": {
"hashes": [
- "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
- "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
+ "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
+ "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
+ ],
+ "index": "pypi",
+ "version": "==3.7.8"
+ },
+ "flake8-annotations": {
+ "hashes": [
+ "sha256:1309f2bc9853a2d77d578b089d331b0b832b40c97932641e136e1b49d3650c82",
+ "sha256:3ecdd27054c3eed6484139025698465e3c9f4e68dbd5043d0204fcb2550ee27b"
],
"index": "pypi",
- "version": "==3.7.7"
+ "version": "==1.0.0"
},
"flake8-bugbear": {
"hashes": [
- "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb",
- "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d"
+ "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571",
+ "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8"
+ ],
+ "index": "pypi",
+ "version": "==19.8.0"
+ },
+ "flake8-docstrings": {
+ "hashes": [
+ "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd",
+ "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06"
],
"index": "pypi",
- "version": "==19.3.0"
+ "version": "==1.4.0"
},
"flake8-import-order": {
"hashes": [
@@ -698,10 +757,10 @@
},
"identify": {
"hashes": [
- "sha256:443f419ca6160773cbaf22dbb302b1e436a386f23129dbb5482b68a147c2eca9",
- "sha256:bd7f15fe07112b713fb68fbdde3a34dd774d9062128f2c398104889f783f989d"
+ "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017",
+ "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e"
],
- "version": "==1.4.2"
+ "version": "==1.4.7"
},
"idna": {
"hashes": [
@@ -712,10 +771,11 @@
},
"importlib-metadata": {
"hashes": [
- "sha256:46fc60c34b6ed7547e2a723fc8de6dc2e3a1173f8423246b3ce497f064e9c3de",
- "sha256:bc136180e961875af88b1ab85b4009f4f1278f8396a60526c0009f503a1a96ca"
+ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
+ "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
],
- "version": "==0.9"
+ "markers": "python_version < '3.8'",
+ "version": "==0.23"
},
"mccabe": {
"hashes": [
@@ -724,6 +784,14 @@
],
"version": "==0.6.1"
},
+ "more-itertools": {
+ "hashes": [
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
"nodeenv": {
"hashes": [
"sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
@@ -732,18 +800,32 @@
},
"packaging": {
"hashes": [
- "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
- "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
+ "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9",
+ "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"
],
- "version": "==19.0"
+ "version": "==19.1"
+ },
+ "pluggy": {
+ "hashes": [
+ "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
+ "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
+ ],
+ "version": "==0.13.0"
},
"pre-commit": {
"hashes": [
- "sha256:2576a2776098f3902ef9540a84696e8e06bf18a337ce43a6a889e7fa5d26c4c5",
- "sha256:82f2f2d657d7f9280de9f927ae56886d60b9ef7f3714eae92d12713cd9cb9e11"
+ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f",
+ "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a"
],
"index": "pypi",
- "version": "==1.15.2"
+ "version": "==1.18.3"
+ },
+ "py": {
+ "hashes": [
+ "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
+ "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
+ ],
+ "version": "==1.8.0"
},
"pycodestyle": {
"hashes": [
@@ -752,6 +834,13 @@
],
"version": "==2.5.0"
},
+ "pydocstyle": {
+ "hashes": [
+ "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058",
+ "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59"
+ ],
+ "version": "==4.0.1"
+ },
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
@@ -761,35 +850,53 @@
},
"pyparsing": {
"hashes": [
- "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a",
- "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"
+ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
+ "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
],
- "version": "==2.4.0"
+ "version": "==2.4.2"
+ },
+ "pytest": {
+ "hashes": [
+ "sha256:95d13143cc14174ca1a01ec68e84d76ba5d9d493ac02716fd9706c949a505210",
+ "sha256:b78fe2881323bd44fd9bd76e5317173d4316577e7b1cddebae9136a4495ec865"
+ ],
+ "index": "pypi",
+ "version": "==5.1.2"
+ },
+ "pytest-cov": {
+ "hashes": [
+ "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6",
+ "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"
+ ],
+ "index": "pypi",
+ "version": "==2.7.1"
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
+ "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
+ "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
+ "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
+ "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
+ "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
+ "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
+ "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
+ "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
+ "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
+ "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
+ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
+ "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
],
"index": "pypi",
- "version": "==5.1"
+ "version": "==5.1.2"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"safety": {
"hashes": [
@@ -806,6 +913,12 @@
],
"version": "==1.12.0"
},
+ "snowballstemmer": {
+ "hashes": [
+ "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e"
+ ],
+ "version": "==1.9.1"
+ },
"toml": {
"hashes": [
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
@@ -815,25 +928,32 @@
},
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
],
"index": "pypi",
- "version": "==1.24.2"
+ "version": "==1.24.3"
},
"virtualenv": {
"hashes": [
- "sha256:15ee248d13e4001a691d9583948ad3947bcb8a289775102e4c4aa98a8b7a6d73",
- "sha256:bfc98bb9b42a3029ee41b96dc00a34c2f254cbf7716bec824477b2c82741a5c4"
+ "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
+ "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
],
- "version": "==16.5.0"
+ "version": "==16.7.5"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
+ "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
+ ],
+ "version": "==0.1.7"
},
"zipp": {
"hashes": [
- "sha256:139391b239594fd8b91d856bc530fbd2df0892b17dd8d98a91f018715954185f",
- "sha256:8047e4575ce8d700370a3301bbfc972896a5845eb62dd535da395b86be95dfad"
+ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
+ "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
],
- "version": "==0.4.0"
+ "version": "==0.6.0"
}
}
}
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index a14364881..4dcad685c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -10,7 +10,7 @@ jobs:
displayName: 'Lint & Test'
pool:
- vmImage: 'Ubuntu 16.04'
+ vmImage: ubuntu-16.04
variables:
PIPENV_CACHE_DIR: ".cache/pipenv"
@@ -18,10 +18,9 @@ jobs:
PIP_SRC: ".cache/src"
steps:
- - script: sudo apt-get update
- displayName: 'Updating package list'
-
- - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev
+ - script: |
+ sudo apt-get update
+ 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
@@ -39,6 +38,23 @@ jobs:
- script: python -m flake8
displayName: 'Run linter'
+ - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests
+ displayName: Run tests
+
+ - task: PublishCodeCoverageResults@1
+ displayName: 'Publish Coverage Results'
+ condition: succeededOrFailed()
+ inputs:
+ codeCoverageTool: Cobertura
+ summaryFileLocation: coverage.xml
+
+ - task: PublishTestResults@2
+ displayName: 'Publish Test Results'
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: junit.xml
+ testRunTitle: 'Bot Test results'
+
- job: build
displayName: 'Build Containers'
dependsOn: 'test'
@@ -54,7 +70,5 @@ jobs:
- task: ShellScript@2
displayName: 'Build and deploy containers'
-
inputs:
scriptPath: scripts/deploy-azure.sh
- args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)'
diff --git a/bot/__init__.py b/bot/__init__.py
index a088138a0..d094e8c13 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -9,7 +9,7 @@ logging.TRACE = 5
logging.addLevelName(logging.TRACE, "TRACE")
-def monkeypatch_trace(self, msg, *args, **kwargs):
+def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
"""
Log 'msg % args' with severity 'TRACE'.
@@ -55,7 +55,7 @@ else:
logging.basicConfig(
- format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s",
+ format="%(asctime)s pd.beardfist.com Bot: | %(name)33s | %(levelname)8s | %(message)s",
datefmt="%b %d %H:%M:%S",
level=logging.TRACE if DEBUG_MODE else logging.INFO,
handlers=logging_handlers
diff --git a/bot/__main__.py b/bot/__main__.py
index ead6d287a..f25693734 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,21 +1,23 @@
+import asyncio
import logging
import socket
+import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord import Game
from discord.ext.commands import Bot, when_mentioned_or
+from bot import patches
+from bot.api import APIClient, APILoggingHandler
from bot.constants import Bot as BotConfig, DEBUG_MODE
-from bot.utils.service_discovery import wait_for_rmq
-log = logging.getLogger(__name__)
+log = logging.getLogger('bot')
bot = Bot(
command_prefix=when_mentioned_or(BotConfig.prefix),
- activity=Game(name="Commands: !help"),
+ activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
- max_messages=10_000
+ max_messages=10_000,
)
# Global aiohttp session for all cogs
@@ -27,18 +29,11 @@ bot.http_session = ClientSession(
family=socket.AF_INET,
)
)
-
-log.info("Waiting for RabbitMQ...")
-
-has_rmq = wait_for_rmq()
-
-if has_rmq:
- log.info("RabbitMQ found")
-else:
- log.warning("Timed out while waiting for RabbitMQ")
+bot.api_client = APIClient(loop=asyncio.get_event_loop())
+log.addHandler(APILoggingHandler(bot.api_client))
# Internal/debug
-bot.load_extension("bot.cogs.events")
+bot.load_extension("bot.cogs.error_handler")
bot.load_extension("bot.cogs.filtering")
bot.load_extension("bot.cogs.logging")
bot.load_extension("bot.cogs.modlog")
@@ -46,12 +41,10 @@ bot.load_extension("bot.cogs.security")
# Commands, etc
bot.load_extension("bot.cogs.antispam")
-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")
-bot.load_extension("bot.cogs.rules")
# Only load this in production
if not DEBUG_MODE:
@@ -61,10 +54,8 @@ if not DEBUG_MODE:
# Feature cogs
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.free")
-bot.load_extension("bot.cogs.fun")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
@@ -74,14 +65,18 @@ bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
bot.load_extension("bot.cogs.snekbox")
bot.load_extension("bot.cogs.superstarify")
+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.watchchannels")
bot.load_extension("bot.cogs.wolfram")
-if has_rmq:
- bot.load_extension("bot.cogs.rmq")
+# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
+if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
+ patches.message_edited_at.apply_patch()
bot.run(BotConfig.token)
-bot.http_session.close() # Close the aiohttp session when the bot finishes running
+# This calls a coroutine, so it doesn't do anything at the moment.
+# bot.http_session.close() # Close the aiohttp session when the bot finishes running
diff --git a/bot/api.py b/bot/api.py
new file mode 100644
index 000000000..7f26e5305
--- /dev/null
+++ b/bot/api.py
@@ -0,0 +1,180 @@
+import asyncio
+import logging
+from typing import Optional
+from urllib.parse import quote as quote_url
+
+import aiohttp
+
+from .constants import Keys, URLs
+
+log = logging.getLogger(__name__)
+
+
+class ResponseCodeError(ValueError):
+ """Raised when a non-OK HTTP response is received."""
+
+ def __init__(
+ self,
+ response: aiohttp.ClientResponse,
+ response_json: Optional[dict] = None,
+ response_text: str = ""
+ ):
+ self.status = response.status
+ self.response_json = response_json or {}
+ self.response_text = response_text
+ self.response = response
+
+ def __str__(self):
+ response = self.response_json if self.response_json else self.response_text
+ return f"Status: {self.status} Response: {response}"
+
+
+class APIClient:
+ """Django Site API wrapper."""
+
+ def __init__(self, **kwargs):
+ auth_headers = {
+ 'Authorization': f"Token {Keys.site_api}"
+ }
+
+ if 'headers' in kwargs:
+ kwargs['headers'].update(auth_headers)
+ else:
+ kwargs['headers'] = auth_headers
+
+ self.session = aiohttp.ClientSession(**kwargs)
+
+ @staticmethod
+ def _url_for(endpoint: str) -> str:
+ return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}"
+
+ async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
+ """Raise ResponseCodeError for non-OK response if an exception should be raised."""
+ if should_raise and response.status >= 400:
+ try:
+ response_json = await response.json()
+ raise ResponseCodeError(response=response, response_json=response_json)
+ except aiohttp.ContentTypeError:
+ response_text = await response.text()
+ raise ResponseCodeError(response=response, response_text=response_text)
+
+ async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API GET."""
+ async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
+ return await resp.json()
+
+ async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API PATCH."""
+ async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
+ return await resp.json()
+
+ async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API POST."""
+ async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
+ return await resp.json()
+
+ async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API PUT."""
+ async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
+ return await resp.json()
+
+ async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]:
+ """Site API DELETE."""
+ async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp:
+ if resp.status == 204:
+ return None
+
+ await self.maybe_raise_for_status(resp, raise_for_status)
+ return await resp.json()
+
+
+def loop_is_running() -> bool:
+ """
+ Determine if there is a running asyncio event loop.
+
+ This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`),
+ which is currently not provided by asyncio.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return False
+ return True
+
+
+class APILoggingHandler(logging.StreamHandler):
+ """Site API logging handler."""
+
+ def __init__(self, client: APIClient):
+ logging.StreamHandler.__init__(self)
+ self.client = client
+
+ # internal batch of shipoff tasks that must not be scheduled
+ # on the event loop yet - scheduled when the event loop is ready.
+ self.queue = []
+
+ async def ship_off(self, payload: dict) -> None:
+ """Ship log payload to the logging API."""
+ try:
+ await self.client.post('logs', json=payload)
+ except ResponseCodeError as err:
+ log.warning(
+ "Cannot send logging record to the site, got code %d.",
+ err.response.status,
+ extra={'via_handler': True}
+ )
+ except Exception as err:
+ log.warning(
+ "Cannot send logging record to the site: %r",
+ err,
+ extra={'via_handler': True}
+ )
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """
+ Determine if a log record should be shipped to the logging API.
+
+ If the asyncio event loop is not yet running, log records will instead be put in a queue
+ which will be consumed once the event loop is running.
+
+ The following two conditions are set:
+ 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level)
+ 2. Ignore log records originating from this logging handler itself to prevent infinite recursion
+ """
+ if (
+ record.levelno >= logging.DEBUG
+ and not record.__dict__.get('via_handler')
+ ):
+ payload = {
+ 'application': 'bot',
+ 'logger_name': record.name,
+ 'level': record.levelname.lower(),
+ 'module': record.module,
+ 'line': record.lineno,
+ 'message': self.format(record)
+ }
+
+ task = self.ship_off(payload)
+ if not loop_is_running():
+ self.queue.append(task)
+ else:
+ asyncio.create_task(task)
+ self.schedule_queued_tasks()
+
+ def schedule_queued_tasks(self) -> None:
+ """Consume the queue and schedule the logging of each queued record."""
+ for task in self.queue:
+ asyncio.create_task(task)
+
+ if self.queue:
+ log.debug(
+ "Scheduled %d pending logging tasks.",
+ len(self.queue),
+ extra={'via_handler': True}
+ )
+
+ self.queue.clear()
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index bf40fe409..80ff37983 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -1,37 +1,25 @@
import inspect
import logging
+from typing import Union
-from discord import Colour, Embed, User
-from discord.ext.commands import (
- Command, Context, clean_content, command, group
-)
+from discord import Colour, Embed, Member, User
+from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group
+from bot.cogs.watchchannels.watchchannel import proxy_user
from bot.converters import TagNameConverter
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Alias:
- """
- Aliases for more used commands
- """
+class Alias (Cog):
+ """Aliases for commonly used commands."""
- def __init__(self, bot):
+ def __init__(self, bot: 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
- """
-
+ async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None:
+ """Invokes a command with args and kwargs."""
log.debug(f"{cmd_name} was invoked through an alias")
cmd = self.bot.get_command(cmd_name)
if not cmd:
@@ -44,9 +32,8 @@ class Alias:
await ctx.invoke(cmd, *args, **kwargs)
@command(name='aliases')
- async def aliases_command(self, ctx):
+ async def aliases_command(self, ctx: Context) -> None:
"""Show configured aliases on the bot."""
-
embed = Embed(
title='Configured aliases',
colour=Colour.blue()
@@ -62,118 +49,98 @@ class Alias:
)
@command(name="resources", aliases=("resource",), hidden=True)
- async def site_resources_alias(self, ctx):
- """
- Alias for invoking <prefix>site resources.
- """
-
+ async def site_resources_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site resources."""
await self.invoke(ctx, "site resources")
@command(name="watch", hidden=True)
- async def bigbrother_watch_alias(
- self, ctx: Context, user: User, *, reason: str
- ):
- """
- Alias for invoking <prefix>bigbrother watch [user] [reason].
- """
-
+ async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>bigbrother watch [user] [reason]."""
await self.invoke(ctx, "bigbrother watch", user, reason=reason)
@command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx, user: User, *, reason: str):
- """
- Alias for invoking <prefix>bigbrother unwatch [user] [reason].
- """
-
+ async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
@command(name="home", hidden=True)
- async def site_home_alias(self, ctx):
- """
- Alias for invoking <prefix>site home.
- """
-
+ async def site_home_alias(self, ctx: Context) -> None:
+ """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.
- """
-
+ async def site_faq_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site faq."""
await self.invoke(ctx, "site faq")
- @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.
- """
+ @command(name="rules", hidden=True)
+ async def site_rules_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site rules."""
+ await self.invoke(ctx, "site rules")
+ @command(name="reload", hidden=True)
+ async def cogs_reload_alias(self, ctx: Context, *, cog_name: str) -> None:
+ """Alias for invoking <prefix>cogs reload [cog_name]."""
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.
- """
-
+ async def defcon_enable_alias(self, ctx: Context) -> None:
+ """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.
- """
-
+ async def defcon_disable_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>defcon disable."""
await self.invoke(ctx, "defcon disable")
@command(name="exception", hidden=True)
- async def tags_get_traceback_alias(self, ctx):
- """
- Alias for invoking <prefix>tags get traceback.
- """
-
- await self.invoke(ctx, "tags get traceback")
+ async def tags_get_traceback_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tags get traceback."""
+ await self.invoke(ctx, "tags get", tag_name="traceback")
@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`.
- """
-
+ async def get_group_alias(self, ctx: Context) -> None:
+ """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
- ):
+ ) -> None:
"""
Alias for invoking <prefix>tags get [tag_name].
tag_name: str - tag to be viewed.
"""
-
- await self.invoke(ctx, "tags get", tag_name)
+ await self.invoke(ctx, "tags get", tag_name=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].
+ ) -> None:
+ """Alias for invoking <prefix>docs get [symbol]."""
+ await self.invoke(ctx, "docs get", symbol)
- symbol: str - name of doc to be viewed.
- """
+ @command(name="nominate", hidden=True)
+ async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>talentpool add [user] [reason]."""
+ await self.invoke(ctx, "talentpool add", user, reason=reason)
- await self.invoke(ctx, "docs get", symbol)
+ @command(name="unnominate", hidden=True)
+ async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>nomination end [user] [reason]."""
+ await self.invoke(ctx, "nomination end", user, reason=reason)
+
+ @command(name="nominees", hidden=True)
+ async def nominees_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tp watched."""
+ await self.invoke(ctx, "talentpool watched")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Alias cog load."""
bot.add_cog(Alias(bot))
log.info("Cog loaded: Alias")
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 03551e806..7a3360436 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -1,19 +1,23 @@
+import asyncio
import logging
+from collections.abc import Mapping
+from dataclasses import dataclass, field
from datetime import datetime, timedelta
-from typing import List
+from operator import itemgetter
+from typing import Dict, Iterable, List, Set
-from discord import Colour, Member, Message, Object, TextChannel
-from discord.ext.commands import Bot
+from discord import Colour, Member, Message, NotFound, Object, TextChannel
+from discord.ext.commands import Bot, Cog
from bot import rules
-from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
Colours, DEBUG_MODE, Event, Filter,
Guild as GuildConfig, Icons,
- Roles, STAFF_ROLES,
+ STAFF_ROLES,
)
+from bot.converters import ExpirationDate
log = logging.getLogger(__name__)
@@ -32,20 +36,104 @@ RULE_FUNCTION_MAPPING = {
}
-class AntiSpam:
- def __init__(self, bot: Bot):
+@dataclass
+class DeletionContext:
+ """Represents a Deletion Context for a single spam event."""
+
+ channel: TextChannel
+ members: Dict[int, Member] = field(default_factory=dict)
+ rules: Set[str] = field(default_factory=set)
+ messages: Dict[int, Message] = field(default_factory=dict)
+
+ def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
+ """Adds new rule violation events to the deletion context."""
+ self.rules.add(rule_name)
+
+ for member in members:
+ if member.id not in self.members:
+ self.members[member.id] = member
+
+ for message in messages:
+ if message.id not in self.messages:
+ self.messages[message.id] = message
+
+ async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
+ """Method that takes care of uploading the queue and posting modlog alert."""
+ triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values())
+
+ mod_alert_message = (
+ f"**Triggered by:** {triggered_by_users}\n"
+ f"**Channel:** {self.channel.mention}\n"
+ f"**Rules:** {', '.join(rule for rule in self.rules)}\n"
+ )
+
+ # For multiple messages or those with excessive newlines, use the logs API
+ if len(self.messages) > 1 or 'newlines' in self.rules:
+ url = await modlog.upload_log(self.messages.values(), actor_id)
+ mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
+ else:
+ mod_alert_message += "Message:\n"
+ [message] = self.messages.values()
+ content = message.clean_content
+ remaining_chars = 2040 - len(mod_alert_message)
+
+ if len(content) > remaining_chars:
+ content = content[:remaining_chars] + "..."
+
+ mod_alert_message += f"{content}"
+
+ *_, last_message = self.messages.values()
+ await modlog.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"Spam detected!",
+ text=mod_alert_message,
+ thumbnail=last_message.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=AntiSpamConfig.ping_everyone
+ )
+
+
+class AntiSpam(Cog):
+ """Cog that controls our anti-spam measures."""
+
+ def __init__(self, bot: Bot, validation_errors: bool) -> None:
self.bot = bot
- self._muted_role = Object(Roles.muted)
+ self.validation_errors = validation_errors
+ role_id = AntiSpamConfig.punishment['role_id']
+ self.muted_role = Object(role_id)
+ self.expiration_date_converter = ExpirationDate()
+
+ self.message_deletion_queue = dict()
+ self.queue_consumption_tasks = dict()
@property
def mod_log(self) -> ModLog:
+ """Allows for easy access of the ModLog cog."""
return self.bot.get_cog("ModLog")
- async def on_ready(self):
- role_id = AntiSpamConfig.punishment['role_id']
- self.muted_role = Object(role_id)
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Unloads the cog and alerts admins if configuration validation failed."""
+ if self.validation_errors:
+ body = "**The following errors were encountered:**\n"
+ body += "\n".join(f"- {error}" for error in self.validation_errors.values())
+ body += "\n\n**The cog has been unloaded.**"
+
+ await self.mod_log.send_log_message(
+ title=f"Error: AntiSpam configuration validation failed!",
+ text=body,
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Colour.red()
+ )
+
+ self.bot.remove_cog(self.__class__.__name__)
+ return
- async def on_message(self, message: Message):
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Applies the antispam rules to each received message."""
if (
not message.guild
or message.guild.id != GuildConfig.id
@@ -58,14 +146,15 @@ class AntiSpam:
# Fetch the rule configuration with the highest rule interval.
max_interval_config = max(
AntiSpamConfig.rules.values(),
- key=lambda config: config['interval']
+ key=itemgetter('interval')
)
max_interval = max_interval_config['interval']
# Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls.
earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval)
relevant_messages = [
- msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False)
+ msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False)
+ if not msg.author.bot
]
for rule_name in AntiSpamConfig.rules:
@@ -86,60 +175,53 @@ class AntiSpam:
if result is not None:
reason, members, relevant_messages = result
full_reason = f"`{rule_name}` rule: {reason}"
+
+ # If there's no spam event going on for this channel, start a new Message Deletion Context
+ if message.channel.id not in self.message_deletion_queue:
+ log.trace(f"Creating queue for channel `{message.channel.id}`")
+ self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel)
+ self.queue_consumption_tasks = self.bot.loop.create_task(
+ self._process_deletion_context(message.channel.id)
+ )
+
+ # Add the relevant of this trigger to the Deletion Context
+ self.message_deletion_queue[message.channel.id].add(
+ rule_name=rule_name,
+ members=members,
+ messages=relevant_messages
+ )
+
for member in members:
# Fire it off as a background task to ensure
# that the sleep doesn't block further tasks
self.bot.loop.create_task(
- self.punish(message, member, full_reason, relevant_messages, rule_name)
+ self.punish(message, member, full_reason)
)
await self.maybe_delete_messages(message.channel, relevant_messages)
break
- async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str):
- # Sanity check to ensure we're not lagging behind
- if self.muted_role not in member.roles:
+ async def punish(self, msg: Message, member: Member, reason: str) -> None:
+ """Punishes the given member for triggering an antispam rule."""
+ if not any(role.id == self.muted_role.id for role in member.roles):
remove_role_after = AntiSpamConfig.punishment['remove_after']
- mod_alert_message = (
- f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n"
- f"**Channel:** {msg.channel.mention}\n"
- f"**Reason:** {reason}\n"
- )
+ # We need context, let's get it
+ context = await self.bot.get_context(msg)
- # For multiple messages or those with excessive newlines, use the logs API
- if len(messages) > 1 or rule_name == 'newlines':
- url = await self.mod_log.upload_log(messages)
- mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
- else:
- mod_alert_message += "Message:\n"
- content = messages[0].clean_content
- remaining_chars = 2040 - len(mod_alert_message)
-
- if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
-
- mod_alert_message += f"{content}"
-
- # Return the mod log message Context that we can use to post the infraction
- mod_log_ctx = await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"Spam detected!",
- text=mod_alert_message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=AntiSpamConfig.ping_everyone
+ # Since we're going to invoke the tempmute command directly, we need to manually call the converter.
+ dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S")
+ await context.invoke(
+ self.bot.get_command('tempmute'),
+ member,
+ dt_remove_role_after,
+ reason=reason
)
- # Run a tempmute
- await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason)
-
- async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]):
- # Is deletion of offending messages actually enabled?
+ async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None:
+ """Cleans the messages if cleaning is configured."""
if AntiSpamConfig.clean_offending:
-
# If we have more than one message, we can use bulk delete.
if len(messages) > 1:
message_ids = [message.id for message in messages]
@@ -150,26 +232,47 @@ class AntiSpam:
# Delete the message directly instead.
else:
self.mod_log.ignore(Event.message_delete, messages[0].id)
- await messages[0].delete()
+ try:
+ await messages[0].delete()
+ except NotFound:
+ log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")
+
+ async def _process_deletion_context(self, context_id: int) -> None:
+ """Processes the Deletion Context queue."""
+ log.trace("Sleeping before processing message deletion queue.")
+ await asyncio.sleep(10)
+
+ if context_id not in self.message_deletion_queue:
+ log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!")
+ return
+ deletion_context = self.message_deletion_queue.pop(context_id)
+ await deletion_context.upload_messages(self.bot.user.id, self.mod_log)
-def validate_config():
- for name, config in AntiSpamConfig.rules.items():
+
+def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
+ """Validates the antispam configs."""
+ validation_errors = {}
+ for name, config in rules.items():
if name not in RULE_FUNCTION_MAPPING:
- raise ValueError(
+ log.error(
f"Unrecognized antispam rule `{name}`. "
f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}"
)
-
+ validation_errors[name] = f"`{name}` is not recognized as an antispam rule."
+ continue
for required_key in ('interval', 'max'):
if required_key not in config:
- raise ValueError(
+ log.error(
f"`{required_key}` is required but was not "
f"set in rule `{name}`'s configuration."
)
+ validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`"
+ return validation_errors
-def setup(bot: Bot):
- validate_config()
- bot.add_cog(AntiSpam(bot))
+def setup(bot: Bot) -> None:
+ """Antispam cog load."""
+ validation_errors = validate_config()
+ bot.add_cog(AntiSpam(bot, validation_errors))
log.info("Cog loaded: AntiSpam")
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
deleted file mode 100644
index 97655812b..000000000
--- a/bot/cogs/bigbrother.py
+++ /dev/null
@@ -1,501 +0,0 @@
-import asyncio
-import logging
-import re
-from collections import defaultdict, deque
-from time import strptime, struct_time
-from typing import List, NamedTuple, Optional, Union
-
-from aiohttp import ClientError
-from discord import Color, Embed, Guild, Member, Message, TextChannel, User, errors
-from discord.ext.commands import Bot, Context, command, group
-
-from bot.constants import (
- BigBrother as BigBrotherConfig, Channels, Emojis,
- Guild as GuildConfig, Keys,
- MODERATION_ROLES, STAFF_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
-from bot.utils.time import parse_rfc1123, time_since
-
-log = logging.getLogger(__name__)
-
-URL_RE = re.compile(r"(https?://[^\s]+)")
-
-
-class WatchInformation(NamedTuple):
- reason: str
- actor_id: Optional[int]
- inserted_at: Optional[str]
-
-
-class BigBrother:
- """User monitoring to assist with moderation."""
-
- HEADERS = {'X-API-Key': Keys.site_api}
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.watched_users = {} # { user_id: log_channel_id }
- self.watch_reasons = {} # { user_id: watch_reason }
- 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.consume_task = None
- self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons
- self.nomination_prefix = "Helper nomination: "
-
- self.bot.loop.create_task(self.get_watched_users())
-
- def update_cache(self, api_response: List[dict]):
- """
- Updates the internal cache of watched users from the given `api_response`.
- This function will only add (or update) existing keys, it will not delete
- keys that were not present in the API response.
- A user is only added if the bot can find a channel
- with the given `channel_id` in its channel cache.
- """
-
- for entry in api_response:
- user_id = int(entry['user_id'])
- channel_id = int(entry['channel_id'])
- channel = self.bot.get_channel(channel_id)
-
- if channel is not None:
- self.watched_users[user_id] = channel
- else:
- log.error(
- f"Site specified to relay messages by `{user_id}` in `{channel_id}`, "
- "but the given channel could not be found. Ignoring."
- )
-
- async def 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)
-
- async def update_watched_users(self):
- async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response:
- if response.status == 200:
- data = await response.json()
- self.update_cache(data)
- log.trace("Updated Big Brother watchlist cache")
- return True
- else:
- return False
-
- async def get_watch_information(self, user_id: int, prefix: str) -> WatchInformation:
- """ Fetches and returns the latest watch reason for a user using the infraction API """
-
- re_bb_watch = rf"^{prefix}"
- user_id = str(user_id)
-
- try:
- response = await self.bot.http_session.get(
- URLs.site_infractions_user_type.format(
- user_id=user_id,
- infraction_type="note",
- ),
- params={"search": re_bb_watch, "hidden": "True", "active": "False"},
- headers=self.HEADERS
- )
- infraction_list = await response.json()
- except ClientError:
- log.exception(f"Failed to retrieve bb watch reason for {user_id}.")
- return WatchInformation(reason="(error retrieving bb reason)", actor_id=None, inserted_at=None)
-
- if infraction_list:
- # Get the latest watch reason
- latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time)
-
- # Get the actor of the watch/nominate action
- actor_id = int(latest_reason_infraction["actor"]["user_id"])
-
- # Get the date the watch was set
- date = latest_reason_infraction["inserted_at"]
-
- # Get the latest reason without the prefix
- latest_reason = latest_reason_infraction['reason'][len(prefix):]
-
- log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}")
- return WatchInformation(reason=latest_reason, actor_id=actor_id, inserted_at=date)
-
- log.trace(f"No bb watch reason found for {user_id}; returning defaults")
- return WatchInformation(reason="(no reason specified)", actor_id=None, inserted_at=None)
-
- @staticmethod
- def _parse_infraction_time(infraction: dict) -> struct_time:
- """
- Helper function that retrieves the insertion time from the infraction dictionary,
- converts the retrieved RFC1123 date_time string to a time object, and returns it
- so infractions can be sorted by their insertion time.
- """
-
- date_string = infraction["inserted_at"]
- return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z")
-
- async def on_member_ban(self, guild: Guild, user: Union[User, Member]):
- if guild.id == GuildConfig.id and user.id in self.watched_users:
- url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
- channel = self.watched_users[user.id]
-
- async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
- del self.watched_users[user.id]
- del self.channel_queues[user.id]
- del self.watch_reasons[user.id]
- if response.status == 204:
- await channel.send(
- f"{Emojis.bb_message}:hammer: {user} got banned, so "
- f"`BigBrother` will no longer relay their messages to {channel}"
- )
-
- else:
- data = await response.json()
- reason = data.get('error_message', "no message provided")
- await channel.send(
- f"{Emojis.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:
- if not self.consuming:
- self.consume_task = self.bot.loop.create_task(self.consume_messages())
-
- if self.consuming and self.consume_task.done():
- # This should never happen, so something went wrong
-
- log.error("The consume_task has finished, but did not reset the self.consuming boolean")
- e = self.consume_task.exception()
- if e:
- log.exception("The Exception for the Task:", exc_info=e)
- else:
- log.error("However, an Exception was not found.")
-
- self.consume_task = 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.consume_task = 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:
- # Retrieve watch reason from API if it's not already in the cache
- if message.author.id not in self.watch_reasons:
- log.trace(f"No watch information for {message.author.id} found in cache; retrieving from API")
- if destination == self.bot.get_channel(Channels.talent_pool):
- prefix = self.nomination_prefix
- else:
- prefix = self.infraction_watch_prefix
- user_watch_information = await self.get_watch_information(message.author.id, prefix)
- self.watch_reasons[message.author.id] = user_watch_information
-
- self.last_log = [message.author.id, message.channel.id, 0]
-
- # Get reason, actor, inserted_at
- reason, actor_id, inserted_at = self.watch_reasons[message.author.id]
-
- # Setting up the default author_field
- author_field = message.author.nick or message.author.name
-
- # When we're dealing with a talent-pool header, add nomination info to the author field
- if destination == self.bot.get_channel(Channels.talent_pool):
- log.trace("We're sending a header to the talent-pool; let's add nomination info")
- # If a reason was provided, both should be known
- if actor_id and inserted_at:
- # Parse actor name
- guild: GuildConfig = self.bot.get_guild(GuildConfig.id)
- actor_as_member = guild.get_member(actor_id)
- actor = actor_as_member.nick or actor_as_member.name
-
- # Get time delta since insertion
- date_time = parse_rfc1123(inserted_at).replace(tzinfo=None)
- time_delta = time_since(date_time, precision="minutes", max_units=1)
-
- # Adding nomination info to author_field
- author_field = f"{author_field} (nominated {time_delta} by {actor})"
- else:
- if inserted_at:
- # Get time delta since insertion
- date_time = parse_rfc1123(inserted_at).replace(tzinfo=None)
- time_delta = time_since(date_time, precision="minutes", max_units=1)
-
- author_field = f"{author_field} (added {time_delta})"
-
- embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})")
- embed.set_author(name=author_field, icon_url=message.author.avatar_url)
- embed.set_footer(text=f"Reason: {reason}")
- await destination.send(embed=embed)
-
- @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)
-
- try:
- await messages.send_attachments(message, destination)
- except (errors.Forbidden, errors.NotFound):
- e = Embed(
- description=":x: **This message contained an attachment, but it could not be retrieved**",
- color=Color.red()
- )
- await destination.send(embed=e)
-
- async def _watch_user(self, ctx: Context, user: User, reason: str, channel_id: int):
- post_data = {
- 'user_id': str(user.id),
- 'channel_id': str(channel_id)
- }
-
- async with self.bot.http_session.post(
- URLs.site_bigbrother_api,
- headers=self.HEADERS,
- json=post_data
- ) as response:
- if response.status == 204:
- if channel_id == Channels.talent_pool:
- await ctx.send(f":ok_hand: added {user} to the <#{channel_id}>!")
- else:
- await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>")
-
- channel = self.bot.get_channel(channel_id)
- if channel is None:
- log.error(
- f"could not update internal cache, failed to find a channel with ID {channel_id}"
- )
- else:
- self.watched_users[user.id] = channel
-
- # Add a note (shadow warning) with the reason for watching
- await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
- else:
- data = await response.json()
- error_reason = data.get('error_message', "no message provided")
- await ctx.send(f":x: the API returned an error: {error_reason}")
-
- @group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
- 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(*MODERATION_ROLES)
- async def watched_command(self, ctx: Context, from_cache: bool = True):
- """
- Shows all users that are currently monitored and in which channel.
- By default, the users are returned from the cache.
- If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'.
- """
- if not from_cache:
- updated = await self.update_watched_users()
- if not updated:
- await ctx.send(f":x: Failed to update cache: non-200 response from the API")
- return
- title = "Watched users (updated cache)"
- else:
- title = "Watched users (from cache)"
-
- lines = tuple(
- f"• <@{user_id}> in <#{self.watched_users[user_id].id}>"
- for user_id in self.watched_users
- )
- await LinePaginator.paginate(
- lines or ("There's nothing here yet.",),
- ctx,
- Embed(title=title, color=Color.blue()),
- empty=False
- )
-
- @bigbrother_group.command(name='watch', aliases=('w',))
- @with_role(*MODERATION_ROLES)
- async def watch_command(self, ctx: Context, user: User, *, reason: str):
- """
- 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)
- """
-
- # Update cache to avoid double watching of a user
- await self.update_watched_users()
-
- if user.id in self.watched_users:
- message = f":x: User is already being watched in {self.watched_users[user.id].name}"
- await ctx.send(message)
- return
-
- channel_id = Channels.big_brother_logs
-
- reason = f"{self.infraction_watch_prefix}{reason}"
-
- await self._watch_user(ctx, user, reason, channel_id)
-
- @bigbrother_group.command(name='unwatch', aliases=('uw',))
- @with_role(*MODERATION_ROLES)
- async def unwatch_command(self, ctx: Context, user: User, *, reason: str):
- """
- Stop relaying messages by the given `user`.
-
- A `reason` for unwatching is required, which will be added as a note to the user.
- """
-
- url = f"{URLs.site_bigbrother_api}?user_id={user.id}"
- async with self.bot.http_session.delete(url, headers=self.HEADERS) as response:
- if response.status == 204:
- await ctx.send(f":ok_hand: will no longer relay messages sent by {user}")
-
- if user.id in self.watched_users:
- channel = self.watched_users[user.id]
-
- del self.watched_users[user.id]
- if user.id in self.channel_queues:
- del self.channel_queues[user.id]
- if user.id in self.watch_reasons:
- del self.watch_reasons[user.id]
- else:
- channel = None
- log.warning(f"user {user.id} was unwatched but was not found in the cache")
-
- reason = f"Unwatched ({channel.name if channel else 'unknown channel'}): {reason}"
- await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
-
- else:
- data = await response.json()
- reason = data.get('error_message', "no message provided")
- await ctx.send(f":x: the API returned an error: {reason}")
-
- @bigbrother_group.command(name='nominate', aliases=('n',))
- @with_role(*MODERATION_ROLES)
- async def nominate_command(self, ctx: Context, user: User, *, reason: str):
- """
- Nominates a user for the helper role by adding them to the talent-pool channel
-
- A `reason` for the nomination is required and will be added as a note to
- the user's records.
- """
-
- # Note: This function is called from HelperNomination.nominate_command so that the
- # !nominate command does not show up under "BigBrother" in the help embed, but under
- # the header HelperNomination for users with the helper role.
-
- member = ctx.guild.get_member(user.id)
-
- if member and any(role.id in STAFF_ROLES for role in member.roles):
- await ctx.send(f":x: {user.mention} is already a staff member!")
- return
-
- channel_id = Channels.talent_pool
-
- # Update watch cache to avoid overwriting active nomination reason
- await self.update_watched_users()
-
- if user.id in self.watched_users:
- if self.watched_users[user.id].id == Channels.talent_pool:
- prefix = "Additional nomination: "
- else:
- # If the user is being watched in big-brother, don't add them to talent-pool
- message = (
- f":x: {user.mention} can't be added to the talent-pool "
- "as they are currently being watched in big-brother."
- )
- await ctx.send(message)
- return
- else:
- prefix = self.nomination_prefix
-
- reason = f"{prefix}{reason}"
-
- await self._watch_user(ctx, user, reason, channel_id)
-
-
-class HelperNomination:
- def __init__(self, bot):
- self.bot = bot
-
- @command(name='nominate', aliases=('n',))
- @with_role(*STAFF_ROLES)
- async def nominate_command(self, ctx: Context, user: User, *, reason: str):
- """
- Nominates a user for the helper role by adding them to the talent-pool channel
-
- A `reason` for the nomination is required and will be added as a note to
- the user's records.
- """
-
- cmd = self.bot.get_command("bigbrother nominate")
-
- await ctx.invoke(cmd, user, reason=reason)
-
-
-def setup(bot: Bot):
- bot.add_cog(BigBrother(bot))
- bot.add_cog(HelperNomination(bot))
- log.info("Cog loaded: BigBrother")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 828e2514c..324d2ccd3 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -2,24 +2,22 @@ import ast
import logging
import re
import time
+from typing import Optional, Tuple
from discord import Embed, Message, RawMessageUpdateEvent
-from discord.ext.commands import Bot, Context, command, group
+from discord.ext.commands import Bot, Cog, Context, command, group
-from bot.constants import (
- Channels, Guild, MODERATION_ROLES,
- Roles, URLs,
-)
+from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
+RE_MARKDOWN = re.compile(r'([*_~`|>])')
-class Bot:
- """
- Bot information commands
- """
+
+class Bot(Cog):
+ """Bot information commands."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -32,6 +30,8 @@ class Bot:
Channels.help_3: 0,
Channels.help_4: 0,
Channels.help_5: 0,
+ Channels.help_6: 0,
+ Channels.help_7: 0,
Channels.python: 0,
}
@@ -46,29 +46,23 @@ class Bot:
@group(invoke_without_command=True, name="bot", hidden=True)
@with_role(Roles.verified)
- async def bot_group(self, ctx: Context):
- """
- Bot informational commands
- """
-
+ async def botinfo_group(self, ctx: Context) -> None:
+ """Bot informational commands."""
await ctx.invoke(self.bot.get_command("help"), "bot")
- @bot_group.command(name='about', aliases=('info',), hidden=True)
+ @botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
- async def about_command(self, ctx: Context):
- """
- Get information about the bot
- """
-
+ async def about_command(self, ctx: Context) -> None:
+ """Get information about the bot."""
embed = Embed(
description="A utility bot designed just for the Python server! Try `!help` for more info.",
- url="https://gitlab.com/discord-python/projects/bot"
+ url="https://github.com/python-discord/bot"
)
embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))
embed.set_author(
name="Python Bot",
- url="https://gitlab.com/discord-python/projects/bot",
+ url="https://github.com/python-discord/bot",
icon_url=URLs.bot_avatar
)
@@ -77,24 +71,18 @@ class Bot:
@command(name='echo', aliases=('print',))
@with_role(*MODERATION_ROLES)
- async def echo_command(self, ctx: Context, *, text: str):
- """
- Send the input verbatim to the current channel
- """
-
+ async def echo_command(self, ctx: Context, *, text: str) -> None:
+ """Send the input verbatim to the current channel."""
await ctx.send(text)
@command(name='embed')
@with_role(*MODERATION_ROLES)
- async def embed_command(self, ctx: Context, *, text: str):
- """
- Send the input within an embed to the current channel
- """
-
+ async def embed_command(self, ctx: Context, *, text: str) -> None:
+ """Send the input within an embed to the current channel."""
embed = Embed(description=text)
await ctx.send(embed=embed)
- def codeblock_stripping(self, msg: str, bad_ticks: bool):
+ def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]:
"""
Strip msg in order to find Python code.
@@ -163,15 +151,10 @@ class Bot:
log.trace(f"Returning message.\n\n{content}\n\n")
return (content,), repl_code
- def fix_indentation(self, msg: str):
- """
- Attempts to fix badly indented code.
- """
-
- def unindent(code, skip_spaces=0):
- """
- Unindents all code down to the number of spaces given ins skip_spaces
- """
+ def fix_indentation(self, msg: str) -> str:
+ """Attempts to fix badly indented code."""
+ def unindent(code, skip_spaces: int = 0) -> str:
+ """Unindents all code down to the number of spaces given in skip_spaces."""
final = ""
current = code[0]
leading_spaces = 0
@@ -207,11 +190,13 @@ class Bot:
msg = f"{first_line}\n{unindent(code, 4)}"
return msg
- def repl_stripping(self, msg: str):
+ def repl_stripping(self, msg: str) -> Tuple[str, bool]:
"""
Strip msg in order to extract Python code out of REPL output.
Tries to strip out REPL Python code out of msg and returns the stripped msg.
+
+ Returns True for the boolean if REPL code was found in the input msg.
"""
final = ""
for line in msg.splitlines(keepends=True):
@@ -225,7 +210,8 @@ class Bot:
log.trace(f"Found REPL code in \n\n{msg}\n\n")
return final.rstrip(), True
- def has_bad_ticks(self, msg: Message):
+ def has_bad_ticks(self, msg: Message) -> bool:
+ """Check to see if msg contains ticks that aren't '`'."""
not_backticks = [
"'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019",
"\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033",
@@ -234,13 +220,14 @@ class Bot:
return msg.content[:3] in not_backticks
- async def on_message(self, msg: Message):
- """
- Detect poorly formatted Python code and send the user
- a helpful message explaining how to do properly
- formatted Python syntax highlighting codeblocks.
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
"""
+ Detect poorly formatted Python code in new messages.
+ If poorly formatted code is detected, send the user a helpful message explaining how to do
+ properly formatted Python syntax highlighting codeblocks.
+ """
parse_codeblock = (
(
msg.channel.id in self.channel_cooldowns
@@ -252,7 +239,7 @@ class Bot:
if parse_codeblock:
on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300
- if not on_cooldown:
+ if not on_cooldown or DEBUG_MODE:
try:
if self.has_bad_ticks(msg):
ticks = msg.content[:3]
@@ -277,13 +264,14 @@ class Bot:
current_length += len(line)
lines_walked += 1
content = content[:current_length] + "#..."
-
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
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"
"**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_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -319,13 +307,15 @@ class Bot:
lines_walked += 1
content = content[:current_length] + "#..."
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
howto += (
"It looks like you're trying to paste code into this channel.\n\n"
"Discord has support for Markdown, which allows you to post code with full "
"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_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -355,7 +345,9 @@ class Bot:
f"The message that was posted was:\n\n{msg.content}\n\n"
)
- async def on_raw_message_edit(self, payload: RawMessageUpdateEvent):
+ @Cog.listener()
+ async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
+ """Check to see if an edited message (previously called out) still contains poorly formatted code."""
if (
# Checks to see if the message was called out by the bot
payload.message_id not in self.codeblock_message_ids
@@ -368,19 +360,20 @@ class Bot:
# Retrieve channel and message objects for use later
channel = self.bot.get_channel(int(payload.data.get("channel_id")))
- user_message = await channel.get_message(payload.message_id)
+ user_message = await channel.fetch_message(payload.message_id)
# 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 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])
+ bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
await bot_message.delete()
del self.codeblock_message_ids[payload.message_id]
log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Bot cog load."""
bot.add_cog(Bot(bot))
log.info("Cog loaded: Bot")
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index b34d1118b..1c0c9a7a8 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -4,7 +4,7 @@ import re
from typing import Optional
from discord import Colour, Embed, Message, User
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.cogs.modlog import ModLog
from bot.constants import (
@@ -16,19 +16,15 @@ from bot.decorators import with_role
log = logging.getLogger(__name__)
-class Clean:
+class Clean(Cog):
"""
- A cog that allows messages to be deleted in
- bulk, while applying various filters.
+ A cog that allows messages to be deleted in bulk, while applying various filters.
- You can delete messages sent by a specific user,
- messages sent by bots, all messages, or messages
- that match a specific regular expression.
+ You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a
+ specific regular expression.
- The deleted messages are saved and uploaded
- to the database via an API endpoint, and a URL is
- returned which can be used to view the messages
- in the Discord dark theme style.
+ The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be
+ used to view the messages in the Discord dark theme style.
"""
def __init__(self, bot: Bot):
@@ -37,44 +33,25 @@ class Clean:
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
async def _clean_messages(
self, amount: int, ctx: Context,
bots_only: bool = False, user: User = None,
regex: Optional[str] = None
- ):
- """
- A helper function that does the actual message cleaning.
-
- :param bots_only: Set this to True if you only want to delete bot messages.
- :param user: Specify a user and it will only delete messages by this user.
- :param regular_expression: Specify a regular expression and it will only
- delete messages that match this.
- """
-
+ ) -> None:
+ """A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
- """
- Returns true if the message was sent by a bot
- """
-
+ """Return True if the message was sent by a bot."""
return message.author.bot
def predicate_specific_user(message: Message) -> bool:
- """
- Return True if the message was sent by the
- user provided in the _clean_messages call.
- """
-
+ """Return True if the message was sent by the user provided in the _clean_messages call."""
return message.author == user
- def predicate_regex(message: Message):
- """
- Returns True if the regex provided in the
- _clean_messages matches the message content
- or any embed attributes the message may have.
- """
-
+ def predicate_regex(message: Message) -> bool:
+ """Check if the regex provided in _clean_messages matches the message content or any embed attributes."""
content = [message.content]
# Add the content for all embed attributes
@@ -133,7 +110,8 @@ class Clean:
self.cleaning = True
invocation_deleted = False
- async for message in ctx.channel.history(limit=amount):
+ # To account for the invocation message, we index `amount + 1` messages.
+ async for message in ctx.channel.history(limit=amount + 1):
# If at any point the cancel command is invoked, we should stop.
if not self.cleaning:
@@ -165,7 +143,7 @@ class Clean:
# Reverse the list to restore chronological order
if messages:
messages = list(reversed(messages))
- log_url = await self.mod_log.upload_log(messages)
+ log_url = await self.mod_log.upload_log(messages, ctx.author.id)
else:
# Can't build an embed, nothing to clean!
embed = Embed(
@@ -191,61 +169,38 @@ class Clean:
@group(invoke_without_command=True, name="clean", hidden=True)
@with_role(*MODERATION_ROLES)
- async def clean_group(self, ctx: Context):
- """
- Commands for cleaning messages in channels
- """
-
+ async def clean_group(self, ctx: Context) -> None:
+ """Commands for cleaning messages in channels."""
await ctx.invoke(self.bot.get_command("help"), "clean")
@clean_group.command(name="user", aliases=["users"])
@with_role(*MODERATION_ROLES)
- async def clean_user(self, ctx: Context, user: User, amount: int = 10):
- """
- Delete messages posted by the provided user,
- and stop cleaning after traversing `amount` messages.
- """
-
+ async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None:
+ """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, user=user)
@clean_group.command(name="all", aliases=["everything"])
@with_role(*MODERATION_ROLES)
- async def clean_all(self, ctx: Context, amount: int = 10):
- """
- Delete all messages, regardless of poster,
- and stop cleaning after traversing `amount` messages.
- """
-
+ async def clean_all(self, ctx: Context, amount: int = 10) -> None:
+ """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx)
@clean_group.command(name="bots", aliases=["bot"])
@with_role(*MODERATION_ROLES)
- async def clean_bots(self, ctx: Context, amount: int = 10):
- """
- Delete all messages posted by a bot,
- and stop cleaning after traversing `amount` messages.
- """
-
+ async def clean_bots(self, ctx: Context, amount: int = 10) -> None:
+ """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, bots_only=True)
@clean_group.command(name="regex", aliases=["word", "expression"])
@with_role(*MODERATION_ROLES)
- async def clean_regex(self, ctx: Context, regex, amount: int = 10):
- """
- Delete all messages that match a certain regex,
- and stop cleaning after traversing `amount` messages.
- """
-
+ async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None:
+ """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, regex=regex)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
@with_role(*MODERATION_ROLES)
- async def clean_cancel(self, ctx: Context):
- """
- If there is an ongoing cleaning process,
- attempt to immediately cancel it.
- """
-
+ async def clean_cancel(self, ctx: Context) -> None:
+ """If there is an ongoing cleaning process, attempt to immediately cancel it."""
self.cleaning = False
embed = Embed(
@@ -255,6 +210,7 @@ class Clean:
await ctx.send(embed=embed, delete_after=10)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Clean cog load."""
bot.add_cog(Clean(bot))
log.info("Cog loaded: Clean")
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
index b82273978..9c50c7dd8 100644
--- a/bot/cogs/cogs.py
+++ b/bot/cogs/cogs.py
@@ -2,7 +2,7 @@ import logging
import os
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import (
Emojis, MODERATION_ROLES, Roles, URLs
@@ -15,10 +15,8 @@ log = logging.getLogger(__name__)
KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"]
-class Cogs:
- """
- Cog management commands
- """
+class Cogs(Cog):
+ """Cog management commands."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -37,22 +35,20 @@ class Cogs:
self.cogs.update({v: k for k, v in self.cogs.items()})
@group(name='cogs', aliases=('c',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES, Roles.devops)
- async def cogs_group(self, ctx: Context):
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
+ async def cogs_group(self, ctx: Context) -> None:
"""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(*MODERATION_ROLES, Roles.devops)
- async def load_command(self, ctx: Context, cog: str):
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
+ async def load_command(self, ctx: Context, cog: str) -> None:
"""
- Load up an unloaded cog, given the module containing it
+ Load up an unloaded cog, given the module containing it.
You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
"""
-
cog = cog.lower()
embed = Embed()
@@ -60,7 +56,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -84,7 +80,7 @@ class Cogs:
except Exception as e:
log.exception(f"{ctx.author} requested we load the '{cog}' cog, "
"but the loading failed")
- embed.description = f"Failed to load cog: {cog}\n\n```{e}```"
+ embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}"
else:
log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!")
embed.description = f"Cog loaded: {cog}"
@@ -96,15 +92,14 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='unload', aliases=('ul',))
- @with_role(*MODERATION_ROLES, Roles.devops)
- async def unload_command(self, ctx: Context, cog: str):
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
+ async def unload_command(self, ctx: Context, cog: str) -> None:
"""
- Unload an already-loaded cog, given the module containing it
+ Unload an already-loaded cog, given the module containing it.
You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
"""
-
cog = cog.lower()
embed = Embed()
@@ -112,7 +107,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -147,10 +142,10 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='reload', aliases=('r',))
- @with_role(*MODERATION_ROLES, Roles.devops)
- async def reload_command(self, ctx: Context, cog: str):
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
+ async def reload_command(self, ctx: Context, cog: str) -> None:
"""
- Reload an unloaded cog, given the module containing it
+ Reload an unloaded cog, given the module containing it.
You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
entire module directly.
@@ -158,7 +153,6 @@ class Cogs:
If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the
bot/cogs directory will be loaded.
"""
-
cog = cog.lower()
embed = Embed()
@@ -166,7 +160,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -198,7 +192,7 @@ class Cogs:
try:
self.bot.unload_extension(loaded_cog)
except Exception as e:
- failed_unloads[loaded_cog] = str(e)
+ failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}"
else:
unloaded += 1
@@ -206,7 +200,7 @@ class Cogs:
try:
self.bot.load_extension(unloaded_cog)
except Exception as e:
- failed_loads[unloaded_cog] = str(e)
+ failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}"
else:
loaded += 1
@@ -219,18 +213,19 @@ class Cogs:
lines.append("\n**Unload failures**")
for cog, error in failed_unloads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
+ lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")
if failed_loads:
lines.append("\n**Load failures**")
- for cog, error in failed_loads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
+ for cog, error in failed_loads.items():
+ lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")
log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n"
f"{lines}")
- return await LinePaginator.paginate(lines, ctx, embed, empty=False)
+ await LinePaginator.paginate(lines, ctx, embed, empty=False)
+ return
elif full_cog in self.bot.extensions:
try:
@@ -251,14 +246,13 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='list', aliases=('all',))
- @with_role(*MODERATION_ROLES, Roles.devops)
- async def list_command(self, ctx: Context):
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
+ async def list_command(self, ctx: Context) -> None:
"""
Get a list of all cogs, including their loaded status.
Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded.
"""
-
embed = Embed()
lines = []
cogs = {}
@@ -266,7 +260,7 @@ class Cogs:
embed.colour = Colour.blurple()
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -298,6 +292,7 @@ class Cogs:
await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Cogs cog load."""
bot.add_cog(Cogs(bot))
log.info("Cog loaded: Cogs")
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index f07d9df9f..048d8a683 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -2,10 +2,10 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed, Member
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles, URLs
+from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -24,29 +24,29 @@ will be resolved soon. In the meantime, please feel free to peruse the resources
BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism"
-class Defcon:
- """Time-sensitive server defense mechanisms"""
+class Defcon(Cog):
+ """Time-sensitive server defense mechanisms."""
+
days = None # type: timedelta
enabled = False # type: bool
def __init__(self, bot: Bot):
self.bot = bot
+ self.channel = None
self.days = timedelta(days=0)
- self.headers = {"X-API-KEY": Keys.site_api}
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_ready(self):
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """On cog load, try to synchronize DEFCON settings to the API."""
+ self.channel = await self.bot.fetch_channel(Channels.defcon)
try:
- response = await self.bot.http_session.get(
- URLs.site_settings_api,
- headers=self.headers,
- params={"keys": "defcon_enabled,defcon_days"}
- )
-
- data = await response.json()
+ response = await self.bot.api_client.get('bot/bot-settings/defcon')
+ data = response['data']
except Exception: # Yikes!
log.exception("Unable to get DEFCON settings!")
@@ -55,9 +55,9 @@ class Defcon:
)
else:
- if data["defcon_enabled"]:
+ if data["enabled"]:
self.enabled = True
- self.days = timedelta(days=data["defcon_days"])
+ self.days = timedelta(days=data["days"])
log.warning(f"DEFCON enabled: {self.days.days} days")
else:
@@ -67,7 +67,9 @@ class Defcon:
await self.update_channel_topic()
- async def on_member_join(self, member: Member):
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold."""
if self.enabled and self.days.days > 0:
now = datetime.utcnow()
@@ -100,109 +102,76 @@ class Defcon:
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
@with_role(Roles.admin, Roles.owner)
- async def defcon_group(self, ctx: Context):
+ async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
-
await ctx.invoke(self.bot.get_command("help"), "defcon")
@defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admin, Roles.owner)
- async def enable_command(self, ctx: Context):
+ async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
- Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must
- be, in days.
+ Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must be,
+ in days.
"""
-
self.enabled = True
try:
- response = await self.bot.http_session.put(
- URLs.site_settings_api,
- headers=self.headers,
- json={"defcon_enabled": True}
+ await self.bot.api_client.put(
+ 'bot/bot-settings/defcon',
+ json={
+ 'name': 'defcon',
+ 'data': {
+ 'enabled': True,
+ # TODO: retrieve old days count
+ 'days': 0
+ }
+ }
)
- await response.json()
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
+ await ctx.send(self.build_defcon_msg("enabled", e))
+ await self.send_defcon_log("enabled", ctx.author, e)
- await self.mod_log.send_log_message(
- Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
else:
- await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.")
-
- await self.mod_log.send_log_message(
- Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- )
+ await ctx.send(self.build_defcon_msg("enabled"))
+ await self.send_defcon_log("enabled", ctx.author)
await self.update_channel_topic()
@defcon_group.command(name='disable', aliases=('off', 'd'))
@with_role(Roles.admin, Roles.owner)
- async def disable_command(self, ctx: Context):
- """
- Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
- """
-
+ async def disable_command(self, ctx: Context) -> None:
+ """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""
self.enabled = False
try:
- response = await self.bot.http_session.put(
- URLs.site_settings_api,
- headers=self.headers,
- json={"defcon_enabled": False}
+ await self.bot.api_client.put(
+ 'bot/bot-settings/defcon',
+ json={
+ 'data': {
+ 'days': 0,
+ 'enabled': False
+ },
+ 'name': 'defcon'
+ }
)
-
- await response.json()
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
-
- await self.mod_log.send_log_message(
- Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
+ await ctx.send(self.build_defcon_msg("disabled", e))
+ await self.send_defcon_log("disabled", ctx.author, e)
else:
- await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.")
-
- await self.mod_log.send_log_message(
- Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)"
- )
+ await ctx.send(self.build_defcon_msg("disabled"))
+ await self.send_defcon_log("disabled", ctx.author)
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
@with_role(Roles.admin, Roles.owner)
- async def status_command(self, ctx: Context):
- """
- Check the current status of DEFCON mode.
- """
-
+ async def status_command(self, ctx: Context) -> None:
+ """Check the current status of DEFCON mode."""
embed = Embed(
colour=Colour.blurple(), title="DEFCON Status",
description=f"**Enabled:** {self.enabled}\n"
@@ -213,57 +182,37 @@ class Defcon:
@defcon_group.command(name='days')
@with_role(Roles.admin, Roles.owner)
- async def days_command(self, ctx: Context, days: int):
- """
- Set how old an account must be to join the server, in days, with DEFCON mode enabled.
- """
-
+ async def days_command(self, ctx: Context, days: int) -> None:
+ """Set how old an account must be to join the server, in days, with DEFCON mode enabled."""
self.days = timedelta(days=days)
try:
- response = await self.bot.http_session.put(
- URLs.site_settings_api,
- headers=self.headers,
- json={"defcon_days": days}
+ await self.bot.api_client.put(
+ 'bot/bot-settings/defcon',
+ json={
+ 'data': {
+ 'days': days,
+ 'enabled': True
+ },
+ 'name': 'defcon'
+ }
)
-
- await response.json()
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} "
- f"days old to join to the server.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
-
- await self.mod_log.send_log_message(
- Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
+ await ctx.send(self.build_defcon_msg("updated", e))
+ await self.send_defcon_log("updated", ctx.author, e)
else:
- await ctx.send(
- f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server"
- )
+ await ctx.send(self.build_defcon_msg("updated"))
+ await self.send_defcon_log("updated", ctx.author)
- await self.mod_log.send_log_message(
- Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}"
- )
+ # Enable DEFCON if it's not already
+ if not self.enabled:
+ self.enabled = True
await self.update_channel_topic()
- async def update_channel_topic(self):
- """
- Update the #defcon channel topic with the current DEFCON status
- """
-
+ async def update_channel_topic(self) -> None:
+ """Update the #defcon channel topic with the current DEFCON status."""
if self.enabled:
day_str = "days" if self.days.days > 1 else "day"
new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})"
@@ -271,10 +220,65 @@ class Defcon:
new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
- defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon)
- await defcon_channel.edit(topic=new_topic)
+ await self.channel.edit(topic=new_topic)
+
+ def build_defcon_msg(self, change: str, e: Exception = None) -> str:
+ """
+ Build in-channel response string for DEFCON action.
+
+ `change` string may be one of the following: ('enabled', 'disabled', 'updated')
+ """
+ if change.lower() == "enabled":
+ msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
+ elif change.lower() == "disabled":
+ msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
+ elif change.lower() == "updated":
+ msg = (
+ f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} "
+ "days old to join the server.\n\n"
+ )
+
+ if e:
+ msg += (
+ "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n"
+ f"```py\n{e}\n```"
+ )
+
+ return msg
+
+ async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None:
+ """
+ Send log message for DEFCON action.
+
+ `change` string may be one of the following: ('enabled', 'disabled', 'updated')
+ """
+ log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n"
+
+ if change.lower() == "enabled":
+ icon = Icons.defcon_enabled
+ color = Colours.soft_green
+ status_msg = "DEFCON enabled"
+ log_msg += f"**Days:** {self.days.days}\n\n"
+ elif change.lower() == "disabled":
+ icon = Icons.defcon_disabled
+ color = Colours.soft_red
+ status_msg = "DEFCON enabled"
+ elif change.lower() == "updated":
+ icon = Icons.defcon_updated
+ color = Colour.blurple()
+ status_msg = "DEFCON updated"
+ log_msg += f"**Days:** {self.days.days}\n\n"
+
+ if e:
+ log_msg += (
+ "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n"
+ f"```py\n{e}\n```"
+ )
+
+ await self.mod_log.send_log_message(icon, color, status_msg, log_msg)
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """DEFCON cog load."""
bot.add_cog(Defcon(bot))
log.info("Cog loaded: Defcon")
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
deleted file mode 100644
index e71e07c2f..000000000
--- a/bot/cogs/deployment.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import logging
-
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, command, group
-
-from bot.constants import Keys, MODERATION_ROLES, Roles, URLs
-from bot.decorators import with_role
-
-log = logging.getLogger(__name__)
-
-
-class Deployment:
- """
- Bot information commands
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name='redeploy', invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
- 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):
- """
- Trigger bot deployment on the server - will only redeploy if there were changes to deploy
- """
-
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for bot. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Bot deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!")
-
- @redeploy_group.command(name='site')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def site_command(self, ctx: Context):
- """
- Trigger website deployment on the server - will only redeploy if there were changes to deploy
- """
-
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for site. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Site deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!")
-
- @command(name='uptimes')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def uptimes_command(self, ctx: Context):
- """
- Check the various deployment uptimes for each service
- """
-
- log.debug(f"{ctx.author} requested service uptimes.")
- response = await self.bot.http_session.get(URLs.status)
- data = await response.json()
-
- embed = Embed(
- title="Service status",
- color=Colour.blurple()
- )
-
- for obj in data:
- key, value = list(obj.items())[0]
-
- embed.add_field(
- name=key, value=value, inline=True
- )
-
- log.debug("Uptimes retrieved and parsed, returning data.")
- await ctx.send(embed=embed)
-
-
-def setup(bot):
- bot.add_cog(Deployment(bot))
- log.info("Cog loaded: Deployment")
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 2f2cf8000..e5c51748f 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -1,20 +1,20 @@
import asyncio
import functools
import logging
-import random
import re
import textwrap
from collections import OrderedDict
-from typing import Dict, List, Optional, Tuple
+from typing import Any, Callable, Optional, Tuple
import discord
from bs4 import BeautifulSoup
+from bs4.element import PageElement
from discord.ext import commands
from markdownify import MarkdownConverter
from requests import ConnectionError
from sphinx.ext import intersphinx
-from bot.constants import ERROR_REPLIES, Keys, MODERATION_ROLES, URLs
+from bot.constants import MODERATION_ROLES
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -28,24 +28,22 @@ UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶')
WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
-def async_cache(max_size=128, arg_offset=0):
+def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
"""
LRU cache implementation for coroutines.
- :param max_size:
- Specifies the maximum size the cache should have.
- Once it exceeds the maximum size, keys are deleted in FIFO order.
- :param arg_offset:
- The offset that should be applied to the coroutine's arguments
- when creating the cache key. Defaults to `0`.
- """
+ Once the cache exceeds the maximum size, keys are deleted in FIFO order.
+ An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
+ """
# Assign the cache to the function itself so we can clear it from outside.
async_cache.cache = OrderedDict()
- def decorator(function):
+ def decorator(function: Callable) -> Callable:
+ """Define the async_cache decorator."""
@functools.wraps(function)
- async def wrapper(*args):
+ async def wrapper(*args) -> Any:
+ """Decorator wrapper for the caching logic."""
key = ':'.join(args[arg_offset:])
value = async_cache.cache.get(key)
@@ -60,27 +58,25 @@ def async_cache(max_size=128, arg_offset=0):
class DocMarkdownConverter(MarkdownConverter):
- def convert_code(self, el, text):
- """Undo `markdownify`s underscore escaping."""
+ """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
+ def convert_code(self, el: PageElement, text: str) -> str:
+ """Undo `markdownify`s underscore escaping."""
return f"`{text}`".replace('\\', '')
- def convert_pre(self, el, text):
+ def convert_pre(self, el: PageElement, text: str) -> str:
"""Wrap any codeblocks in `py` for syntax highlighting."""
-
code = ''.join(el.strings)
return f"```py\n{code}```"
-def markdownify(html):
+def markdownify(html: str) -> DocMarkdownConverter:
+ """Create a DocMarkdownConverter object from the input html."""
return DocMarkdownConverter(bullets='•').convert(html)
class DummyObject(object):
- """
- A dummy object which supports assigning anything,
- which the builtin `object()` does not support normally.
- """
+ """A dummy object which supports assigning anything, which the builtin `object()` does not support normally."""
class SphinxConfiguration:
@@ -95,14 +91,15 @@ class InventoryURL(commands.Converter):
"""
Represents an Intersphinx inventory URL.
- This converter checks whether intersphinx
- accepts the given inventory URL, and raises
+ This converter checks whether intersphinx accepts the given inventory URL, and raises
`BadArgument` if that is not the case.
+
Otherwise, it simply passes through the given URL.
"""
@staticmethod
- async def convert(ctx, url: str):
+ async def convert(ctx: commands.Context, url: str) -> str:
+ """Convert url to Intersphinx inventory URL."""
try:
intersphinx.fetch_inventory(SphinxConfiguration(), '', url)
except AttributeError:
@@ -121,32 +118,34 @@ class InventoryURL(commands.Converter):
return url
-class Doc:
- def __init__(self, bot):
+class Doc(commands.Cog):
+ """A set of commands for querying & displaying documentation."""
+
+ def __init__(self, bot: commands.Bot):
self.base_urls = {}
self.bot = bot
self.inventories = {}
- self.headers = {"X-API-KEY": Keys.site_api}
- async def on_ready(self):
+ @commands.Cog.listener()
+ async def on_ready(self) -> None:
+ """Refresh documentation inventory."""
await self.refresh_inventory()
async def update_single(
self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration
- ):
+ ) -> None:
"""
Rebuild the inventory for a single package.
- :param package_name: The package name to use, appears in the log.
- :param base_url: The root documentation URL for the specified package.
- Used to build absolute paths that link to specific symbols.
- :param inventory_url: The absolute URL to the intersphinx inventory.
- Fetched by running `intersphinx.fetch_inventory` in an
- executor on the bot's event loop.
- :param config: A `SphinxConfiguration` instance to mock the regular sphinx
- project layout. Required for use with intersphinx.
+ Where:
+ * `package_name` is the package name to use, appears in the log
+ * `base_url` is the root documentation URL for the specified package, used to build
+ absolute paths that link to specific symbols
+ * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
+ `intersphinx.fetch_inventory` in an executor on the bot's event loop
+ * `config` is a `SphinxConfiguration` instance to mock the regular sphinx
+ project layout, required for use with intersphinx
"""
-
self.base_urls[package_name] = base_url
fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url)
@@ -160,7 +159,8 @@ class Doc:
log.trace(f"Fetched inventory for {package_name}.")
- async def refresh_inventory(self):
+ async def refresh_inventory(self) -> None:
+ """Refresh internal documentation inventory."""
log.debug("Refreshing documentation inventory...")
# Clear the old base URLS and inventories to ensure
@@ -179,7 +179,7 @@ class Doc:
coros = [
self.update_single(
package["package"], package["base_url"], package["inventory_url"], config
- ) for package in await self.get_all_packages()
+ ) for package in await self.bot.api_client.get('bot/documentation-links')
]
await asyncio.gather(*coros)
@@ -187,16 +187,13 @@ class Doc:
"""
Given a Python symbol, return its signature and description.
- :param symbol: The symbol for which HTML data should be returned.
- :return:
- A tuple in the form (str, str), or `None`.
- The first tuple element is the signature of the given
- symbol as a markup-free string, and the second tuple
- element is the description of the given symbol with HTML
- markup included. If the given symbol could not be found,
- returns `None`.
- """
+ Returns a tuple in the form (str, str), or `None`.
+ The first tuple element is the signature of the given symbol as a markup-free string, and
+ the second tuple element is the description of the given symbol with HTML markup included.
+
+ If the given symbol could not be found, returns `None`.
+ """
url = self.inventories.get(symbol)
if url is None:
return None
@@ -224,16 +221,10 @@ class Doc:
@async_cache(arg_offset=1)
async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
"""
- Using `get_symbol_html`, attempt to scrape and
- fetch the data for the given `symbol`, and build
- a formatted embed out of its contents.
-
- :param symbol: The symbol for which the embed should be returned
- :return:
- If the symbol is known, an Embed with documentation about it.
- Otherwise, `None`.
- """
+ Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
+ If the symbol is known, an Embed with documentation about it is returned.
+ """
scraped_html = await self.get_symbol_html(symbol)
if scraped_html is None:
return None
@@ -267,110 +258,17 @@ class Doc:
description=f"```py\n{signature}```{description}"
)
- async def get_all_packages(self) -> List[Dict[str, str]]:
- """
- Performs HTTP GET to get all packages from the website.
-
- :return:
- A list of packages, in the following format:
- [
- {
- "package": "example-package",
- "base_url": "https://example.readthedocs.io",
- "inventory_url": "https://example.readthedocs.io/objects.inv"
- },
- ...
- ]
- `package` specifies the package name, for example 'aiohttp'.
- `base_url` specifies the documentation root URL, used to build absolute links.
- `inventory_url` specifies the location of the Intersphinx inventory.
- """
-
- async with self.bot.http_session.get(URLs.site_docs_api, headers=self.headers) as resp:
- return await resp.json()
-
- async def get_package(self, package_name: str) -> Optional[Dict[str, str]]:
- """
- Performs HTTP GET to get the specified package from the documentation database.
-
- :param package_name: The package name for which information should be returned.
- :return:
- Either a dictionary with information in the following format:
- {
- "package": "example-package",
- "base_url": "https://example.readthedocs.io",
- "inventory_url": "https://example.readthedocs.io/objects.inv"
- }
- or `None` if the site didn't returned no results for the given name.
- """
-
- params = {"package": package_name}
-
- async with self.bot.http_session.get(URLs.site_docs_api,
- headers=self.headers,
- params=params) as resp:
- package_data = await resp.json()
- if not package_data:
- return None
- return package_data[0]
-
- async def set_package(self, name: str, base_url: str, inventory_url: str) -> Dict[str, bool]:
- """
- Performs HTTP POST to add a new package to the website's documentation database.
-
- :param name: The name of the package, for example `aiohttp`.
- :param base_url: The documentation root URL, used to build absolute links.
- :param inventory_url: The absolute URl to the intersphinx inventory of the package.
-
- :return: The JSON response of the server, which is always:
- {
- "success": True
- }
- """
-
- package_json = {
- 'package': name,
- 'base_url': base_url,
- 'inventory_url': inventory_url
- }
-
- async with self.bot.http_session.post(URLs.site_docs_api,
- headers=self.headers,
- json=package_json) as resp:
- return await resp.json()
-
- async def delete_package(self, name: str) -> bool:
- """
- Performs HTTP DELETE to delete the specified package from the documentation database.
-
- :param name: The package to delete.
-
- :return: `True` if successful, `False` if the package is unknown.
- """
-
- package_json = {'package': name}
-
- async with self.bot.http_session.delete(URLs.site_docs_api,
- headers=self.headers,
- json=package_json) as resp:
- changes = await resp.json()
- return changes["deleted"] == 1 # Did the package delete successfully?
-
@commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
- async def docs_group(self, ctx, symbol: commands.clean_content = None):
+ async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""Lookup documentation for Python symbols."""
-
await ctx.invoke(self.get_command)
@docs_group.command(name='get', aliases=('g',))
- async def get_command(self, ctx, symbol: commands.clean_content = None):
+ async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""
Return a documentation embed for a given symbol.
- If no symbol is given, return a list of all available inventories.
- :param ctx: Discord message context
- :param symbol: The symbol for which documentation should be returned,
- or nothing to get a list of all inventories
+ If no symbol is given, return a list of all available inventories.
Examples:
!docs
@@ -378,7 +276,6 @@ class Doc:
!docs aiohttp.ClientSession
!docs get aiohttp.ClientSession
"""
-
if symbol is None:
inventory_embed = discord.Embed(
title=f"All inventories (`{len(self.base_urls)}` total)",
@@ -386,7 +283,12 @@ class Doc:
)
lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items())
- await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False)
+ if self.base_urls:
+ await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False)
+
+ else:
+ inventory_embed.description = "Hmmm, seems like there's nothing here yet."
+ await ctx.send(embed=inventory_embed)
else:
# Fetching documentation for a symbol (at least for the first time, since
@@ -407,18 +309,13 @@ class Doc:
@docs_group.command(name='set', aliases=('s',))
@with_role(*MODERATION_ROLES)
async def set_command(
- self, ctx, package_name: ValidPythonIdentifier,
+ self, ctx: commands.Context, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
- ):
+ ) -> None:
"""
Adds a new documentation metadata object to the site's database.
- The database will update the object, should an existing item
- with the specified `package_name` already exist.
- :param ctx: Discord message context
- :param package_name: The package name, for example `aiohttp`.
- :param base_url: The package documentation's root URL, used to build absolute links.
- :param inventory_url: The intersphinx inventory URL.
+ The database will update the object, should an existing item with the specified `package_name` already exist.
Example:
!docs set \
@@ -426,8 +323,13 @@ class Doc:
https://discordpy.readthedocs.io/en/rewrite/ \
https://discordpy.readthedocs.io/en/rewrite/objects.inv
"""
+ body = {
+ 'package': package_name,
+ 'base_url': base_url,
+ 'inventory_url': inventory_url
+ }
+ await self.bot.api_client.post('bot/documentation-links', json=body)
- await self.set_package(package_name, base_url, inventory_url)
log.info(
f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) "
"added a new documentation package:\n"
@@ -444,54 +346,23 @@ class Doc:
@docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(*MODERATION_ROLES)
- async def delete_command(self, ctx, package_name: ValidPythonIdentifier):
+ async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
"""
Removes the specified package from the database.
- :param ctx: Discord message context
- :param package_name: The package name, for example `aiohttp`.
-
Examples:
!docs delete aiohttp
"""
+ await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
- success = await self.delete_package(package_name)
- if success:
-
- async with ctx.typing():
- # Rebuild the inventory to ensure that everything
- # that was from this package is properly deleted.
- await self.refresh_inventory()
- await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
-
- else:
- await ctx.send(
- f"Can't find any package named `{package_name}` in the database. "
- "View all known packages by using `docs.get()`."
- )
-
- @get_command.error
- @delete_command.error
- @set_command.error
- async def general_command_error(self, ctx, error: commands.CommandError):
- """
- Handle the `BadArgument` error caused by
- the commands when argument validation fails.
-
- :param ctx: Discord message context of the message creating the error
- :param error: The error raised, usually `BadArgument`
- """
-
- if isinstance(error, commands.BadArgument):
- embed = discord.Embed(
- title=random.choice(ERROR_REPLIES),
- description=f"Error: {error}",
- colour=discord.Colour.red()
- )
- await ctx.send(embed=embed)
- else:
- log.exception(f"Unhandled error: {error}")
+ async with ctx.typing():
+ # Rebuild the inventory to ensure that everything
+ # that was from this package is properly deleted.
+ await self.refresh_inventory()
+ await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
+ """Doc cog load."""
bot.add_cog(Doc(bot))
+ log.info("Cog loaded: Doc")
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
new file mode 100644
index 000000000..49411814c
--- /dev/null
+++ b/bot/cogs/error_handler.py
@@ -0,0 +1,148 @@
+import contextlib
+import logging
+
+from discord.ext.commands import (
+ BadArgument,
+ BotMissingPermissions,
+ CheckFailure,
+ CommandError,
+ CommandInvokeError,
+ CommandNotFound,
+ CommandOnCooldown,
+ DisabledCommand,
+ MissingPermissions,
+ NoPrivateMessage,
+ UserInputError,
+)
+from discord.ext.commands import Bot, Cog, Context
+
+from bot.api import ResponseCodeError
+from bot.constants import Channels
+from bot.decorators import InChannelCheckFailure
+
+log = logging.getLogger(__name__)
+
+
+class ErrorHandler(Cog):
+ """Handles errors emitted from commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @Cog.listener()
+ async def on_command_error(self, ctx: Context, e: CommandError) -> None:
+ """
+ Provide generic command error handling.
+
+ Error handling is deferred to any local error handler, if present.
+
+ Error handling emits a single error response, prioritized as follows:
+ 1. If the name fails to match a command but matches a tag, the tag is invoked
+ 2. Send a BadArgument error message to the invoking context & invoke the command's help
+ 3. Send a UserInputError error message to the invoking context & invoke the command's help
+ 4. Send a NoPrivateMessage error message to the invoking context
+ 5. Send a BotMissingPermissions error message to the invoking context
+ 6. Log a MissingPermissions error, no message is sent
+ 7. Send a InChannelCheckFailure error message to the invoking context
+ 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent
+ 9. For CommandInvokeErrors, response is based on the type of error:
+ * 404: Error message is sent to the invoking context
+ * 400: Log the resopnse JSON, no message is sent
+ * 500 <= status <= 600: Error message is sent to the invoking context
+ 10. Otherwise, handling is deferred to `handle_unexpected_error`
+ """
+ command = ctx.command
+ parent = None
+
+ if command is not None:
+ parent = command.parent
+
+ # Retrieve the help command for the invoked command.
+ if parent and command:
+ help_command = (self.bot.get_command("help"), parent.name, command.name)
+ elif command:
+ help_command = (self.bot.get_command("help"), command.name)
+ else:
+ help_command = (self.bot.get_command("help"),)
+
+ if hasattr(e, "handled"):
+ log.trace(f"Command {command} had its error already handled locally; ignoring.")
+ return
+
+ # Try to look for a tag with the command's name if the command isn't found.
+ if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+ if not ctx.channel.id == Channels.verification:
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
+
+ # Return to not raise the exception
+ with contextlib.suppress(ResponseCodeError):
+ await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
+ return
+ elif isinstance(e, BadArgument):
+ await ctx.send(f"Bad argument: {e}\n")
+ await ctx.invoke(*help_command)
+ elif isinstance(e, UserInputError):
+ await ctx.send("Something about your input seems off. Check the arguments:")
+ await ctx.invoke(*help_command)
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+ elif isinstance(e, NoPrivateMessage):
+ await ctx.send("Sorry, this command can't be used in a private message!")
+ elif isinstance(e, BotMissingPermissions):
+ await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.")
+ log.warning(
+ f"The bot is missing permissions to execute command {command}: {e.missing_perms}"
+ )
+ elif isinstance(e, MissingPermissions):
+ log.debug(
+ f"{ctx.message.author} is missing permissions to invoke command {command}: "
+ f"{e.missing_perms}"
+ )
+ elif isinstance(e, InChannelCheckFailure):
+ await ctx.send(e)
+ elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)):
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+ elif isinstance(e, CommandInvokeError):
+ if isinstance(e.original, ResponseCodeError):
+ status = e.original.response.status
+
+ if status == 404:
+ await ctx.send("There does not seem to be anything matching your query.")
+ elif status == 400:
+ content = await e.original.response.json()
+ log.debug(f"API responded with 400 for command {command}: %r.", content)
+ await ctx.send("According to the API, your request is malformed.")
+ elif 500 <= status < 600:
+ await ctx.send("Sorry, there seems to be an internal issue with the API.")
+ log.warning(f"API responded with {status} for command {command}")
+ else:
+ await ctx.send(f"Got an unexpected status code from the API (`{status}`).")
+ log.warning(f"Unexpected API response for command {command}: {status}")
+ else:
+ await self.handle_unexpected_error(ctx, e.original)
+ else:
+ await self.handle_unexpected_error(ctx, e)
+
+ @staticmethod
+ async def handle_unexpected_error(ctx: Context, e: CommandError) -> None:
+ """Generic handler for errors without an explicit handler."""
+ await ctx.send(
+ f"Sorry, an unexpected error occurred. Please let us know!\n\n"
+ f"```{e.__class__.__name__}: {e}```"
+ )
+ log.error(
+ f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}"
+ )
+ raise e
+
+
+def setup(bot: Bot) -> None:
+ """Error handler cog load."""
+ bot.add_cog(ErrorHandler(bot))
+ log.info("Cog loaded: Events")
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 8e97a35a2..9ce854f2c 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -6,9 +6,10 @@ import re
import textwrap
import traceback
from io import StringIO
+from typing import Any, Optional, Tuple
import discord
-from discord.ext.commands import Bot, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Roles
from bot.decorators import with_role
@@ -17,11 +18,8 @@ from bot.interpreter import Interpreter
log = logging.getLogger(__name__)
-class CodeEval:
- """
- Owner and admin feature that evaluates code
- and returns the result to the channel.
- """
+class CodeEval(Cog):
+ """Owner and admin feature that evaluates code and returns the result to the channel."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -31,7 +29,8 @@ class CodeEval:
self.interpreter = Interpreter(bot)
- def _format(self, inp, out): # (str, Any) -> (str, discord.Embed)
+ def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:
+ """Format the eval output into a string & attempt to format it into an Embed."""
self._ = out
res = ""
@@ -124,7 +123,8 @@ class CodeEval:
return res # Return (text, embed)
- async def _eval(self, ctx, code): # (discord.Context, str) -> None
+ async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]:
+ """Eval the input code string & send an embed to the invoking context."""
self.ln += 1
if code.startswith("exit"):
@@ -174,16 +174,15 @@ async def func(): # (None,) -> Any
@group(name='internal', aliases=('int',))
@with_role(Roles.owner, Roles.admin)
- async def internal_group(self, ctx):
+ async def internal_group(self, ctx: Context) -> None:
"""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. """
+ async def eval(self, ctx: Context, *, code: str) -> None:
+ """Run eval in a REPL-like format."""
code = code.strip("`")
if re.match('py(thon)?\n', code):
code = "\n".join(code.split("\n")[1:])
@@ -197,6 +196,7 @@ async def func(): # (None,) -> Any
await self._eval(ctx, code)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Code eval cog load."""
bot.add_cog(CodeEval(bot))
log.info("Cog loaded: Eval")
diff --git a/bot/cogs/events.py b/bot/cogs/events.py
deleted file mode 100644
index 2819b7dcc..000000000
--- a/bot/cogs/events.py
+++ /dev/null
@@ -1,311 +0,0 @@
-import logging
-from functools import partial
-from typing import List
-
-from discord import Colour, Embed, Member, Object
-from discord.ext.commands import (
- BadArgument, Bot, BotMissingPermissions,
- CommandError, CommandInvokeError, CommandNotFound,
- Context, NoPrivateMessage, UserInputError
-)
-
-from bot.constants import (
- Channels, Colours, DEBUG_MODE,
- Guild, Icons, Keys,
- Roles, URLs
-)
-from bot.utils import chunks
-
-log = logging.getLogger(__name__)
-
-RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements))
-
-
-class Events:
- """No commands, just event handlers."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
-
- @property
- def send_log(self) -> partial:
- cog = self.bot.get_cog("ModLog")
- return partial(cog.send_log_message, channel_id=Channels.userlog)
-
- async def send_updated_users(self, *users, replace_all=False):
- users = list(filter(lambda user: str(Roles.verified) in user["roles"], users))
-
- for chunk in chunks(users, 1000):
- response = None
-
- try:
- if replace_all:
- response = await self.bot.http_session.post(
- url=URLs.site_user_api,
- json=chunk,
- headers={"X-API-Key": Keys.site_api}
- )
- else:
- response = await self.bot.http_session.put(
- url=URLs.site_user_api,
- json=chunk,
- headers={"X-API-Key": Keys.site_api}
- )
-
- await response.json() # We do this to ensure we got a proper response from the site
- except Exception:
- if not response:
- log.exception(f"Failed to send {len(chunk)} users")
- else:
- text = await response.text()
- log.exception(f"Failed to send {len(chunk)} users", extra={"body": text})
- break # Stop right now, thank you very much
-
- result = {}
-
- if replace_all:
- response = None
-
- try:
- response = await self.bot.http_session.post(
- url=URLs.site_user_complete_api,
- headers={"X-API-Key": Keys.site_api}
- )
-
- result = await response.json()
- except Exception:
- if not response:
- log.exception(f"Failed to send {len(chunk)} users")
- else:
- text = await response.text()
- log.exception(f"Failed to send {len(chunk)} users", extra={"body": text})
-
- return result
-
- async def send_delete_users(self, *users):
- try:
- response = await self.bot.http_session.delete(
- url=URLs.site_user_api,
- json=list(users),
- headers={"X-API-Key": Keys.site_api}
- )
-
- return await response.json()
- except Exception:
- log.exception(f"Failed to delete {len(users)} users")
- return {}
-
- async def get_user(self, user_id):
- response = await self.bot.http_session.get(
- url=URLs.site_user_api,
- params={"user_id": user_id},
- headers={"X-API-Key": Keys.site_api}
- )
-
- resp = await response.json()
- return resp["data"]
-
- async def has_active_mute(self, user_id: str) -> bool:
- """
- Check whether a user has any active mute infractions
- """
-
- 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()
-
- # Check for active mute infractions
- if not infraction_list:
- # Short circuit
- return False
-
- return any(
- infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute"
- )
-
- async def on_command_error(self, ctx: Context, e: CommandError):
- command = ctx.command
- parent = None
-
- if command is not None:
- parent = command.parent
-
- if parent and command:
- help_command = (self.bot.get_command("help"), parent.name, command.name)
- elif command:
- help_command = (self.bot.get_command("help"), command.name)
- else:
- help_command = (self.bot.get_command("help"),)
-
- if hasattr(command, "on_error"):
- log.debug(f"Command {command} has a local error handler, ignoring.")
- return
-
- 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):
- await ctx.invoke(*help_command)
- elif isinstance(e, NoPrivateMessage):
- await ctx.send("Sorry, this command can't be used in a private message!")
- elif isinstance(e, BotMissingPermissions):
- await ctx.send(
- f"Sorry, it looks like I don't have the permissions I need to do that.\n\n"
- f"Here's what I'm missing: **{e.missing_perms}**"
- )
- elif isinstance(e, CommandInvokeError):
- await ctx.send(
- f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```"
- )
- raise e.original
- raise e
-
- async def on_ready(self):
- users = []
-
- for member in self.bot.get_guild(Guild.id).members: # type: Member
- roles: List[int] = [str(r.id) for r in member.roles]
-
- users.append({
- "avatar": member.avatar_url_as(format="png"),
- "user_id": str(member.id),
- "roles": roles,
- "username": member.name,
- "discriminator": member.discriminator
- })
-
- if users:
- log.info(f"{len(users)} user roles to be updated")
-
- done = await self.send_updated_users(*users, replace_all=True)
-
- if any(done.values()):
- embed = Embed(
- title="Users updated"
- )
-
- for key, value in done.items():
- if value:
- if key == "deleted_oauth":
- key = "Deleted (OAuth)"
- elif key == "deleted_jam_profiles":
- key = "Deleted (Jammer Profiles)"
- elif key == "deleted_responses":
- key = "Deleted (Jam Form Responses)"
- elif key == "jam_bans":
- key = "Ex-Jammer Bans"
- else:
- key = key.title()
-
- embed.add_field(
- name=key, value=str(value)
- )
-
- if not DEBUG_MODE:
- await self.bot.get_channel(Channels.devlog).send(
- embed=embed
- )
-
- 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):
- return
-
- before_role_names: List[str] = [role.name for role in before.roles]
- after_role_names: List[str] = [role.name for role in after.roles]
- role_ids: List[str] = [str(r.id) for r in after.roles]
-
- log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}")
-
- changes = await self.send_updated_users({
- "avatar": after.avatar_url_as(format="png"),
- "user_id": str(after.id),
- "roles": role_ids,
- "username": after.name,
- "discriminator": after.discriminator
- })
-
- log.debug(f"User {after.id} updated; changes: {changes}")
-
- async def on_member_join(self, member: Member):
- role_ids: List[str] = [str(r.id) for r in member.roles]
- new_roles = []
-
- try:
- user_objs = await self.get_user(str(member.id))
- except Exception as e:
- log.exception("Failed to persist roles")
-
- await self.send_log(
- Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles",
- f"```py\n{e}\n```",
- member.avatar_url_as(static_format="png")
- )
- else:
- if user_objs:
- old_roles = user_objs[0].get("roles", [])
-
- for role in RESTORE_ROLES:
- if role in old_roles:
- # Check for mute roles that were not able to be removed and skip if present
- if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)):
- log.debug(
- f"User {member.id} has no active mute infraction, "
- "their leftover muted role will not be persisted"
- )
- continue
-
- new_roles.append(Object(int(role)))
-
- for role in new_roles:
- if str(role) not in role_ids:
- role_ids.append(str(role.id))
-
- changes = await self.send_updated_users({
- "avatar": member.avatar_url_as(format="png"),
- "user_id": str(member.id),
- "roles": role_ids,
- "username": member.name,
- "discriminator": member.discriminator
- })
-
- log.debug(f"User {member.id} joined; changes: {changes}")
-
- if new_roles:
- await member.add_roles(
- *new_roles,
- reason="Roles restored"
- )
-
- await self.send_log(
- Icons.crown_blurple, Colour.blurple(), "Roles restored",
- f"Restored {len(new_roles)} roles",
- member.avatar_url_as(static_format="png")
- )
-
- async def on_member_remove(self, member: Member):
- changes = await self.send_delete_users({
- "user_id": str(member.id)
- })
-
- log.debug(f"User {member.id} left; changes: {changes}")
-
-
-def setup(bot):
- bot.add_cog(Events(bot))
- log.info("Cog loaded: Events")
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 418297fc4..9cd1b7203 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -5,7 +5,7 @@ from typing import Optional, Union
import discord.errors
from dateutil.relativedelta import relativedelta
from discord import Colour, DMChannel, Member, Message, TextChannel
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.cogs.modlog import ModLog
from bot.constants import (
@@ -29,11 +29,8 @@ URL_RE = r"(https?://[^\s]+)"
ZALGO_RE = r"[\u0300-\u036F\u0489]"
-class Filtering:
- """
- Filtering out invites, blacklisting domains,
- and warning us of certain regular expressions
- """
+class Filtering(Cog):
+ """Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -59,7 +56,7 @@ class Filtering:
"user_notification": Filter.notify_user_invites,
"notification_msg": (
f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
- r"Our server rules can be found here: <https://pythondiscord.com/about/rules>"
+ r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"
)
},
"filter_domains": {
@@ -94,26 +91,29 @@ class Filtering:
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, msg: Message):
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Invoke message filter for new messages."""
await self._filter_message(msg)
- async def on_message_edit(self, before: Message, after: Message):
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
+ """
+ Invoke message filter for message edits.
+
+ If there have been multiple edits, calculate the time delta from the previous edit.
+ """
if not before.edited_at:
delta = relativedelta(after.edited_at, before.created_at).microseconds
else:
- delta = None
+ delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
- async def _filter_message(self, msg: Message, delta: Optional[int] = None):
- """
- Whenever a message is sent or edited,
- run it through our filters to see if it
- violates any of our rules, and then respond
- accordingly.
- """
-
+ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
+ """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
# Should we filter this message?
role_whitelisted = False
@@ -142,7 +142,7 @@ class Filtering:
# If the edit delta is less than 0.001 seconds, then we're probably dealing
# with a double filter trigger.
if delta is not None and delta < 100:
- return
+ continue
# Does the filter only need the message content or the full message?
if _filter["content_only"]:
@@ -224,14 +224,10 @@ class Filtering:
@staticmethod
async def _has_watchlist_words(text: str) -> bool:
"""
- Returns True if the text contains
- one of the regular expressions from the
- word_watchlist in our filter config.
+ Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config.
- Only matches words with boundaries before
- and after the expression.
+ Only matches words with boundaries before and after the expression.
"""
-
for expression in Filter.word_watchlist:
if re.search(fr"\b{expression}\b", text, re.IGNORECASE):
return True
@@ -241,14 +237,10 @@ class Filtering:
@staticmethod
async def _has_watchlist_tokens(text: str) -> bool:
"""
- Returns True if the text contains
- one of the regular expressions from the
- token_watchlist in our filter config.
+ Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config.
- This will match the expression even if it
- does not have boundaries before and after
+ This will match the expression even if it does not have boundaries before and after.
"""
-
for expression in Filter.token_watchlist:
if re.search(fr"{expression}", text, re.IGNORECASE):
@@ -260,11 +252,7 @@ class Filtering:
@staticmethod
async def _has_urls(text: str) -> bool:
- """
- Returns True if the text contains one of
- the blacklisted URLs from the config file.
- """
-
+ """Returns True if the text contains one of the blacklisted URLs from the config file."""
if not re.search(URL_RE, text, re.IGNORECASE):
return False
@@ -283,7 +271,6 @@ class Filtering:
Zalgo range is \u0300 – \u036F and \u0489.
"""
-
return bool(re.search(ZALGO_RE, text))
async def _has_invites(self, text: str) -> Union[dict, bool]:
@@ -295,7 +282,6 @@ class Filtering:
Attempts to catch some of common ways to try to cheat the system.
"""
-
# Remove backslashes to prevent escape character aroundfuckery like
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
@@ -336,30 +322,27 @@ class Filtering:
return invite_data if invite_data else False
@staticmethod
- async def _has_rich_embed(msg: Message):
- """
- Returns True if any of the embeds in the message are of type 'rich', but are not twitter
- embeds. Returns False otherwise.
- """
+ async def _has_rich_embed(msg: Message) -> bool:
+ """Returns True if any of the embeds in the message are of type 'rich', but are not twitter embeds."""
if msg.embeds:
for embed in msg.embeds:
if embed.type == "rich" and (not embed.url or "twitter.com" not in embed.url):
return True
return False
- async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel):
+ async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None:
"""
- Notify filtered_member about a moderation action with the reason str
+ Notify filtered_member about a moderation action with the reason str.
First attempts to DM the user, fall back to in-channel notification if user has DMs disabled
"""
-
try:
await filtered_member.send(reason)
except discord.errors.Forbidden:
await channel.send(f"{filtered_member.mention} {reason}")
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Filtering cog load."""
bot.add_cog(Filtering(bot))
log.info("Cog loaded: Filtering")
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
index fd6009bb8..167fab319 100644
--- a/bot/cogs/free.py
+++ b/bot/cogs/free.py
@@ -2,7 +2,7 @@ import logging
from datetime import datetime
from discord import Colour, Embed, Member, utils
-from discord.ext.commands import Context, command
+from discord.ext.commands import Bot, Cog, Context, command
from bot.constants import Categories, Channels, Free, STAFF_ROLES
from bot.decorators import redirect_output
@@ -15,18 +15,16 @@ RATE = Free.cooldown_rate
PER = Free.cooldown_per
-class Free:
+class Free(Cog):
"""Tries to figure out which help channels are free."""
PYTHON_HELP_ID = Categories.python_help
@command(name="free", aliases=('f',))
@redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
- async def free(self, ctx: Context, user: Member = None, seek: int = 2):
+ async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None:
"""
Lists free help channels by likeliness of availability.
- :param user: accepts user mention, ID, etc.
- :param seek: How far back to check the last active message.
seek is used only when this command is invoked in a help channel.
You cannot override seek without mentioning a user first.
@@ -101,6 +99,7 @@ class Free:
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Free cog load."""
bot.add_cog(Free())
log.info("Cog loaded: Free")
diff --git a/bot/cogs/fun.py b/bot/cogs/fun.py
deleted file mode 100644
index 57fa7cb1c..000000000
--- a/bot/cogs/fun.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import logging
-
-from discord import Message
-from discord.ext.commands import Bot
-
-from bot.constants import Channels
-
-RESPONSES = {
- "_pokes {us}_": "_Pokes {them}_",
- "_eats {us}_": "_Tastes slimy and snake-like_",
- "_pets {us}_": "_Purrs_"
-}
-
-log = logging.getLogger(__name__)
-
-
-class Fun:
- """
- Fun, entirely useless stuff
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- async def on_ready(self):
- keys = list(RESPONSES.keys())
-
- for key in keys:
- changed_key = key.replace("{us}", self.bot.user.mention)
-
- if key != changed_key:
- RESPONSES[changed_key] = RESPONSES[key]
- del RESPONSES[key]
-
- async def on_message(self, message: Message):
- if message.channel.id != Channels.bot:
- return
-
- content = message.content
-
- if content and content[0] == "*" and content[-1] == "*":
- content = f"_{content[1:-1]}_"
-
- response = RESPONSES.get(content)
-
- if response:
- log.debug(f"{message.author} said '{message.clean_content}'. Responding with '{response}'.")
- await message.channel.send(response.format(them=message.author.mention))
-
-
-def setup(bot):
- bot.add_cog(Fun(bot))
- log.info("Cog loaded: Fun")
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 20ed08f07..4971cd0bb 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -3,10 +3,11 @@ import inspect
import itertools
from collections import namedtuple
from contextlib import suppress
+from typing import Union
-from discord import Colour, Embed, HTTPException
+from discord import Colour, Embed, HTTPException, Message, Reaction, User
from discord.ext import commands
-from discord.ext.commands import CheckFailure
+from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context
from fuzzywuzzy import fuzz, process
from bot import constants
@@ -35,15 +36,11 @@ class HelpQueryNotFound(ValueError):
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.
+ Instances of this object contain a dictionary of any command(s) that were close to matching the
+ query, where keys are the possible matched command names and values are the likeness match scores.
"""
- def __init__(self, arg, possible_matches=None):
+ def __init__(self, arg: str, possible_matches: dict = None):
super().__init__(arg)
self.possible_matches = possible_matches
@@ -52,48 +49,30 @@ 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.
+ Expected attributes include:
+ * title: str
+ The title of the help message.
+ * query: Union[discord.ext.commands.Bot, 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: `discord.Message`
+ The message object that's showing the help contents.
+ * destination: `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.
- """
-
+ def __init__(
+ self,
+ ctx: Context,
+ *command,
+ cleanup: bool = False,
+ only_can_run: bool = True,
+ show_hidden: bool = False,
+ max_lines: int = 15
+ ):
+ """Creates an instance of the HelpSession class."""
self._ctx = ctx
self._bot = ctx.bot
self.title = "Command Help"
@@ -107,7 +86,7 @@ class HelpSession:
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
+ self.destination = ctx.channel
# set the config for the session
self._cleanup = cleanup
@@ -122,20 +101,8 @@ class HelpSession:
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`]
- """
-
+ def _get_query(self, query: str) -> Union[Command, Cog]:
+ """Attempts to match the provided query with a valid command or cog."""
command = self._bot.get_command(query)
if command:
return command
@@ -150,48 +117,26 @@ class HelpSession:
self._handle_not_found(query)
- def _handle_not_found(self, query):
+ def _handle_not_found(self, query: str) -> None:
"""
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
+ Will pass on possible close matches along with the `HelpQueryNotFound` exception.
"""
-
- # combine command and cog names
+ # 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.
- """
-
+ async def timeout(self, seconds: int = 30) -> None:
+ """Waits for a set number of seconds, then stops the help session."""
await asyncio.sleep(seconds)
await self.stop()
- def reset_timeout(self):
- """
- Cancels the original timeout task and sets it again from the start.
- """
-
+ def reset_timeout(self) -> None:
+ """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():
@@ -200,18 +145,8 @@ class HelpSession:
# 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.
- """
-
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Event handler for when reactions are added on the help message."""
# ensure it was the relevant session message
if reaction.message.id != self.message.id:
return
@@ -237,24 +172,13 @@ class HelpSession:
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.
- """
-
+ async def on_message_delete(self, message: Message) -> None:
+ """Closes the help session when the help message is deleted."""
if message.id == self.message.id:
await self.stop()
- async def prepare(self):
- """
- Sets up the help session pages, events, message and reactions.
- """
-
+ async def prepare(self) -> None:
+ """Sets up the help session pages, events, message and reactions."""
# create paginated content
await self.build_pages()
@@ -266,12 +190,8 @@ class HelpSession:
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.
- """
-
+ def add_reactions(self) -> None:
+ """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:
@@ -281,44 +201,22 @@ class HelpSession:
else:
self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
- def _category_key(self, cmd):
+ def _category_key(self, cmd: Command) -> str:
"""
- 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.
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
- Parameters
- ----------
- cmd: :class:`discord.ext.commands.Command`
- The command object being checked.
-
- Returns
- -------
- str
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
"""
-
cog = cmd.cog_name
return f'**{cog}**' if cog else f'**\u200bNo Category:**'
- def _get_command_params(self, cmd):
+ def _get_command_params(self, cmd: Command) -> str:
"""
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
+ This is a custom implementation of `command.signature` in order to format the command
+ signature without aliases.
"""
-
results = []
for name, param in cmd.clean_params.items():
@@ -346,16 +244,8 @@ class HelpSession:
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]
- """
-
+ async def build_pages(self) -> None:
+ """Builds the list of content pages to be paginated through in the help message, as a list of str."""
# Use LinePaginator to restrict embed line height
paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
@@ -482,20 +372,8 @@ class HelpSession:
# 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`
- """
-
+ def embed_page(self, page_number: int = 0) -> Embed:
+ """Returns an Embed with the requested page formatted within."""
embed = Embed()
# if command or cog, add query to title for pages other than first
@@ -514,17 +392,8 @@ class HelpSession:
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.
- """
-
+ async def update_page(self, page_number: int = 0) -> None:
+ """Sends the intial message, or changes the existing one to the given page number."""
self._current_page = page_number
embed_page = self.embed_page(page_number)
@@ -534,47 +403,27 @@ class HelpSession:
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`
+ async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
"""
+ Create and begin a help session based on the given command context.
+ Available options kwargs:
+ * 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.
+ """
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.
- """
-
+ async def stop(self) -> None:
+ """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)
@@ -586,80 +435,47 @@ class HelpSession:
await self.message.clear_reactions()
@property
- def is_first_page(self):
- """
- A bool reflecting if session is currently showing the first page.
-
- Returns
- -------
- bool
- """
-
+ def is_first_page(self) -> bool:
+ """Check if session is currently showing the first page."""
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
- """
-
+ def is_last_page(self) -> bool:
+ """Check if the session is currently showing the last page."""
return self._current_page == (len(self._pages)-1)
- async def do_first(self):
- """
- Event that is called when the user requests the first page.
- """
-
+ async def do_first(self) -> None:
+ """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.
- """
-
+ async def do_back(self) -> None:
+ """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.
- """
-
+ async def do_next(self) -> None:
+ """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.
- """
-
+ async def do_end(self) -> None:
+ """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.
- """
-
+ async def do_stop(self) -> None:
+ """Event that is called when the user requests to stop the help session."""
await self.message.delete()
-class Help:
- """
- Custom Embed Pagination Help feature
- """
+class Help(DiscordCog):
+ """Custom Embed Pagination Help feature."""
+
@commands.command('help')
@redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
- async def new_help(self, ctx, *commands):
- """
- Shows Command Help.
- """
-
+ async def new_help(self, ctx: Context, *commands) -> None:
+ """Shows Command Help."""
try:
await HelpSession.start(ctx, *commands)
except HelpQueryNotFound as error:
@@ -674,42 +490,29 @@ class Help:
await ctx.send(embed=embed)
-def unload(bot):
+def unload(bot: Bot) -> None:
"""
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.
+ This is run if the cog raises an exception on load, or if the extension is unloaded.
"""
-
bot.remove_command('help')
bot.add_command(bot._old_help)
-def setup(bot):
+def setup(bot: Bot) -> None:
"""
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.
+ 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.
- Parameters
- ----------
- bot: `discord.ext.commands.Bot`
- The discord bot client.
+ If an exception is raised during the loading of the cog, `unload` will be called in order to
+ reinstate the original help command.
"""
-
bot._old_help = bot.get_command('help')
bot.remove_command('help')
@@ -720,18 +523,12 @@ def setup(bot):
raise
-def teardown(bot):
+def teardown(bot: Bot) -> None:
"""
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.
+ Calls `unload` in order to reinstate the original help command.
"""
-
unload(bot)
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 469999c00..60aec6219 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,40 +1,27 @@
import logging
-import random
import textwrap
from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
-from discord.ext.commands import BadArgument, Bot, CommandError, Context, MissingPermissions, command
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import (
- Channels, Emojis, Keys, MODERATION_ROLES,
- NEGATIVE_REPLIES, STAFF_ROLES, URLs
-)
-from bot.decorators import with_role
+from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
+from bot.decorators import InChannelCheckFailure, with_role
from bot.utils.checks import with_role_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
-class Information:
- """
- A cog with commands for generating embeds with
- server information, such as server statistics
- and user information.
- """
+class Information(Cog):
+ """A cog with commands for generating embeds with server info, such as server stats and user info."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-Key": Keys.site_api}
@with_role(*MODERATION_ROLES)
@command(name="roles")
- async def roles_info(self, ctx: Context):
- """
- Returns a list of all roles and their
- corresponding IDs.
- """
-
+ async def roles_info(self, ctx: Context) -> None:
+ """Returns a list of all roles and their corresponding IDs."""
# Sort the roles alphabetically and remove the @everyone role
roles = sorted(ctx.guild.roles, key=lambda role: role.name)
roles = [role for role in roles if role.name != "@everyone"]
@@ -56,12 +43,8 @@ class Information:
await ctx.send(embed=embed)
@command(name="server", aliases=["server_info", "guild", "guild_info"])
- async def server_info(self, ctx: Context):
- """
- Returns an embed full of
- server information.
- """
-
+ async def server_info(self, ctx: Context) -> None:
+ """Returns an embed full of server information."""
created = time_since(ctx.guild.created_at, precision="days")
features = ", ".join(ctx.guild.features)
region = ctx.guild.region
@@ -125,35 +108,27 @@ class Information:
await ctx.send(embed=embed)
@command(name="user", aliases=["user_info", "member", "member_info"])
- async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False):
- """
- Returns info about a user.
- """
-
- # Do a role check if this is being executed on
- # someone other than the caller
- if user and user != ctx.author:
- if not with_role_check(ctx, *MODERATION_ROLES):
- raise BadArgument("You do not have permission to use this command on users other than yourself.")
-
- # Non-moderators may only do this in #bot-commands and can't see
- # hidden infractions.
+ async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:
+ """Returns info about a user."""
+ if user is None:
+ user = ctx.author
+
+ # Do a role check if this is being executed on someone other than the caller
+ if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES):
+ await ctx.send("You may not use this command on users other than yourself.")
+ return
+
+ # Non-moderators may only do this in #bot-commands and can't see hidden infractions.
if not with_role_check(ctx, *STAFF_ROLES):
if not ctx.channel.id == Channels.bot:
- raise MissingPermissions("You can't do that here!")
+ raise InChannelCheckFailure(Channels.bot)
# Hide hidden infractions for users without a moderation role
hidden = False
- # Validates hidden input
- hidden = str(hidden)
-
- if user is None:
- user = ctx.author
-
# User information
created = time_since(user.created_at, max_units=3)
- name = f"{user.name}#{user.discriminator}"
+ name = str(user)
if user.nick:
name = f"{user.nick} ({name})"
@@ -161,19 +136,17 @@ class Information:
joined = time_since(user.joined_at, precision="days")
# You're welcome, Volcyyyyyyyyyyyyyyyy
- roles = ", ".join(
- role.mention for role in user.roles if role.name != "@everyone"
- )
+ roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")
# 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
+ infractions = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'hidden': str(hidden),
+ 'user__id': str(user.id)
+ }
)
- infractions = await api_response.json()
-
infr_total = 0
infr_active = 0
@@ -208,24 +181,8 @@ class Information:
await ctx.send(embed=embed)
- @user_info.error
- async def user_info_command_error(self, ctx: Context, error: CommandError):
- embed = Embed(colour=Colour.red())
-
- if isinstance(error, BadArgument):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
- elif isinstance(error, MissingPermissions):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>."
- await ctx.send(embed=embed)
-
- else:
- log.exception(f"Unhandled error: {error}")
-
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Information cog load."""
bot.add_cog(Information(bot))
log.info("Cog loaded: Information")
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
index 96b98e559..be9d33e3e 100644
--- a/bot/cogs/jams.py
+++ b/bot/cogs/jams.py
@@ -2,6 +2,7 @@ import logging
from discord import Member, PermissionOverwrite, utils
from discord.ext import commands
+from more_itertools import unique_everseen
from bot.constants import Roles
from bot.decorators import with_role
@@ -9,35 +10,33 @@ from bot.decorators import with_role
log = logging.getLogger(__name__)
-class CodeJams:
- """
- Manages the code-jam related parts of our server
- """
+class CodeJams(commands.Cog):
+ """Manages the code-jam related parts of our server."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.command()
@with_role(Roles.admin)
- async def createteam(
- self, ctx: commands.Context,
- team_name: str, members: commands.Greedy[Member]
- ):
+ async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
"""
- Create a team channel (both voice and text) in the Code Jams category, assign roles
- and then add overwrites for the team.
+ Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
The first user passed will always be the team leader.
"""
+ # Ignore duplicate members
+ members = list(unique_everseen(members))
# We had a little issue during Code Jam 4 here, the greedy converter did it's job
# and ignored anything which wasn't a valid argument which left us with teams of
# two members or at some times even 1 member. This fixes that by checking that there
# are always 3 members in the members list.
if len(members) < 3:
- await ctx.send(":no_entry_sign: One of your arguments was invalid - there must be a "
- f"minimum of 3 valid members in your team. Found: {len(members)} "
- "members")
+ await ctx.send(
+ ":no_entry_sign: One of your arguments was invalid\n"
+ f"There must be a minimum of 3 valid members in your team. Found: {len(members)}"
+ " members"
+ )
return
code_jam_category = utils.get(ctx.guild.categories, name="Code Jam")
@@ -65,7 +64,7 @@ class CodeJams:
connect=True
),
ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
- ctx.guild.get_role(Roles.developer): PermissionOverwrite(
+ ctx.guild.get_role(Roles.verified): PermissionOverwrite(
read_messages=False,
connect=False
)
@@ -102,9 +101,14 @@ class CodeJams:
for member in members:
await member.add_roles(jammer_role)
- await ctx.send(f":ok_hand: Team created: {team_channel.mention}")
+ await ctx.send(
+ f":ok_hand: Team created: {team_channel.mention}\n"
+ f"**Team Leader:** {members[0].mention}\n"
+ f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
+ )
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
+ """Code Jams cog load."""
bot.add_cog(CodeJams(bot))
log.info("Cog loaded: CodeJams")
diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py
index 6b8462f3b..8e47bcc36 100644
--- a/bot/cogs/logging.py
+++ b/bot/cogs/logging.py
@@ -1,7 +1,7 @@
import logging
from discord import Embed
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.constants import Channels, DEBUG_MODE
@@ -9,28 +9,32 @@ from bot.constants import Channels, DEBUG_MODE
log = logging.getLogger(__name__)
-class Logging:
- """
- Debug logging module
- """
+class Logging(Cog):
+ """Debug logging module."""
def __init__(self, bot: Bot):
self.bot = bot
- async def on_ready(self):
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Announce our presence to the configured devlog channel."""
log.info("Bot connected!")
embed = Embed(description="Connected!")
embed.set_author(
name="Python Bot",
- url="https://gitlab.com/discord-python/projects/bot",
- icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png"
+ url="https://github.com/python-discord/bot",
+ icon_url=(
+ "https://raw.githubusercontent.com/"
+ "python-discord/branding/master/logos/logo_circle/logo_circle_large.png"
+ )
)
if not DEBUG_MODE:
await self.bot.get_channel(Channels.devlog).send(embed=embed)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Logging cog load."""
bot.add_cog(Logging(bot))
log.info("Cog loaded: Logging")
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index 73359c88c..81b3864a7 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -1,25 +1,25 @@
import asyncio
import logging
import textwrap
-from typing import Union
+from datetime import datetime
+from typing import Dict, Union
-from aiohttp import ClientError
from discord import (
- Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User
+ Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User
)
from discord.ext.commands import (
- BadArgument, BadUnionArgument, Bot, Context, command, group
+ BadArgument, BadUnionArgument, Bot, Cog, Context, command, group
)
from bot import constants
from bot.cogs.modlog import ModLog
-from bot.constants import Colours, Event, Icons, Keys, MODERATION_ROLES, URLs
-from bot.converters import InfractionSearchQuery
+from bot.constants import Colours, Event, Icons, MODERATION_ROLES
+from bot.converters import ExpirationDate, InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
-from bot.utils.moderation import post_infraction
+from bot.utils.moderation import already_has_active_infraction, post_infraction
from bot.utils.scheduling import Scheduler, create_task
-from bot.utils.time import parse_rfc1123, wait_until
+from bot.utils.time import wait_until
log = logging.getLogger(__name__)
@@ -28,11 +28,12 @@ INFRACTION_ICONS = {
"Kick": Icons.sign_out,
"Ban": Icons.user_ban
}
-RULES_URL = "https://pythondiscord.com/about/rules"
+RULES_URL = "https://pythondiscord.com/pages/rules"
APPEALABLE_INFRACTIONS = ("Ban", "Mute")
def proxy_user(user_id: str) -> Object:
+ """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved."""
try:
user_id = int(user_id)
except ValueError:
@@ -46,54 +47,41 @@ def proxy_user(user_id: str) -> Object:
UserTypes = Union[Member, User, proxy_user]
-class Moderation(Scheduler):
- """
- Server moderation tools.
- """
+class Moderation(Scheduler, Cog):
+ """Server moderation tools."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
self._muted_role = Object(constants.Roles.muted)
super().__init__()
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_ready(self):
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Schedule expiration for previous infractions."""
# Schedule expiration for previous infractions
- response = await self.bot.http_session.get(
- URLs.site_infractions,
- params={"dangling": "true"},
- headers=self.headers
+ infractions = await self.bot.api_client.get(
+ 'bot/infractions', params={'active': 'true'}
)
- infraction_list = await response.json()
- for infraction_object in infraction_list:
- if infraction_object["expires_at"] is not None:
- self.schedule_task(self.bot.loop, infraction_object["id"], infraction_object)
+ for infraction in infractions:
+ if infraction["expires_at"] is not None:
+ self.schedule_task(self.bot.loop, infraction["id"], infraction)
# region: Permanent infractions
@with_role(*MODERATION_ROLES)
@command()
- async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None):
- """
- Create a warning infraction in the database for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the warning.
- """
-
- response_object = await post_infraction(ctx, user, type="warning", reason=reason)
- if response_object is None:
+ async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None:
+ """Create a warning infraction in the database for a user."""
+ infraction = await post_infraction(ctx, user, type="warning", reason=reason)
+ if infraction is None:
return
- notified = await self.notify_infraction(
- user=user,
- infr_type="Warning",
- reason=reason
- )
+ notified = await self.notify_infraction(user=user, infr_type="Warning", reason=reason)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: warned {user.mention}"
@@ -122,33 +110,23 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command()
- async def kick(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Kicks a user.
-
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the kick.
- """
-
+ async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ """Kicks a user with the provided reason."""
if not await self.respect_role_hierarchy(ctx, user, 'kick'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="kick", reason=reason)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="kick", reason=reason)
+ if infraction is None:
return
- notified = await self.notify_infraction(
- user=user,
- infr_type="Kick",
- reason=reason
- )
+ notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason)
self.mod_log.ignore(Event.member_remove, user.id)
@@ -182,32 +160,28 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command()
- async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None):
- """
- Create a permanent ban infraction in the database for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the ban.
- """
-
+ async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None:
+ """Create a permanent ban infraction for a user with the provided reason."""
if not await self.respect_role_hierarchy(ctx, user, 'ban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="ban", reason=reason)
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason)
+ if infraction is None:
return
notified = await self.notify_infraction(
user=user,
infr_type="Ban",
- duration="Permanent",
reason=reason
)
@@ -246,21 +220,18 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command()
- async def mute(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Create a permanent mute infraction in the database for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the mute.
- """
+ async def mute(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ """Create a permanent mute infraction for a user with the provided reason."""
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
- response_object = await post_infraction(ctx, user, type="mute", reason=reason)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -269,7 +240,7 @@ class Moderation(Scheduler):
notified = await self.notify_infraction(
user=user,
infr_type="Mute",
- duration="Permanent",
+ expires_at="Permanent",
reason=reason
)
@@ -300,7 +271,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -308,19 +279,19 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
- async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None):
+ async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None:
"""
- Create a temporary mute infraction in the database for a user.
+ Create a temporary mute infraction for a user with the provided expiration and reason.
- **`user`:** Accepts user mention, ID, etc.
- **`duration`:** The duration for the temporary mute infraction
- **`reason`:** The reason for the temporary mute.
+ Duration strings are parsed per: http://strftime.org/
"""
+ expiration = duration
- response_object = await post_infraction(
- ctx, user, type="mute", reason=reason, duration=duration
- )
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -329,14 +300,17 @@ class Moderation(Scheduler):
notified = await self.notify_infraction(
user=user,
infr_type="Mute",
- duration=duration,
+ expires_at=expiration,
reason=reason
)
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_task(ctx.bot.loop, infraction_object["id"], infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}"
@@ -363,41 +337,38 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
DM: {dm_status}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command()
- async def tempban(
- self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None
- ):
+ async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None:
"""
- Create a temporary ban infraction in the database for a user.
+ Create a temporary ban infraction for a user with the provided expiration and reason.
- **`user`:** Accepts user mention, ID, etc.
- **`duration`:** The duration for the temporary ban infraction
- **`reason`:** The reason for the temporary ban.
+ Duration strings are parsed per: http://strftime.org/
"""
+ expiration = duration
if not await self.respect_role_hierarchy(ctx, user, 'tempban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(
- ctx, user, type="ban", reason=reason, duration=duration
- )
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration)
+ if infraction is None:
return
notified = await self.notify_infraction(
user=user,
infr_type="Ban",
- duration=duration,
+ expires_at=expiration,
reason=reason
)
@@ -410,10 +381,13 @@ class Moderation(Scheduler):
except Forbidden:
action_result = False
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_task(ctx.bot.loop, infraction_object["id"], infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}"
@@ -439,11 +413,10 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
DM: {dm_status}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -451,18 +424,14 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn'])
- async def note(self, ctx: Context, user: UserTypes, *, reason: str = None):
+ async def note(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None:
"""
- Create a private infraction note in the database for a user.
+ Create a private infraction note in the database for a user with the provided reason.
- **`user`:** accepts user mention, ID, etc.
- **`reason`:** The reason for the warning.
+ This does not send the user a notification
"""
-
- response_object = await post_infraction(
- ctx, user, type="warning", reason=reason, hidden=True
- )
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+ if infraction is None:
return
if reason is None:
@@ -480,26 +449,24 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
Reason: {reason}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=['shadowkick', 'skick'])
- async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None):
+ async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
"""
- Kicks a user.
+ Kick a user for the provided reason.
- **`user`:** accepts user mention, ID, etc.
- **`reason`:** The reason for the kick.
+ This does not send the user a notification.
"""
-
if not await self.respect_role_hierarchy(ctx, user, 'shadowkick'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_remove, user.id)
@@ -533,26 +500,27 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None):
+ async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None:
"""
- Create a permanent ban infraction in the database for a user.
+ Create a permanent ban infraction for a user with the provided reason.
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the ban.
+ This does not send the user a notification.
"""
-
if not await self.respect_role_hierarchy(ctx, user, 'shadowban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
@@ -587,21 +555,22 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=['shadowmute', 'smute'])
- async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None):
+ async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None) -> None:
"""
- Create a permanent mute infraction in the database for a user.
+ Create a permanent mute infraction for a user with the provided reason.
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the mute.
+ This does not send the user a notification.
"""
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
- response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -622,7 +591,7 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
Reason: {reason}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -631,29 +600,34 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempmute, stempmute"])
async def shadow_tempmute(
- self, ctx: Context, user: Member, duration: str, *, reason: str = None
- ):
+ self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None
+ ) -> None:
"""
- Create a temporary mute infraction in the database for a user.
+ Create a temporary mute infraction for a user with the provided reason.
- **`user`:** Accepts user mention, ID, etc.
- **`duration`:** The duration for the temporary mute infraction
- **`reason`:** The reason for the temporary mute.
+ Duration strings are parsed per: http://strftime.org/
+
+ This does not send the user a notification.
"""
+ expiration = duration
- response_object = await post_infraction(
- ctx, user, type="mute", reason=reason, duration=duration, hidden=True
- )
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration, hidden=True)
+ if infraction 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"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_expiration(ctx.bot.loop, infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
if reason is None:
await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.")
@@ -671,34 +645,35 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempban, stempban"])
async def shadow_tempban(
- self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None
- ):
+ self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None
+ ) -> None:
"""
- Create a temporary ban infraction in the database for a user.
+ Create a temporary ban infraction for a user with the provided reason.
- **`user`:** Accepts user mention, ID, etc.
- **`duration`:** The duration for the temporary ban infraction
- **`reason`:** The reason for the temporary ban.
+ Duration strings are parsed per: http://strftime.org/
+
+ This does not send the user a notification.
"""
+ expiration = duration
if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(
- ctx, user, type="ban", reason=reason, duration=duration, hidden=True
- )
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
@@ -710,10 +685,13 @@ class Moderation(Scheduler):
except Forbidden:
action_result = False
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_expiration(ctx.bot.loop, infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
if reason is None:
await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.")
@@ -739,11 +717,10 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -751,40 +728,32 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
- async def unmute(self, ctx: Context, user: Member):
- """
- Deactivates the active mute infraction for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- """
-
+ async def unmute(self, ctx: Context, user: UserTypes) -> None:
+ """Deactivates the active mute infraction for a user."""
try:
# check the current active infraction
- response = await self.bot.http_session.get(
- URLs.site_infractions_user_type_current.format(
- user_id=user.id,
- infraction_type="mute"
- ),
- headers=self.headers
+ response = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'mute',
+ 'user__id': user.id
+ }
)
+ if len(response) > 1:
+ log.warning("Found more than one active mute infraction for user `%d`", user.id)
- response_object = await response.json()
- if "error_code" in response_object:
- return await ctx.send(
- ":x: There was an error removing the infraction: "
- f"{response_object['error_message']}"
- )
-
- infraction_object = response_object["infraction"]
- if infraction_object is None:
+ if not response:
# no active infraction
- return await ctx.send(
+ await ctx.send(
f":x: There is no active mute infraction for user {user.mention}."
)
+ return
- await self._deactivate_infraction(infraction_object)
- if infraction_object["expires_at"] is not None:
- self.cancel_expiration(infraction_object["id"])
+ for infraction in response:
+ await self._deactivate_infraction(infraction)
+ if infraction["expires_at"] is not None:
+ self.cancel_expiration(infraction["id"])
notified = await self.notify_pardon(
user=user,
@@ -804,61 +773,82 @@ class Moderation(Scheduler):
await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.")
+ embed_text = textwrap.dedent(
+ f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ DM: {dm_status}
+ """
+ )
+
+ if len(response) > 1:
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+ title = "Member unmuted"
+ embed_text += "Note: User had multiple **active** mute infractions in the database."
+ else:
+ infraction = response[0]
+ footer = f"Infraction ID: {infraction['id']}"
+ title = "Member unmuted"
+
# Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_unmute,
colour=Colour(Colours.soft_green),
- title="Member unmuted",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction_object['expires_at']}
- DM: {dm_status}
- """),
- footer=infraction_object["id"],
+ text=embed_text,
+ footer=footer,
content=log_content
)
-
- except Exception as e:
- log.exception("There was an error removing an infraction.", exc_info=e)
+ except Exception:
+ log.exception("There was an error removing an infraction.")
await ctx.send(":x: There was an error removing the infraction.")
@with_role(*MODERATION_ROLES)
@command()
- async def unban(self, ctx: Context, user: UserTypes):
- """
- Deactivates the active ban infraction for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- """
-
+ async def unban(self, ctx: Context, user: UserTypes) -> None:
+ """Deactivates the active ban infraction for a user."""
try:
# check the current active infraction
- response = await self.bot.http_session.get(
- URLs.site_infractions_user_type_current.format(
- user_id=user.id,
- infraction_type="ban"
- ),
- headers=self.headers
+ response = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'ban',
+ 'user__id': str(user.id)
+ }
)
- response_object = await response.json()
- if "error_code" in response_object:
- return await ctx.send(
- ":x: There was an error removing the infraction: "
- f"{response_object['error_message']}"
+ if len(response) > 1:
+ log.warning(
+ "More than one active ban infraction found for user `%d`.",
+ user.id
)
- infraction_object = response_object["infraction"]
- if infraction_object is None:
+ if not response:
# no active infraction
- return await ctx.send(
+ await ctx.send(
f":x: There is no active ban infraction for user {user.mention}."
)
+ return
+
+ for infraction in response:
+ await self._deactivate_infraction(infraction)
+ if infraction["expires_at"] is not None:
+ self.cancel_expiration(infraction["id"])
- await self._deactivate_infraction(infraction_object)
- if infraction_object["expires_at"] is not None:
- self.cancel_expiration(infraction_object["id"])
+ embed_text = textwrap.dedent(
+ f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ """
+ )
+
+ if len(response) > 1:
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+ embed_text += "Note: User had multiple **active** ban infractions in the database."
+ else:
+ infraction = response[0]
+ footer = f"Infraction ID: {infraction['id']}"
await ctx.send(f":ok_hand: Un-banned {user.mention}.")
@@ -868,11 +858,8 @@ class Moderation(Scheduler):
colour=Colour(Colours.soft_green),
title="Member unbanned",
thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction_object['expires_at']}
- """)
+ text=embed_text,
+ footer=footer,
)
except Exception:
log.exception("There was an error removing an infraction.")
@@ -883,70 +870,68 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
- async def infraction_group(self, ctx: Context):
+ async def infraction_group(self, ctx: Context) -> None:
"""Infraction manipulation commands."""
-
await ctx.invoke(self.bot.get_command("help"), "infraction")
@with_role(*MODERATION_ROLES)
@infraction_group.group(name='edit', invoke_without_command=True)
- async def infraction_edit_group(self, ctx: Context):
+ async def infraction_edit_group(self, ctx: Context) -> None:
"""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: Context, infraction_id: str, duration: str):
+ async def edit_duration(
+ self, ctx: Context,
+ infraction_id: int, expires_at: Union[ExpirationDate, str]
+ ) -> None:
"""
- Sets the duration of the given infraction, relative to the time of
- updating.
+ Sets the duration of the given infraction, relative to the time of updating.
- **`infraction_id`:** The ID (UUID) of the infraction.
- **`duration`:** The new duration of the infraction, relative to the
- time of updating. Use "permanent" to the infraction as permanent.
+ Duration strings are parsed per: http://strftime.org/, use "permanent" to mark the infraction as permanent.
"""
+ if isinstance(expires_at, str) and expires_at != 'permanent':
+ raise BadArgument(
+ "If `expires_at` is given as a non-datetime, "
+ "it must be `permanent`."
+ )
+ if expires_at == 'permanent':
+ expires_at = None
try:
- previous = await self.bot.http_session.get(
- URLs.site_infractions_by_id.format(
- infraction_id=infraction_id
- ),
- headers=self.headers
+ previous_infraction = await self.bot.api_client.get(
+ 'bot/infractions/' + str(infraction_id)
)
- previous_object = await previous.json()
-
- if duration == "permanent":
- duration = None
# check the current active infraction
- response = await self.bot.http_session.patch(
- URLs.site_infractions,
+ infraction = await self.bot.api_client.patch(
+ 'bot/infractions/' + str(infraction_id),
json={
- "id": infraction_id,
- "duration": duration
- },
- headers=self.headers
+ 'expires_at': (
+ expires_at.isoformat()
+ if expires_at is not None
+ else None
+ )
+ }
)
- response_object = await response.json()
- if "error_code" in response_object or response_object.get("success") is False:
- return await ctx.send(
- ":x: There was an error updating the infraction: "
- f"{response_object['error_message']}"
- )
- infraction_object = response_object["infraction"]
# Re-schedule
- self.cancel_task(infraction_id)
+ self.cancel_task(infraction['id'])
loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction_object["id"], infraction_object)
+ self.schedule_task(loop, infraction['id'], infraction)
- if duration is None:
+ if expires_at is None:
await ctx.send(f":ok_hand: Updated infraction: marked as permanent.")
else:
+ human_expiry = (
+ datetime
+ .fromisoformat(infraction['expires_at'][:-1])
+ .strftime('%c')
+ )
await ctx.send(
":ok_hand: Updated infraction: set to expire on "
- f"{infraction_object['expires_at']}."
+ f"{human_expiry}."
)
except Exception:
@@ -954,10 +939,8 @@ class Moderation(Scheduler):
await ctx.send(":x: There was an error updating the infraction.")
return
- prev_infraction = previous_object["infraction"]
-
# Get information about the infraction's user
- user_id = int(infraction_object["user"]["user_id"])
+ user_id = infraction["user"]
user = ctx.guild.get_member(user_id)
if user:
@@ -968,7 +951,7 @@ class Moderation(Scheduler):
thumbnail = None
# The infraction's actor
- actor_id = int(infraction_object["actor"]["user_id"])
+ actor_id = infraction["actor"]
actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
await self.mod_log.send_log_message(
@@ -980,55 +963,33 @@ class Moderation(Scheduler):
Member: {member_text}
Actor: {actor}
Edited by: {ctx.message.author}
- Previous expiry: {prev_infraction['expires_at']}
- New expiry: {infraction_object['expires_at']}
+ Previous expiry: {previous_infraction['expires_at']}
+ New expiry: {infraction['expires_at']}
""")
)
@with_role(*MODERATION_ROLES)
@infraction_edit_group.command(name="reason")
- async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str):
- """
- Sets the reason of the given infraction.
- **`infraction_id`:** The ID (UUID) of the infraction.
- **`reason`:** The new reason of the infraction.
- """
-
+ async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str) -> None:
+ """Edit the reason of the given infraction."""
try:
- previous = await self.bot.http_session.get(
- URLs.site_infractions_by_id.format(
- infraction_id=infraction_id
- ),
- headers=self.headers
+ old_infraction = await self.bot.api_client.get(
+ 'bot/infractions/' + str(infraction_id)
)
- previous_object = await previous.json()
-
- response = await self.bot.http_session.patch(
- URLs.site_infractions,
- json={
- "id": infraction_id,
- "reason": reason
- },
- headers=self.headers
+ updated_infraction = await self.bot.api_client.patch(
+ 'bot/infractions/' + str(infraction_id),
+ json={'reason': reason}
)
- response_object = await response.json()
- if "error_code" in response_object or response_object.get("success") is False:
- return await ctx.send(
- ":x: There was an error updating the infraction: "
- f"{response_object['error_message']}"
- )
-
await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".")
+
except Exception:
log.exception("There was an error updating an infraction.")
- return await ctx.send(":x: There was an error updating the infraction.")
-
- new_infraction = response_object["infraction"]
- prev_infraction = previous_object["infraction"]
+ await ctx.send(":x: There was an error updating the infraction.")
+ return
# Get information about the infraction's user
- user_id = int(new_infraction["user"]["user_id"])
+ user_id = updated_infraction['user']
user = ctx.guild.get_member(user_id)
if user:
@@ -1039,7 +1000,7 @@ class Moderation(Scheduler):
thumbnail = None
# The infraction's actor
- actor_id = int(new_infraction["actor"]["user_id"])
+ actor_id = updated_infraction['actor']
actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
await self.mod_log.send_log_message(
@@ -1051,8 +1012,8 @@ class Moderation(Scheduler):
Member: {user_text}
Actor: {actor}
Edited by: {ctx.message.author}
- Previous reason: {prev_infraction['reason']}
- New reason: {new_infraction['reason']}
+ Previous reason: {old_infraction['reason']}
+ New reason: {updated_infraction['reason']}
""")
)
@@ -1061,11 +1022,8 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@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.
- """
-
+ async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:
+ """Searches for infractions in the database."""
if isinstance(query, User):
await ctx.invoke(self.search_user, query)
@@ -1074,72 +1032,44 @@ class Moderation(Scheduler):
@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.
- """
-
- 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
-
+ async def search_user(self, ctx: Context, user: Union[User, proxy_user]) -> None:
+ """Search for infractions by member."""
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'user__id': str(user.id)}
+ )
embed = Embed(
title=f"Infractions for {user} ({len(infraction_list)} total)",
colour=Colour.orange()
)
-
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.
- """
-
- 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
-
+ async def search_reason(self, ctx: Context, reason: str) -> None:
+ """Search for infractions by their reason. Use Re2 for matching."""
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions', params={'search': reason}
+ )
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):
-
+ async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list) -> None:
+ """Send a paginated embed of infractions for the specified user."""
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)
- )
+ lines = tuple(
+ self._infraction_to_string(infraction)
+ for infraction in infractions
+ )
await LinePaginator.paginate(
lines,
@@ -1153,14 +1083,10 @@ class Moderation(Scheduler):
# endregion
# region: Utility functions
- def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict):
- """
- Schedules a task to expire a temporary infraction.
-
- :param loop: the asyncio event loop
- :param infraction_object: the infraction object to expire at the end of the task
- """
-
+ def schedule_expiration(
+ self, loop: asyncio.AbstractEventLoop, infraction_object: Dict[str, Union[str, int, bool]]
+ ) -> None:
+ """Schedules a task to expire a temporary infraction."""
infraction_id = infraction_object["id"]
if infraction_id in self.scheduled_tasks:
return
@@ -1169,12 +1095,8 @@ class Moderation(Scheduler):
self.scheduled_tasks[infraction_id] = task
- def cancel_expiration(self, infraction_id: str):
- """
- Un-schedules a task set to expire a temporary infraction.
- :param infraction_id: the ID of the infraction in question
- """
-
+ def cancel_expiration(self, infraction_id: str) -> None:
+ """Un-schedules a task set to expire a temporary infraction."""
task = self.scheduled_tasks.get(infraction_id)
if task is None:
log.warning(f"Failed to unschedule {infraction_id}: no task found.")
@@ -1183,19 +1105,17 @@ class Moderation(Scheduler):
log.debug(f"Unscheduled {infraction_id}.")
del self.scheduled_tasks[infraction_id]
- async def _scheduled_task(self, infraction_object: dict):
+ async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:
"""
- 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, and the expiration task is cancelled.
+ Marks an infraction expired after the delay from time of scheduling to time of expiration.
- :param infraction_object: the infraction in question
+ At the time of expiration, the infraction is marked as inactive on the website, and the
+ expiration task is cancelled. The user is then notified via DM.
"""
-
infraction_id = infraction_object["id"]
# transform expiration to delay in seconds
- expiration_datetime = parse_rfc1123(infraction_object["expires_at"])
+ expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1])
await wait_until(expiration_datetime)
log.debug(f"Marking infraction {infraction_id} as inactive (expired).")
@@ -1204,7 +1124,7 @@ class Moderation(Scheduler):
self.cancel_task(infraction_object["id"])
# Notify the user that they've been unmuted.
- user_id = int(infraction_object["user"]["user_id"])
+ user_id = infraction_object["user"]
guild = self.bot.get_guild(constants.Guild.id)
await self.notify_pardon(
user=guild.get_member(user_id),
@@ -1213,16 +1133,14 @@ class Moderation(Scheduler):
icon_url=Icons.user_unmute
)
- async def _deactivate_infraction(self, infraction_object):
+ async def _deactivate_infraction(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:
"""
- A co-routine which marks an infraction as inactive on the website. This co-routine does
- not cancel or un-schedule an expiration task.
+ A co-routine which marks an infraction as inactive on the website.
- :param infraction_object: the infraction in question
+ This co-routine does not cancel or un-schedule an expiration task.
"""
-
guild: Guild = self.bot.get_guild(constants.Guild.id)
- user_id = int(infraction_object["user"]["user_id"])
+ user_id = infraction_object["user"]
infraction_type = infraction_object["type"]
if infraction_type == "mute":
@@ -1235,24 +1153,29 @@ class Moderation(Scheduler):
log.warning(f"Failed to un-mute user: {user_id} (not found)")
elif infraction_type == "ban":
user: Object = Object(user_id)
- await guild.unban(user)
-
- await self.bot.http_session.patch(
- URLs.site_infractions,
- headers=self.headers,
- json={
- "id": infraction_object["id"],
- "active": False
- }
+ try:
+ await guild.unban(user)
+ except NotFound:
+ log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.")
+
+ await self.bot.api_client.patch(
+ 'bot/infractions/' + str(infraction_object['id']),
+ json={"active": False}
)
- def _infraction_to_string(self, infraction_object):
- actor_id = int(infraction_object["actor"]["user_id"])
+ def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str:
+ """Convert the infraction object to a string representation."""
+ actor_id = infraction_object["actor"]
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
+ active = infraction_object["active"]
+ user_id = infraction_object["user"]
+ hidden = infraction_object["hidden"]
+ created = datetime.fromisoformat(infraction_object["inserted_at"][:-1]).strftime("%Y-%m-%d %H:%M")
+ if infraction_object["expires_at"] is None:
+ expires = "*Permanent*"
+ else:
+ expires = datetime.fromisoformat(infraction_object["expires_at"][:-1]).strftime("%Y-%m-%d %H:%M")
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
@@ -1261,8 +1184,8 @@ class Moderation(Scheduler):
Type: **{infraction_object["type"]}**
Shadow: {hidden}
Reason: {infraction_object["reason"] or "*None*"}
- Created: {infraction_object["inserted_at"]}
- Expires: {infraction_object["expires_at"] or "*Permanent*"}
+ Created: {created}
+ Expires: {expires}
Actor: {actor.mention if actor else actor_id}
ID: `{infraction_object["id"]}`
{"**===============**" if active else "==============="}
@@ -1271,28 +1194,24 @@ class Moderation(Scheduler):
return lines.strip()
async def notify_infraction(
- self, user: Union[User, Member], infr_type: str, duration: str = None,
- reason: str = None
- ):
+ self,
+ user: Union[User, Member],
+ infr_type: str,
+ expires_at: Union[datetime, str] = 'N/A',
+ reason: str = "No reason provided."
+ ) -> bool:
"""
- Notify a user of their fresh infraction :)
+ Attempt to notify a user, via DM, of their fresh infraction.
- :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.
+ Returns a boolean indicator of whether the DM was successful.
"""
-
- if duration is None:
- duration = "N/A"
-
- if reason is None:
- reason = "No reason provided."
+ if isinstance(expires_at, datetime):
+ expires_at = expires_at.strftime('%c')
embed = Embed(
description=textwrap.dedent(f"""
**Type:** {infr_type}
- **Duration:** {duration}
+ **Expires:** {expires_at}
**Reason:** {reason}
"""),
colour=Colour(Colours.soft_red)
@@ -1309,18 +1228,17 @@ class Moderation(Scheduler):
return await self.send_private_embed(user, embed)
async def notify_pardon(
- self, user: Union[User, Member], title: str, content: str,
- icon_url: str = Icons.user_verified
- ):
+ self,
+ user: Union[User, Member],
+ title: str,
+ content: str,
+ icon_url: str = Icons.user_verified
+ ) -> bool:
"""
- Notify a user that an infraction has been lifted.
+ Attempt to notify a user, via DM, of their expired infraction.
- :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.
+ Optionally returns a boolean indicator of whether the DM was successful.
"""
-
embed = Embed(
description=content,
colour=Colour(Colours.soft_green)
@@ -1330,16 +1248,14 @@ class Moderation(Scheduler):
return await self.send_private_embed(user, embed)
- async def send_private_embed(self, user: Union[User, Member], embed: Embed):
+ async def send_private_embed(self, user: Union[User, Member], embed: Embed) -> bool:
"""
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.
+ Returns a boolean indicator of DM success.
"""
-
# sometimes `user` is a `discord.Object`, so let's make it a proper user.
- user = await self.bot.get_user_info(user.id)
+ user = await self.bot.fetch_user(user.id)
try:
await user.send(embed=embed)
@@ -1351,7 +1267,8 @@ class Moderation(Scheduler):
)
return False
- async def log_notify_failure(self, target: str, actor: Member, infraction_type: str):
+ async def log_notify_failure(self, target: str, actor: Member, infraction_type: str) -> None:
+ """Send a mod log entry if an attempt to DM the target user has failed."""
await self.mod_log.send_log_message(
icon_url=Icons.token_removed,
content=actor.mention,
@@ -1365,23 +1282,23 @@ class Moderation(Scheduler):
# endregion
- async def __error(self, ctx, error):
+ @staticmethod
+ async def cog_command_error(ctx: Context, error: Exception) -> None:
+ """Send a notification to the invoking context on a Union failure."""
if isinstance(error, BadUnionArgument):
if User in error.converters:
await ctx.send(str(error.errors[0]))
+ error.handled = True
- async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool:
+ @staticmethod
+ async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool:
"""
Check if the highest role of the invoking member is greater than that of the target member.
+
If this check fails, a warning is sent to the invoking ctx.
Returns True always if target is not a discord.Member instance.
-
- :param ctx: The command context when invoked.
- :param target: The target of the infraction.
- :param infr_type: The type of infraction.
"""
-
if not isinstance(target, Member):
return True
@@ -1400,6 +1317,7 @@ class Moderation(Scheduler):
return target_is_lower
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Moderation cog load."""
bot.add_cog(Moderation(bot))
log.info("Cog loaded: Moderation")
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index b3094321e..68424d268 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -1,9 +1,8 @@
import asyncio
-import datetime
import logging
+from datetime import datetime
from typing import List, Optional, Union
-from aiohttp import ClientResponseError
from dateutil.relativedelta import relativedelta
from deepdiff import DeepDiff
from discord import (
@@ -12,12 +11,10 @@ from discord import (
RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel
)
from discord.abc import GuildChannel
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog, Context
from bot.constants import (
- Channels, Colours, Emojis,
- Event, Guild as GuildConstant, Icons,
- Keys, Roles, URLs
+ Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
)
from bot.utils.time import humanize_delta
@@ -27,77 +24,50 @@ GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]
CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
-MEMBER_CHANGES_SUPPRESSED = ("activity", "status")
+MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status")
ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
-class ModLog:
- """
- Logging for server events and staff actions
- """
+class ModLog(Cog, name="ModLog"):
+ """Logging for server events and staff actions."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
self._ignored = {event: [] for event in Event}
self._cached_deletes = []
self._cached_edits = []
- async def upload_log(self, messages: List[Message]) -> Optional[str]:
+ async def upload_log(self, messages: List[Message], actor_id: int) -> str:
"""
- Uploads the log data to the database via
- an API endpoint for uploading logs.
+ Uploads the log data to the database via an API endpoint for uploading logs.
Used in several mod log embeds.
Returns a URL that can be used to view the log.
"""
-
- log_data = []
-
- for message in messages:
- author = f"{message.author.name}#{message.author.discriminator}"
-
- # message.author may return either a User or a Member. Users don't have roles.
- if type(message.author) is User:
- role_id = Roles.developer
- else:
- role_id = message.author.top_role.id
-
- content = message.content
- embeds = [embed.to_dict() for embed in message.embeds]
- attachments = ["<Attachment>" for _ in message.attachments]
-
- log_data.append({
- "content": content,
- "author": author,
- "user_id": str(message.author.id),
- "role_id": str(role_id),
- "timestamp": message.created_at.strftime("%D %H:%M"),
- "attachments": attachments,
- "embeds": embeds,
- })
-
- response = await self.bot.http_session.post(
- URLs.site_logs_api,
- headers=self.headers,
- json={"log_data": log_data}
+ response = await self.bot.api_client.post(
+ 'bot/deleted-messages',
+ json={
+ 'actor': actor_id,
+ 'creation': datetime.utcnow().isoformat(),
+ 'deletedmessage_set': [
+ {
+ 'id': message.id,
+ 'author': message.author.id,
+ 'channel_id': message.channel.id,
+ 'content': message.content,
+ 'embeds': [embed.to_dict() for embed in message.embeds]
+ }
+ for message in messages
+ ]
+ }
)
- try:
- data = await response.json()
- log_id = data["log_id"]
- except (KeyError, ClientResponseError):
- log.debug(
- "API returned an unexpected result:\n"
- f"{response.text}"
- )
- return
+ return f"{URLs.site_logs_view}/{response['id']}"
- return f"{URLs.site_logs_view}/{log_id}"
-
- def ignore(self, event: Event, *items: int):
+ def ignore(self, event: Event, *items: int) -> None:
+ """Add event to ignored events to suppress log emission."""
for item in items:
if item not in self._ignored[event]:
self._ignored[event].append(item)
@@ -115,17 +85,17 @@ class ModLog:
content: Optional[str] = None,
additional_embeds: Optional[List[Embed]] = None,
additional_embeds_msg: Optional[str] = None,
- timestamp_override: Optional[datetime.datetime] = None,
+ timestamp_override: Optional[datetime] = None,
footer: Optional[str] = None,
- ):
+ ) -> Context:
+ """Generate log embed and send to logging channel."""
embed = Embed(description=text)
if title and icon_url:
embed.set_author(name=title, icon_url=icon_url)
embed.colour = colour
-
- embed.timestamp = timestamp_override or datetime.datetime.utcnow()
+ embed.timestamp = timestamp_override or datetime.utcnow()
if footer:
embed.set_footer(text=footer)
@@ -150,7 +120,9 @@ class ModLog:
return await self.bot.get_context(log_message) # Optionally return for use with antispam
- async def on_guild_channel_create(self, channel: GUILD_CHANNEL):
+ @Cog.listener()
+ async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None:
+ """Log channel create event to mod log."""
if channel.guild.id != GuildConstant.id:
return
@@ -174,7 +146,9 @@ class ModLog:
await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message)
- async def on_guild_channel_delete(self, channel: GUILD_CHANNEL):
+ @Cog.listener()
+ async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None:
+ """Log channel delete event to mod log."""
if channel.guild.id != GuildConstant.id:
return
@@ -195,7 +169,9 @@ class ModLog:
title, message
)
- async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel):
+ @Cog.listener()
+ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None:
+ """Log channel update event to mod log."""
if before.guild.id != GuildConstant.id:
return
@@ -253,7 +229,9 @@ class ModLog:
"Channel updated", message
)
- async def on_guild_role_create(self, role: Role):
+ @Cog.listener()
+ async def on_guild_role_create(self, role: Role) -> None:
+ """Log role create event to mod log."""
if role.guild.id != GuildConstant.id:
return
@@ -262,7 +240,9 @@ class ModLog:
"Role created", f"`{role.id}`"
)
- async def on_guild_role_delete(self, role: Role):
+ @Cog.listener()
+ async def on_guild_role_delete(self, role: Role) -> None:
+ """Log role delete event to mod log."""
if role.guild.id != GuildConstant.id:
return
@@ -271,7 +251,9 @@ class ModLog:
"Role removed", f"{role.name} (`{role.id}`)"
)
- async def on_guild_role_update(self, before: Role, after: Role):
+ @Cog.listener()
+ async def on_guild_role_update(self, before: Role, after: Role) -> None:
+ """Log role update event to mod log."""
if before.guild.id != GuildConstant.id:
return
@@ -322,7 +304,9 @@ class ModLog:
"Role updated", message
)
- async def on_guild_update(self, before: Guild, after: Guild):
+ @Cog.listener()
+ async def on_guild_update(self, before: Guild, after: Guild) -> None:
+ """Log guild update event to mod log."""
if before.id != GuildConstant.id:
return
@@ -371,7 +355,9 @@ class ModLog:
thumbnail=after.icon_url_as(format="png")
)
- async def on_member_ban(self, guild: Guild, member: Union[Member, User]):
+ @Cog.listener()
+ async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None:
+ """Log ban event to mod log."""
if guild.id != GuildConstant.id:
return
@@ -386,12 +372,14 @@ class ModLog:
channel_id=Channels.modlog
)
- async def on_member_join(self, member: Member):
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """Log member join event to user log."""
if member.guild.id != GuildConstant.id:
return
message = f"{member.name}#{member.discriminator} (`{member.id}`)"
- now = datetime.datetime.utcnow()
+ now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
message += "\n\n**Account age:** " + humanize_delta(difference)
@@ -406,7 +394,9 @@ class ModLog:
channel_id=Channels.userlog
)
- async def on_member_remove(self, member: Member):
+ @Cog.listener()
+ async def on_member_remove(self, member: Member) -> None:
+ """Log member leave event to user log."""
if member.guild.id != GuildConstant.id:
return
@@ -421,7 +411,9 @@ class ModLog:
channel_id=Channels.userlog
)
- async def on_member_unban(self, guild: Guild, member: User):
+ @Cog.listener()
+ async def on_member_unban(self, guild: Guild, member: User) -> None:
+ """Log member unban event to mod log."""
if guild.id != GuildConstant.id:
return
@@ -436,7 +428,9 @@ class ModLog:
channel_id=Channels.modlog
)
- async def on_member_update(self, before: Member, after: Member):
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
+ """Log member update event to user log."""
if before.guild.id != GuildConstant.id:
return
@@ -525,7 +519,9 @@ class ModLog:
channel_id=Channels.userlog
)
- async def on_message_delete(self, message: Message):
+ @Cog.listener()
+ async def on_message_delete(self, message: Message) -> None:
+ """Log message delete event to message change log."""
channel = message.channel
author = message.author
@@ -556,19 +552,22 @@ class ModLog:
"\n"
)
+ if message.attachments:
+ # Prepend the message metadata with the number of attachments
+ response = f"**Attachments:** {len(message.attachments)}\n" + response
+
# Shorten the message content if necessary
content = message.clean_content
remaining_chars = 2040 - len(response)
if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
+ botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id)
+ ending = f"\n\nMessage truncated, [full message here]({botlog_url})."
+ truncation_point = remaining_chars - len(ending)
+ content = f"{content[:truncation_point]}...{ending}"
response += f"{content}"
- if message.attachments:
- # Prepend the message metadata with the number of attachments
- response = f"**Attachments:** {len(message.attachments)}\n" + response
-
await self.send_log_message(
Icons.message_delete, Colours.soft_red,
"Message deleted",
@@ -576,7 +575,9 @@ class ModLog:
channel_id=Channels.message_log
)
- async def on_raw_message_delete(self, event: RawMessageDeleteEvent):
+ @Cog.listener()
+ async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None:
+ """Log raw message delete event to message change log."""
if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
return
@@ -615,7 +616,9 @@ class ModLog:
channel_id=Channels.message_log
)
- async def on_message_edit(self, before: Message, after: Message):
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
+ """Log message edit event to message change log."""
if (
not before.guild
or before.guild.id != GuildConstant.id
@@ -688,10 +691,12 @@ class ModLog:
channel_id=Channels.message_log, timestamp_override=after.edited_at
)
- async def on_raw_message_edit(self, event: RawMessageUpdateEvent):
+ @Cog.listener()
+ async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None:
+ """Log raw message edit event to message change log."""
try:
channel = self.bot.get_channel(int(event.data["channel_id"]))
- message = await channel.get_message(event.message_id)
+ message = await channel.fetch_message(event.message_id)
except NotFound: # Was deleted before we got the event
return
@@ -757,6 +762,7 @@ class ModLog:
)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Mod log cog load."""
bot.add_cog(ModLog(bot))
log.info("Cog loaded: ModLog")
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 9b0f5d6c5..8f1af347a 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -1,11 +1,12 @@
import asyncio
+import difflib
import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Bot, Context, Converter, group
+from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group
-from bot.constants import Channels, Keys, MODERATION_ROLES, URLs
+from bot.constants import Channels, MODERATION_ROLES
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -18,7 +19,8 @@ class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
@staticmethod
- async def convert(ctx: Context, argument: str):
+ async def convert(ctx: Context, argument: str) -> str:
+ """Attempt to replace any invalid characters with their approximate Unicode equivalent."""
allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
if not (2 <= len(argument) <= 96):
@@ -37,16 +39,8 @@ class OffTopicName(Converter):
return argument.translate(table)
-async def update_names(bot: Bot, headers: dict):
- """
- The background updater task that performs a channel name update daily.
-
- Args:
- bot (Bot):
- The running bot instance, used for fetching data from the
- website via the bot's `http_session`.
- """
-
+async def update_names(bot: Bot) -> None:
+ """Background updater task that performs the daily channel name update."""
while True:
# Since we truncate the compute timedelta to seconds, we add one second to ensure
# we go past midnight in the `seconds_to_sleep` set below.
@@ -55,11 +49,9 @@ async def update_names(bot: Bot, headers: dict):
seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1
await asyncio.sleep(seconds_to_sleep)
- response = await bot.http_session.get(
- f'{URLs.site_off_topic_names_api}?random_items=3',
- headers=headers
+ channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
+ 'bot/off-topic-channel-names', params={'random_items': 3}
)
- channel_0_name, channel_1_name, channel_2_name = await response.json()
channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS)
await channel_0.edit(name=f'ot0-{channel_0_name}')
@@ -71,101 +63,100 @@ async def update_names(bot: Bot, headers: dict):
)
-class OffTopicNames:
+class OffTopicNames(Cog):
"""Commands related to managing the off-topic category channel names."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
self.updater_task = None
- def __cleanup(self):
+ def cog_unload(self) -> None:
+ """Cancel any running updater tasks on cog unload."""
if self.updater_task is not None:
self.updater_task.cancel()
- async def on_ready(self):
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Start off-topic channel updating event loop if it hasn't already started."""
if self.updater_task is None:
- coro = update_names(self.bot, self.headers)
+ coro = update_names(self.bot)
self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
@with_role(*MODERATION_ROLES)
- async def otname_group(self, ctx):
+ async def otname_group(self, ctx: Context) -> None:
"""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(*MODERATION_ROLES)
- async def add_command(self, ctx, name: OffTopicName):
+ async def add_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Adds a new off-topic name to the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
- result = await self.bot.http_session.post(
- URLs.site_off_topic_names_api,
- headers=self.headers,
- params={'name': name}
+ await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name})
+ log.info(
+ f"{ctx.author.name}#{ctx.author.discriminator}"
+ f" added the off-topic channel name '{name}"
)
-
- response = await result.json()
-
- if result.status == 200:
- log.info(
- f"{ctx.author.name}#{ctx.author.discriminator}"
- f" added the off-topic channel name '{name}"
- )
- await ctx.send(":ok_hand:")
- else:
- error_reason = response.get('message', "No reason provided.")
- await ctx.send(f":warning: got non-200 from the API: {error_reason}")
+ await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
@with_role(*MODERATION_ROLES)
- async def delete_command(self, ctx, name: OffTopicName):
+ async def delete_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
- result = await self.bot.http_session.delete(
- URLs.site_off_topic_names_api,
- headers=self.headers,
- params={'name': name}
+ await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
+ log.info(
+ f"{ctx.author.name}#{ctx.author.discriminator}"
+ f" deleted the off-topic channel name '{name}"
)
-
- response = await result.json()
-
- if result.status == 200:
- if response['deleted'] == 0:
- await ctx.send(f":warning: No name matching `{name}` was found in the database.")
- else:
- log.info(
- f"{ctx.author.name}#{ctx.author.discriminator}"
- f" deleted the off-topic channel name '{name}"
- )
- await ctx.send(":ok_hand:")
- else:
- error_reason = response.get('message', "No reason provided.")
- await ctx.send(f":warning: got non-200 from the API: {error_reason}")
+ await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
@with_role(*MODERATION_ROLES)
- async def list_command(self, ctx):
+ async def list_command(self, ctx: Context) -> None:
"""
Lists all currently known off-topic channel names in a paginator.
+
Restricted to Moderator and above to not spoil the surprise.
"""
-
- result = await self.bot.http_session.get(
- URLs.site_off_topic_names_api,
- headers=self.headers
+ result = await self.bot.api_client.get('bot/off-topic-channel-names')
+ lines = sorted(f"• {name}" for name in result)
+ embed = Embed(
+ title=f"Known off-topic names (`{len(result)}` total)",
+ colour=Colour.blue()
)
- response = await result.json()
- lines = sorted(f"• {name}" for name in response)
+ if result:
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ embed.description = "Hmmm, seems like there's nothing here yet."
+ await ctx.send(embed=embed)
+ @otname_group.command(name='search', aliases=('s',))
+ @with_role(*MODERATION_ROLES)
+ async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:
+ """Search for an off-topic name."""
+ result = await self.bot.api_client.get('bot/off-topic-channel-names')
+ in_matches = {name for name in result if query in name}
+ close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70)
+ lines = sorted(f"• {name}" for name in in_matches.union(close_matches))
embed = Embed(
- title=f"Known off-topic names (`{len(response)}` total)",
+ title=f"Query results",
colour=Colour.blue()
)
- await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+
+ if lines:
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ embed.description = "Nothing found."
+ await ctx.send(embed=embed)
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Off topic names cog load."""
bot.add_cog(OffTopicNames(bot))
log.info("Cog loaded: OffTopicNames")
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index b5bd26e3d..63a57c5c6 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -3,9 +3,10 @@ import logging
import random
import textwrap
from datetime import datetime, timedelta
+from typing import List
-from discord import Colour, Embed, TextChannel
-from discord.ext.commands import Bot, Context, group
+from discord import Colour, Embed, Message, TextChannel
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES
from bot.converters import Subreddit
@@ -15,10 +16,8 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Reddit:
- """
- Track subreddit posts and show detailed statistics about them.
- """
+class Reddit(Cog):
+ """Track subreddit posts and show detailed statistics about them."""
HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}
URL = "https://www.reddit.com"
@@ -34,11 +33,8 @@ class Reddit:
self.new_posts_task = None
self.top_weekly_posts_task = None
- async def fetch_posts(self, route: str, *, amount: int = 25, params=None):
- """
- A helper method to fetch a certain amount of Reddit posts at a given route.
- """
-
+ async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]:
+ """A helper method to fetch a certain amount of Reddit posts at a given route."""
# Reddit's JSON responses only provide 25 posts at most.
if not 25 >= amount > 0:
raise ValueError("Invalid amount of subreddit posts requested.")
@@ -57,11 +53,10 @@ class Reddit:
return posts[:amount]
- async def send_top_posts(self, channel: TextChannel, subreddit: Subreddit, content=None, time="all"):
- """
- Create an embed for the top posts, then send it in a given TextChannel.
- """
-
+ async def send_top_posts(
+ self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all"
+ ) -> Message:
+ """Create an embed for the top posts, then send it in a given TextChannel."""
# Create the new spicy embed.
embed = Embed()
embed.description = ""
@@ -115,11 +110,8 @@ class Reddit:
embed=embed
)
- async def poll_new_posts(self):
- """
- Periodically search for new subreddit posts.
- """
-
+ async def poll_new_posts(self) -> None:
+ """Periodically search for new subreddit posts."""
while True:
await asyncio.sleep(RedditConfig.request_delay)
@@ -179,11 +171,8 @@ class Reddit:
log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.")
- async def poll_top_weekly_posts(self):
- """
- Post a summary of the top posts every week.
- """
-
+ async def poll_top_weekly_posts(self) -> None:
+ """Post a summary of the top posts every week."""
while True:
now = datetime.utcnow()
@@ -214,19 +203,13 @@ class Reddit:
await message.pin()
@group(name="reddit", invoke_without_command=True)
- async def reddit_group(self, ctx: Context):
- """
- View the top posts from various subreddits.
- """
-
+ async def reddit_group(self, ctx: Context) -> None:
+ """View the top posts from various subreddits."""
await ctx.invoke(self.bot.get_command("help"), "reddit")
@reddit_group.command(name="top")
- async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of all time from a given subreddit.
- """
-
+ async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of all time from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -235,11 +218,8 @@ class Reddit:
)
@reddit_group.command(name="daily")
- async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of today from a given subreddit.
- """
-
+ async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of today from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -248,11 +228,8 @@ class Reddit:
)
@reddit_group.command(name="weekly")
- async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of this week from a given subreddit.
- """
-
+ async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of this week from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -262,11 +239,8 @@ class Reddit:
@with_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
- async def subreddits_command(self, ctx: Context):
- """
- Send a paginated embed of all the subreddits we're relaying.
- """
-
+ async def subreddits_command(self, ctx: Context) -> None:
+ """Send a paginated embed of all the subreddits we're relaying."""
embed = Embed()
embed.title = "Relayed subreddits."
embed.colour = Colour.blurple()
@@ -279,8 +253,10 @@ class Reddit:
max_lines=15
)
- async def on_ready(self):
- self.reddit_channel = self.bot.get_channel(Channels.reddit)
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Initiate reddit post event loop."""
+ self.reddit_channel = await self.bot.fetch_channel(Channels.reddit)
if self.reddit_channel is not None:
if self.new_posts_task is None:
@@ -291,6 +267,7 @@ class Reddit:
log.warning("Couldn't locate a channel for subreddit relaying.")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Reddit cog load."""
bot.add_cog(Reddit(bot))
log.info("Cog loaded: Reddit")
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index e8177107b..8460de91f 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -1,22 +1,21 @@
import asyncio
-import datetime
import logging
import random
import textwrap
+from datetime import datetime
+from operator import itemgetter
+from typing import Optional
-from aiohttp import ClientResponseError
from dateutil.relativedelta import relativedelta
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord import Colour, Embed, Message
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import (
- Channels, Icons, Keys, NEGATIVE_REPLIES,
- POSITIVE_REPLIES, STAFF_ROLES, URLs
-)
+from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
+from bot.converters import ExpirationDate
from bot.pagination import LinePaginator
from bot.utils.checks import without_role_check
from bot.utils.scheduling import Scheduler
-from bot.utils.time import humanize_delta, parse_rfc1123, wait_until
+from bot.utils.time import humanize_delta, wait_until
log = logging.getLogger(__name__)
@@ -24,28 +23,26 @@ WHITELISTED_CHANNELS = (Channels.bot,)
MAXIMUM_REMINDERS = 5
-class Reminders(Scheduler):
+class Reminders(Scheduler, Cog):
+ """Provide in-channel reminder functionality."""
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
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Get all current reminders from the API and reschedule them."""
+ response = await self.bot.api_client.get(
+ 'bot/reminders',
+ params={'active': 'true'}
)
- response_data = await response.json()
-
- # Find the current time, timezone-aware.
- now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+ now = datetime.utcnow()
loop = asyncio.get_event_loop()
- for reminder in response_data["reminders"]:
- remind_at = parse_rfc1123(reminder["remind_at"])
+ for reminder in response:
+ remind_at = datetime.fromisoformat(reminder['expiration'][:-1])
# If the reminder is already overdue ...
if remind_at < now:
@@ -56,43 +53,18 @@ class Reminders(Scheduler):
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)
- """
-
+ async def _send_confirmation(ctx: Context, on_success: str) -> None:
+ """Send an embed confirming the reminder change was made successfully."""
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
-
+ embed.colour = Colour.green()
+ embed.title = random.choice(POSITIVE_REPLIES)
+ embed.description = on_success
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:
- """
+ async def _scheduled_task(self, reminder: dict) -> None:
+ """A coroutine which sends the reminder once the time is reached, and cancels the running task."""
reminder_id = reminder["id"]
- reminder_datetime = parse_rfc1123(reminder["remind_at"])
+ reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1])
# Send the reminder message once the desired duration has passed
await wait_until(reminder_datetime)
@@ -104,51 +76,24 @@ class Reminders(Scheduler):
# 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
- )
+ async def _delete_reminder(self, reminder_id: str) -> None:
+ """Delete a reminder from the database, given its ID, and cancel the running task."""
+ await self.bot.api_client.delete('bot/reminders/' + str(reminder_id))
# 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.
- """
-
+ async def _reschedule_reminder(self, reminder: dict) -> None:
+ """Reschedule a reminder object."""
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"]))
+ async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:
+ """Send the reminder."""
+ channel = self.bot.get_channel(reminder["channel_id"])
+ user = self.bot.get_user(reminder["author"])
embed = Embed()
embed.colour = Colour.blurple()
@@ -173,19 +118,17 @@ class Reminders(Scheduler):
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)
+ async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None:
+ """Commands for managing your reminders."""
+ await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
@remind_group.command(name="new", aliases=("add", "create"))
- async def new_reminder(self, ctx: Context, duration: str, *, content: str):
+ async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> Optional[Message]:
"""
Set yourself a simple reminder.
- """
+ Expiration is parsed per: http://strftime.org/
+ """
embed = Embed()
# If the user is not staff, we need to verify whether or not to make a reminder at all.
@@ -200,13 +143,13 @@ class Reminders(Scheduler):
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 self.bot.api_client.get(
+ 'bot/reminders',
+ params={
+ 'user__id': str(ctx.author.id)
+ }
)
- 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:
@@ -217,78 +160,53 @@ class Reminders(Scheduler):
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!"
+ reminder = await self.bot.api_client.post(
+ 'bot/reminders',
+ json={
+ 'author': ctx.author.id,
+ 'channel_id': ctx.message.channel.id,
+ 'content': content,
+ 'expiration': expiration.isoformat()
+ }
)
- # If it worked, schedule the reminder.
- if not failed:
- loop = asyncio.get_event_loop()
- reminder = response_data["reminder"]
+ # Confirm to the user that it worked.
+ await self._send_confirmation(
+ ctx, on_success="Your reminder has been created successfully!"
+ )
- self.schedule_task(loop, reminder["id"], reminder)
+ loop = asyncio.get_event_loop()
+ 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.
- """
-
+ async def list_reminders(self, ctx: Context) -> Optional[Message]:
+ """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 self.bot.api_client.get(
+ 'bot/reminders',
+ params={'user__id': str(ctx.author.id)}
)
- data = await response.json()
- now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
+ now = datetime.utcnow()
# 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])
+ reminders = sorted(
+ (
+ (rem['content'], rem['expiration'], rem['id'])
+ for rem in data
+ ),
+ key=itemgetter(1)
+ )
lines = []
- for index, (content, remind_at, friendly_id) in enumerate(reminders):
+ for content, remind_at, id_ in reminders:
# Parse and humanize the time, make it pretty :D
- remind_datetime = parse_rfc1123(remind_at)
+ remind_datetime = datetime.fromisoformat(remind_at[:-1])
time = humanize_delta(relativedelta(remind_datetime, now))
text = textwrap.dedent(f"""
- **Reminder #{index}:** *expires in {time}* (ID: {friendly_id})
+ **Reminder #{id_}:** *expires in {time}* (ID: {id_})
{content}
""").strip()
@@ -314,93 +232,55 @@ class Reminders(Scheduler):
)
@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.
- """
-
+ async def edit_reminder_group(self, ctx: Context) -> None:
+ """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.
+ async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate) -> None:
"""
+ Edit one of your reminder's expiration.
+ Expiration is parsed per: http://strftime.org/
+ """
# 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
- }
+ reminder = await self.bot.api_client.patch(
+ 'bot/reminders/' + str(id_),
+ json={'expiration': expiration.isoformat()}
)
# 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!"
+ await self._send_confirmation(
+ ctx, on_success="That reminder has been edited successfully!"
)
- if not failed:
- await self._reschedule_reminder(response_data["reminder"])
+ await self._reschedule_reminder(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.
- """
-
+ async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None:
+ """Edit one of your reminder's 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
- }
+ reminder = await self.bot.api_client.patch(
+ 'bot/reminders/' + str(id_),
+ json={'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!"
+ await self._send_confirmation(
+ ctx, on_success="That reminder has been edited successfully!"
)
-
- if not failed:
- await self._reschedule_reminder(response_data["reminder"])
+ await self._reschedule_reminder(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!"
+ async def delete_reminder(self, ctx: Context, id_: int) -> None:
+ """Delete one of your active reminders."""
+ await self._delete_reminder(id_)
+ await self._send_confirmation(
+ ctx, on_success="That reminder has been deleted successfully!"
)
- if not failed:
- await self._delete_reminder(response_data["reminder_id"])
-
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Reminders cog load."""
bot.add_cog(Reminders(bot))
log.info("Cog loaded: Reminders")
diff --git a/bot/cogs/rmq.py b/bot/cogs/rmq.py
deleted file mode 100644
index 585eacc25..000000000
--- a/bot/cogs/rmq.py
+++ /dev/null
@@ -1,229 +0,0 @@
-import asyncio
-import datetime
-import json
-import logging
-import pprint
-
-import aio_pika
-from aio_pika import Message
-from dateutil import parser as date_parser
-from discord import Colour, Embed
-from discord.ext.commands import Bot
-from discord.utils import get
-
-from bot.constants import Channels, Guild, RabbitMQ
-
-log = logging.getLogger(__name__)
-
-LEVEL_COLOURS = {
- "debug": Colour.blue(),
- "info": Colour.green(),
- "warning": Colour.gold(),
- "error": Colour.red()
-}
-
-DEFAULT_LEVEL_COLOUR = Colour.greyple()
-EMBED_PARAMS = (
- "colour", "title", "url", "description", "timestamp"
-)
-
-CONSUME_TIMEOUT = datetime.timedelta(seconds=10)
-
-
-class RMQ:
- """
- RabbitMQ event handling
- """
-
- rmq = None # type: aio_pika.Connection
- channel = None # type: aio_pika.Channel
- queue = None # type: aio_pika.Queue
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- async def on_ready(self):
- self.rmq = await aio_pika.connect_robust(
- host=RabbitMQ.host, port=RabbitMQ.port, login=RabbitMQ.username, password=RabbitMQ.password
- )
-
- log.info("Connected to RabbitMQ")
-
- self.channel = await self.rmq.channel()
- self.queue = await self.channel.declare_queue("bot_events", durable=True)
-
- log.debug("Channel opened, queue declared")
-
- async for message in self.queue:
- with message.process():
- message.ack()
- await self.handle_message(message, message.body.decode())
-
- async def send_text(self, queue: str, data: str):
- message = Message(data.encode("utf-8"))
- await self.channel.default_exchange.publish(message, queue)
-
- async def send_json(self, queue: str, **data):
- message = Message(json.dumps(data).encode("utf-8"))
- await self.channel.default_exchange.publish(message, queue)
-
- async def consume(self, queue: str, **kwargs):
- queue_obj = await self.channel.declare_queue(queue, **kwargs)
-
- result = None
- start_time = datetime.datetime.now()
-
- while result is None:
- if datetime.datetime.now() - start_time >= CONSUME_TIMEOUT:
- result = "Timed out while waiting for a response."
- else:
- result = await queue_obj.get(timeout=5, fail=False)
- await asyncio.sleep(0.5)
-
- if result:
- result.ack()
-
- return result
-
- async def handle_message(self, message, data):
- log.debug(f"Message: {message}")
- log.debug(f"Data: {data}")
-
- try:
- data = json.loads(data)
- except Exception:
- await self.do_mod_log("error", "Unable to parse event", data)
- else:
- event = data["event"]
- event_data = data["data"]
-
- try:
- func = getattr(self, f"do_{event}")
- await func(**event_data)
- except Exception as e:
- await self.do_mod_log(
- "error", f"Unable to handle event: {event}",
- str(e)
- )
-
- async def do_mod_log(self, level: str, title: str, message: str):
- colour = LEVEL_COLOURS.get(level, DEFAULT_LEVEL_COLOUR)
- embed = Embed(
- title=title, description=f"```\n{message}\n```",
- colour=colour, timestamp=datetime.datetime.utcnow()
- )
-
- await self.bot.get_channel(Channels.devlog).send(embed=embed)
- log.log(logging._nameToLevel[level.upper()], f"Modlog: {title} | {message}")
-
- async def do_send_message(self, target: int, message: str):
- channel = self.bot.get_channel(target)
-
- if channel is None:
- await self.do_mod_log(
- "error", "Failed: Send Message",
- f"Unable to find channel: {target}"
- )
- else:
- await channel.send(message)
-
- await self.do_mod_log(
- "info", "Succeeded: Send Embed",
- f"Message sent to channel {target}\n\n{message}"
- )
-
- async def do_send_embed(self, target: int, **embed_params):
- for param, value in list(embed_params.items()): # To keep a full copy
- if param not in EMBED_PARAMS:
- await self.do_mod_log(
- "warning", "Warning: Send Embed",
- f"Unknown embed parameter: {param}"
- )
- del embed_params[param]
-
- if param == "timestamp":
- embed_params[param] = date_parser.parse(value)
- elif param == "colour":
- embed_params[param] = Colour(value)
-
- channel = self.bot.get_channel(target)
-
- if channel is None:
- await self.do_mod_log(
- "error", "Failed: Send Embed",
- f"Unable to find channel: {target}"
- )
- else:
- await channel.send(embed=Embed(**embed_params))
-
- await self.do_mod_log(
- "info", "Succeeded: Send Embed",
- f"Embed sent to channel {target}\n\n{pprint.pformat(embed_params, 4)}"
- )
-
- async def do_add_role(self, target: int, role_id: int, reason: str):
- guild = self.bot.get_guild(Guild.id)
- member = guild.get_member(int(target))
-
- if member is None:
- return await self.do_mod_log(
- "error", "Failed: Add Role",
- f"Unable to find member: {target}"
- )
-
- role = get(guild.roles, id=int(role_id))
-
- if role is None:
- return await self.do_mod_log(
- "error", "Failed: Add Role",
- f"Unable to find role: {role_id}"
- )
-
- try:
- await member.add_roles(role, reason=reason)
- except Exception as e:
- await self.do_mod_log(
- "error", "Failed: Add Role",
- f"Error while adding role {role.name}: {e}"
- )
- else:
- await self.do_mod_log(
- "info", "Succeeded: Add Role",
- f"Role {role.name} added to member {target}"
- )
-
- async def do_remove_role(self, target: int, role_id: int, reason: str):
- guild = self.bot.get_guild(Guild.id)
- member = guild.get_member(int(target))
-
- if member is None:
- return await self.do_mod_log(
- "error", "Failed: Remove Role",
- f"Unable to find member: {target}"
- )
-
- role = get(guild.roles, id=int(role_id))
-
- if role is None:
- return await self.do_mod_log(
- "error", "Failed: Remove Role",
- f"Unable to find role: {role_id}"
- )
-
- try:
- await member.remove_roles(role, reason=reason)
- except Exception as e:
- await self.do_mod_log(
- "error", "Failed: Remove Role",
- f"Error while adding role {role.name}: {e}"
- )
- else:
- await self.do_mod_log(
- "info", "Succeeded: Remove Role",
- f"Role {role.name} removed from member {target}"
- )
-
-
-def setup(bot):
- bot.add_cog(RMQ(bot))
- log.info("Cog loaded: RMQ")
diff --git a/bot/cogs/rules.py b/bot/cogs/rules.py
deleted file mode 100644
index b8a26ff76..000000000
--- a/bot/cogs/rules.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import re
-from typing import Optional
-
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, command
-
-from bot.constants import Channels, STAFF_ROLES
-from bot.decorators import redirect_output
-from bot.pagination import LinePaginator
-
-
-class Rules:
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- # We'll get the rules from the API when the
- # site has been updated to the Django Framework.
- # Hard-code the rules for now until the new RulesView is released.
-
- self.rules = (
- "Be polite, and do not spam.",
-
- "Follow the [Discord Community Guidelines](https://discordapp.com/guidelines).",
-
- "Don't intentionally make other people uncomfortable - if someone asks you to stop "
- "discussing something, you should stop.",
-
- "Be patient both with users asking questions, and the users answering them.",
-
- "We will not help you with anything that might break a law or the terms of service "
- "of any other community, site, service, or otherwise - No piracy, brute-forcing, "
- "captcha circumvention, sneaker bots, or anything else of that nature.",
-
- "Listen to and respect the staff members - we're here to help, but we're all human "
- "beings.",
-
- "All discussion should be kept within the relevant channels for the subject - See the "
- "[channels page](https://pythondiscord.com/about/channels) for more information.",
-
- "This is an English-speaking server, so please speak English to the best of your "
- "ability - [Google Translate](https://translate.google.com/) should be fine if you're "
- "not sure.",
-
- "Keep all discussions safe for work - No gore, nudity, sexual soliciting, references "
- "to suicide, or anything else of that nature",
-
- "We do not allow advertisements for communities (including other Discord servers) or "
- "commercial projects - Contact us directly if you want to discuss a partnership!"
- )
- self.default_desc = ("The rules and guidelines that apply to this community can be found on"
- " our [rules page](https://pythondiscord.com/about/rules). We expect"
- " all members of the community to have read and understood these."
- )
- self.title_link = 'https://pythondiscord.com/about/rules'
-
- @command(aliases=['r', 'rule'], name='rules')
- @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
- async def rules_command(self, ctx: Context, *, rules: Optional[str] = None):
- """
- Provides a link to the `rules` endpoint of the website, or displays
- specific rules, if they are requested.
-
- **`ctx`:** The Discord message context
- **`rules`:** The rules a user wants to get.
- """
- rules_embed = Embed(title='Rules', color=Colour.blurple())
-
- if not rules:
- # Rules were not submitted. Return the default description.
- rules_embed.description = self.default_desc
- rules_embed.url = 'https://pythondiscord.com/about/rules'
- return await ctx.send(embed=rules_embed)
-
- # Split the rules input by slash, comma or space
- # Returns a list of ints if they're in range of rules index
- rules_to_get = []
- split_rules = re.split(r'[/, ]', rules)
- for item in split_rules:
- if not item.isdigit():
- if not item:
- continue
- rule_match = re.search(r'\d?\d[:|-]1?\d', item)
- if rule_match:
- a, b = sorted([int(x)-1 for x in re.split(r'[:-]', rule_match.group())])
- rules_to_get.extend(range(a, b+1))
- else:
- rules_to_get.append(int(item)-1)
- final_rules = [
- f'**{i+1}.** {self.rules[i]}' for i in sorted(rules_to_get) if i < len(self.rules)
- ]
-
- if not final_rules:
- # No valid rules in rules input. Return the default description.
- rules_embed.description = self.default_desc
- return await ctx.send(embed=rules_embed)
- await LinePaginator.paginate(
- final_rules, ctx, rules_embed,
- max_lines=3, url=self.title_link
- )
-
-
-def setup(bot):
- bot.add_cog(Rules(bot))
diff --git a/bot/cogs/security.py b/bot/cogs/security.py
index f4a843fbf..316b33d6b 100644
--- a/bot/cogs/security.py
+++ b/bot/cogs/security.py
@@ -1,27 +1,30 @@
import logging
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage
log = logging.getLogger(__name__)
-class Security:
- """
- Security-related helpers
- """
+class Security(Cog):
+ """Security-related helpers."""
def __init__(self, bot: Bot):
self.bot = bot
self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all
self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM
- def check_not_bot(self, ctx: Context):
+ def check_not_bot(self, ctx: Context) -> bool:
+ """Check if the context is a bot user."""
return not ctx.author.bot
- def check_on_guild(self, ctx: Context):
- return ctx.guild is not None
+ def check_on_guild(self, ctx: Context) -> bool:
+ """Check if the context is in a guild."""
+ if ctx.guild is None:
+ raise NoPrivateMessage("This command cannot be used in private messages.")
+ return True
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Security cog load."""
bot.add_cog(Security(bot))
log.info("Cog loaded: Security")
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index e5fd645fb..4a423faa9 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -1,31 +1,31 @@
import logging
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import URLs
+from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.decorators import redirect_output
+from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-INFO_URL = f"{URLs.site_schema}{URLs.site}/info"
+PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages"
-class Site:
+class Site(Cog):
"""Commands for linking to different parts of the site."""
def __init__(self, bot: Bot):
self.bot = bot
@group(name="site", aliases=("s",), invoke_without_command=True)
- async def site_group(self, ctx):
+ async def site_group(self, ctx: Context) -> None:
"""Commands for getting info about our website."""
-
await ctx.invoke(self.bot.get_command("help"), "site")
@site_group.command(name="home", aliases=("about",))
- async def site_main(self, ctx: Context):
+ async def site_main(self, ctx: Context) -> None:
"""Info about the website itself."""
-
url = f"{URLs.site_schema}{URLs.site}/"
embed = Embed(title="Python Discord website")
@@ -41,29 +41,30 @@ class Site:
await ctx.send(embed=embed)
@site_group.command(name="resources")
- async def site_resources(self, ctx: Context):
+ async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
+ learning_url = f"{PAGES_URL}/resources"
+ tools_url = f"{PAGES_URL}/tools"
- url = f"{INFO_URL}/resources"
-
- embed = Embed(title="Resources")
- embed.set_footer(text=url)
+ embed = Embed(title="Resources & Tools")
+ embed.set_footer(text=f"{learning_url} | {tools_url}")
embed.colour = Colour.blurple()
embed.description = (
- f"The [Resources page]({url}) on our website contains a "
+ f"The [Resources page]({learning_url}) on our website contains a "
"list of hand-selected goodies that we regularly recommend "
- "to both beginners and experts."
+ f"to both beginners and experts. The [Tools page]({tools_url}) "
+ "contains a couple of the most popular tools for programming in "
+ "Python."
)
await ctx.send(embed=embed)
@site_group.command(name="help")
- async def site_help(self, ctx: Context):
+ async def site_help(self, ctx: Context) -> None:
"""Info about the site's Getting Help page."""
+ url = f"{PAGES_URL}/asking-good-questions"
- url = f"{INFO_URL}/help"
-
- embed = Embed(title="Getting Help")
+ embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
embed.colour = Colour.blurple()
embed.description = (
@@ -75,10 +76,9 @@ class Site:
await ctx.send(embed=embed)
@site_group.command(name="faq")
- async def site_faq(self, ctx: Context):
+ async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
-
- url = f"{INFO_URL}/faq"
+ url = f"{PAGES_URL}/frequently-asked-questions"
embed = Embed(title="FAQ")
embed.set_footer(text=url)
@@ -92,7 +92,42 @@ class Site:
await ctx.send(embed=embed)
+ @site_group.command(aliases=['r', 'rule'], name='rules')
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def site_rules(self, ctx: Context, *rules: int) -> None:
+ """Provides a link to all rules or, if specified, displays specific rule(s)."""
+ rules_embed = Embed(title='Rules', color=Colour.blurple())
+ rules_embed.url = f"{PAGES_URL}/rules"
+
+ if not rules:
+ # Rules were not submitted. Return the default description.
+ rules_embed.description = (
+ "The rules and guidelines that apply to this community can be found on"
+ f" our [rules page]({PAGES_URL}/rules). We expect"
+ " all members of the community to have read and understood these."
+ )
+
+ await ctx.send(embed=rules_embed)
+ return
+
+ full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'})
+ invalid_indices = tuple(
+ pick
+ for pick in rules
+ if pick < 0 or pick >= len(full_rules)
+ )
+
+ if invalid_indices:
+ indices = ', '.join(map(str, invalid_indices))
+ await ctx.send(f":x: Invalid rule indices {indices}")
+ return
+
+ final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules)
+
+ await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
+
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Site cog load."""
bot.add_cog(Site(bot))
log.info("Cog loaded: Site")
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index cc18c0041..5accbdb5e 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -1,39 +1,19 @@
import datetime
import logging
-import random
import re
import textwrap
+from signal import Signals
+from typing import Optional, Tuple
-from discord import Colour, Embed
-from discord.ext.commands import (
- Bot, CommandError, Context, NoPrivateMessage, command, guild_only
-)
+from discord.ext.commands import Bot, Cog, Context, command, guild_only
-from bot.cogs.rmq import RMQ
-from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.decorators import in_channel
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
-RMQ_ARGS = {
- "durable": False,
- "arguments": {"x-message-ttl": 5000},
- "auto_delete": True
-}
-
-CODE_TEMPLATE = """
-venv_file = "/snekbox/.venv/bin/activate_this.py"
-exec(open(venv_file).read(), dict(__file__=venv_file))
-
-try:
-{CODE}
-except:
- import traceback
- print(traceback.format_exc())
-"""
-
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
FORMATTED_CODE_REGEX = re.compile(
r"^\s*" # any leading whitespace from the beginning of the string
@@ -53,42 +33,45 @@ RAW_CODE_REGEX = re.compile(
re.DOTALL # "." also matches newlines
)
+MAX_PASTE_LEN = 1000
-class Snekbox:
- """
- Safe evaluation using Snekbox
- """
+
+class Snekbox(Cog):
+ """Safe evaluation of Python code using Snekbox."""
def __init__(self, bot: Bot):
self.bot = bot
self.jobs = {}
- @property
- def rmq(self) -> RMQ:
- return self.bot.get_cog("RMQ")
+ async def post_eval(self, code: str) -> dict:
+ """Send a POST request to the Snekbox API to evaluate code and return the results."""
+ url = URLs.snekbox_eval_api
+ data = {"input": code}
+ async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp:
+ return await resp.json()
- @command(name='eval', aliases=('e',))
- @guild_only()
- @in_channel(Channels.bot, bypass_roles=STAFF_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
- manage to find an issue with it!
-
- This command supports multiple lines of code, including code wrapped inside a formatted code block.
- """
-
- if ctx.author.id in self.jobs:
- await ctx.send(f"{ctx.author.mention} You've already got a job running - please wait for it to finish!")
- return
-
- if not code: # None or empty string
- return await ctx.invoke(self.bot.get_command("help"), "eval")
+ async def upload_output(self, output: str) -> Optional[str]:
+ """Upload the eval output to a paste service and return a URL to it if successful."""
+ log.trace("Uploading full output to paste service...")
- log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}")
- self.jobs[ctx.author.id] = datetime.datetime.now()
+ if len(output) > MAX_PASTE_LEN:
+ log.info("Full output is too long to upload")
+ return "too long to upload"
- # Strip whitespace and inline or block code markdown and extract the code and some formatting info
+ url = URLs.paste_service.format(key="documents")
+ try:
+ async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp:
+ data = await resp.json()
+
+ if "key" in data:
+ return URLs.paste_service.format(key=data["key"])
+ except Exception:
+ # 400 (Bad Request) means there are too many characters
+ log.exception("Failed to upload full output to paste service!")
+
+ @staticmethod
+ def prepare_input(code: str) -> str:
+ """Extract code from the Markdown, format it, and insert it into the code template."""
match = FORMATTED_CODE_REGEX.fullmatch(code)
if match:
code, block, lang, delim = match.group("code", "block", "lang", "delim")
@@ -100,112 +83,145 @@ class Snekbox:
log.trace(f"Extracted {info} for evaluation:\n{code}")
else:
code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code"))
- log.trace(f"Eval message contains not or badly formatted code, stripping whitespace only:\n{code}")
+ log.trace(
+ f"Eval message contains unformatted or badly formatted code, "
+ f"stripping whitespace only:\n{code}"
+ )
- try:
- stripped_lines = [ln.strip() for ln in code.split('\n')]
- if all(line.startswith('#') for line in stripped_lines):
- return await ctx.send(
- f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```"
- )
+ return code
+
+ @staticmethod
+ def get_results_message(results: dict) -> Tuple[str, str]:
+ """Return a user-friendly message and error corresponding to the process's return code."""
+ stdout, returncode = results["stdout"], results["returncode"]
+ msg = f"Your eval job has completed with return code {returncode}"
+ error = ""
+
+ if returncode is None:
+ msg = "Your eval job has failed"
+ error = stdout.strip()
+ elif returncode == 128 + Signals.SIGKILL:
+ msg = "Your eval job timed out or ran out of memory"
+ elif returncode == 255:
+ msg = "Your eval job has failed"
+ error = "A fatal NsJail error occurred"
+ else:
+ # Try to append signal's name if one exists
+ try:
+ name = Signals(returncode - 128).name
+ msg = f"{msg} ({name})"
+ except ValueError:
+ pass
- code = textwrap.indent(code, " ")
- code = CODE_TEMPLATE.replace("{CODE}", code)
+ return msg, error
- await self.rmq.send_json(
- "input",
- snekid=str(ctx.author.id), message=code
- )
+ async def format_output(self, output: str) -> Tuple[str, Optional[str]]:
+ """
+ Format the output and return a tuple of the formatted output and a URL to the full output.
- async with ctx.typing():
- message = await self.rmq.consume(str(ctx.author.id), **RMQ_ARGS)
- paste_link = None
+ Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters
+ and upload the full output to a paste service.
+ """
+ log.trace("Formatting output...")
- if isinstance(message, str):
- output = str.strip(" \n")
- else:
- output = message.body.decode().strip(" \n")
+ output = output.strip(" \n")
+ original_output = output # To be uploaded to a pasting service if needed
+ paste_link = None
- if "<@" in output:
- output = output.replace("<@", "<@\u200B") # Zero-width space
+ if "<@" in output:
+ output = output.replace("<@", "<@\u200B") # Zero-width space
- if "<!@" in output:
- output = output.replace("<!@", "<!@\u200B") # Zero-width space
+ if "<!@" in output:
+ output = output.replace("<!@", "<!@\u200B") # Zero-width space
- if ESCAPE_REGEX.findall(output):
- output = "Code block escape attempt detected; will not output result"
- else:
- # the original output, to send to a pasting service if needed
- full_output = output
- truncated = False
- if output.count("\n") > 0:
- output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)]
- output = "\n".join(output)
-
- if output.count("\n") > 10:
- output = "\n".join(output.split("\n")[:10])
-
- if len(output) >= 1000:
- output = f"{output[:1000]}\n... (truncated - too long, too many lines)"
- else:
- output = f"{output}\n... (truncated - too many lines)"
- truncated = True
-
- elif len(output) >= 1000:
- output = f"{output[:1000]}\n... (truncated - too long)"
- truncated = True
-
- if truncated:
- try:
- response = await self.bot.http_session.post(
- URLs.paste_service.format(key="documents"),
- data=full_output
- )
- data = await response.json()
- if "key" in data:
- paste_link = URLs.paste_service.format(key=data["key"])
- except Exception:
- log.exception("Failed to upload full output to paste service!")
-
- if output.strip():
- if paste_link:
- msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \
- f"\nFull output: {paste_link}"
- else:
- msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```"
-
- response = await ctx.send(msg)
- self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot))
+ if ESCAPE_REGEX.findall(output):
+ return "Code block escape attempt detected; will not output result", paste_link
- else:
- await ctx.send(
- f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```"
- )
- finally:
- del self.jobs[ctx.author.id]
+ truncated = False
+ lines = output.count("\n")
- @eval_command.error
- async def eval_command_error(self, ctx: Context, error: CommandError):
- embed = Embed(colour=Colour.red())
+ if lines > 0:
+ output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway
+ output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1))
+ output = "\n".join(output)
- if isinstance(error, NoPrivateMessage):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = "You're not allowed to use this command in private messages."
- await ctx.send(embed=embed)
+ if lines > 10:
+ truncated = True
+ if len(output) >= 1000:
+ output = f"{output[:1000]}\n... (truncated - too long, too many lines)"
+ else:
+ output = f"{output}\n... (truncated - too many lines)"
+ elif len(output) >= 1000:
+ truncated = True
+ output = f"{output[:1000]}\n... (truncated - too long)"
- elif isinstance(error, InChannelCheckFailure):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
+ if truncated:
+ paste_link = await self.upload_output(original_output)
- else:
- original_error = getattr(error, 'original', "no original error")
- log.error(f"Unhandled error in snekbox eval: {error} ({original_error})")
- embed.title = random.choice(ERROR_REPLIES)
- embed.description = "Some unhandled error occurred. Sorry for that!"
- await ctx.send(embed=embed)
+ output = output.strip()
+ if not output:
+ output = "[No output]"
+
+ return output, paste_link
+
+ @command(name="eval", aliases=("e",))
+ @guild_only()
+ @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+ async def eval_command(self, ctx: Context, *, code: str = None) -> None:
+ """
+ Run Python code and get the results.
+
+ This command supports multiple lines of code, including code wrapped inside a formatted code
+ block. We've done our best to make this safe, but do let us know if you manage to find an
+ issue with it!
+ """
+ if ctx.author.id in self.jobs:
+ await ctx.send(
+ f"{ctx.author.mention} You've already got a job running - "
+ f"please wait for it to finish!"
+ )
+ return
+
+ if not code: # None or empty string
+ await ctx.invoke(self.bot.get_command("help"), "eval")
+ return
+
+ log.info(
+ f"Received code from {ctx.author.name}#{ctx.author.discriminator} "
+ f"for evaluation:\n{code}"
+ )
+
+ self.jobs[ctx.author.id] = datetime.datetime.now()
+ code = self.prepare_input(code)
+
+ try:
+ async with ctx.typing():
+ results = await self.post_eval(code)
+ msg, error = self.get_results_message(results)
+
+ if error:
+ output, paste_link = error, None
+ else:
+ output, paste_link = await self.format_output(results["stdout"])
+
+ msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```"
+ if paste_link:
+ msg = f"{msg}\nFull output: {paste_link}"
+
+ response = await ctx.send(msg)
+ self.bot.loop.create_task(
+ wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
+ )
+
+ log.info(
+ f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of "
+ f"{results['returncode']}"
+ )
+ finally:
+ del self.jobs[ctx.author.id]
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Snekbox cog load."""
bot.add_cog(Snekbox(bot))
log.info("Cog loaded: Snekbox")
diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py
deleted file mode 100644
index f46f62552..000000000
--- a/bot/cogs/superstarify.py
+++ /dev/null
@@ -1,285 +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.cogs.moderation import Moderation
-from bot.cogs.modlog import ModLog
-from bot.constants import (
- Icons, Keys,
- MODERATION_ROLES, NEGATIVE_REPLIES,
- POSITIVE_REPLIES, 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(*MODERATION_ROLES)
- 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(*MODERATION_ROLES)
- 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/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py
new file mode 100644
index 000000000..f7d6a269d
--- /dev/null
+++ b/bot/cogs/superstarify/__init__.py
@@ -0,0 +1,269 @@
+import logging
+import random
+from datetime import datetime
+
+from discord import Colour, Embed, Member
+from discord.errors import Forbidden
+from discord.ext.commands import Bot, Cog, Context, command
+
+from bot.cogs.moderation import Moderation
+from bot.cogs.modlog import ModLog
+from bot.cogs.superstarify.stars import get_nick
+from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES
+from bot.converters import ExpirationDate
+from bot.decorators import with_role
+from bot.utils.moderation import post_infraction
+
+log = logging.getLogger(__name__)
+NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy"
+
+
+class Superstarify(Cog):
+ """A set of commands to moderate terrible nicknames."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @property
+ def moderation(self) -> Moderation:
+ """Get currently loaded Moderation cog instance."""
+ return self.bot.get_cog("Moderation")
+
+ @property
+ def modlog(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
+ """
+ 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.trace(
+ f"{before.display_name} is trying to change their nickname to {after.display_name}. "
+ "Checking if the user is in superstar-prison..."
+ )
+
+ active_superstarifies = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'superstar',
+ 'user__id': str(before.id)
+ }
+ )
+
+ if active_superstarifies:
+ [infraction] = active_superstarifies
+ forced_nick = get_nick(infraction['id'], before.id)
+ if after.display_name == forced_nick:
+ return # Nick change was triggered by this event. Ignore.
+
+ log.info(
+ f"{after.display_name} is currently in superstar-prison. "
+ f"Changing the nick back to {before.display_name}."
+ )
+ await after.edit(nick=forced_nick)
+ end_timestamp_human = (
+ datetime.fromisoformat(infraction['expires_at'][:-1])
+ .strftime('%c')
+ )
+
+ 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"**{end_timestamp_human}**."
+ )
+ 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."
+ )
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """
+ 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.
+ """
+ active_superstarifies = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'superstar',
+ 'user__id': member.id
+ }
+ )
+
+ if active_superstarifies:
+ [infraction] = active_superstarifies
+ forced_nick = get_nick(infraction['id'], member.id)
+ await member.edit(nick=forced_nick)
+ end_timestamp_human = (
+ datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c')
+ )
+
+ 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_human}**."
+ )
+ 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_human}**"
+ )
+ 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(*MODERATION_ROLES)
+ async def superstarify(
+ self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None
+ ) -> None:
+ """
+ Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration.
+
+ An optional reason can be provided.
+
+ If no reason is given, the original name will be shown in a generated reason.
+ """
+ active_superstarifies = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'superstar',
+ 'user__id': str(member.id)
+ }
+ )
+ if active_superstarifies:
+ await ctx.send(
+ ":x: According to my records, this user is already superstarified. "
+ f"See infraction **#{active_superstarifies[0]['id']}**."
+ )
+ return
+
+ infraction = await post_infraction(
+ ctx, member,
+ type='superstar', reason=reason or ('old nick: ' + member.display_name),
+ expires_at=expiration
+ )
+ forced_nick = get_nick(infraction['id'], member.id)
+
+ embed = Embed()
+ 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**{expiration}**.\n\n"
+ "If you're confused by this, please read our "
+ f"[official nickname policy]({NICKNAME_POLICY_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: `{member.display_name}`\n"
+ f"New nickname: `{forced_nick}`\n"
+ f"Superstardom ends: **{expiration}**"
+ )
+ 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",
+ expires_at=expiration,
+ reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
+ )
+
+ # Change the nick and return the embed
+ log.trace("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(*MODERATION_ROLES)
+ async def unsuperstarify(self, ctx: Context, member: Member) -> None:
+ """Remove the superstarify entry from our database, allowing the user to change their nickname."""
+ log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}")
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+
+ active_superstarifies = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': 'superstar',
+ 'user__id': str(member.id)
+ }
+ )
+ if not active_superstarifies:
+ await ctx.send(":x: There is no active superstarify infraction for this user.")
+ return
+
+ [infraction] = active_superstarifies
+ await self.bot.api_client.patch(
+ 'bot/infractions/' + str(infraction['id']),
+ json={'active': False}
+ )
+
+ embed = Embed()
+ embed.description = "User has been released from superstar-prison."
+ embed.title = random.choice(POSITIVE_REPLIES)
+
+ await self.moderation.notify_pardon(
+ user=member,
+ title="You are no longer superstarified.",
+ content="You may now change your nickname on the server."
+ )
+ log.trace(f"{member.display_name} was successfully released from superstar-prison.")
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Superstarify cog load."""
+ bot.add_cog(Superstarify(bot))
+ log.info("Cog loaded: Superstarify")
diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py
new file mode 100644
index 000000000..dbac86770
--- /dev/null
+++ b/bot/cogs/superstarify/stars.py
@@ -0,0 +1,87 @@
+import random
+
+
+STAR_NAMES = (
+ "Adele",
+ "Aerosmith",
+ "Aretha Franklin",
+ "Ayumi Hamasaki",
+ "B'z",
+ "Barbra Streisand",
+ "Barry Manilow",
+ "Barry White",
+ "Beyonce",
+ "Billy Joel",
+ "Bob Dylan",
+ "Bob Marley",
+ "Bob Seger",
+ "Bon Jovi",
+ "Britney Spears",
+ "Bruce Springsteen",
+ "Bruno Mars",
+ "Bryan Adams",
+ "Celine Dion",
+ "Cher",
+ "Christina Aguilera",
+ "David Bowie",
+ "Donna Summer",
+ "Drake",
+ "Ed Sheeran",
+ "Elton John",
+ "Elvis Presley",
+ "Eminem",
+ "Enya",
+ "Flo Rida",
+ "Frank Sinatra",
+ "Garth Brooks",
+ "George Michael",
+ "George Strait",
+ "James Taylor",
+ "Janet Jackson",
+ "Jay-Z",
+ "Johnny Cash",
+ "Johnny Hallyday",
+ "Julio Iglesias",
+ "Justin Bieber",
+ "Justin Timberlake",
+ "Kanye West",
+ "Katy Perry",
+ "Kenny G",
+ "Kenny Rogers",
+ "Lady Gaga",
+ "Lil Wayne",
+ "Linda Ronstadt",
+ "Lionel Richie",
+ "Madonna",
+ "Mariah Carey",
+ "Meat Loaf",
+ "Michael Jackson",
+ "Neil Diamond",
+ "Nicki Minaj",
+ "Olivia Newton-John",
+ "Paul McCartney",
+ "Phil Collins",
+ "Pink",
+ "Prince",
+ "Reba McEntire",
+ "Rihanna",
+ "Robbie Williams",
+ "Rod Stewart",
+ "Santana",
+ "Shania Twain",
+ "Stevie Wonder",
+ "Taylor Swift",
+ "Tim McGraw",
+ "Tina Turner",
+ "Tom Petty",
+ "Tupac Shakur",
+ "Usher",
+ "Van Halen",
+ "Whitney Houston",
+)
+
+
+def get_nick(infraction_id: int, member_id: int) -> str:
+ """Randomly select a nickname from the Superstarify nickname list."""
+ rng = random.Random(str(infraction_id) + str(member_id))
+ return rng.choice(STAR_NAMES)
diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py
new file mode 100644
index 000000000..d4565f848
--- /dev/null
+++ b/bot/cogs/sync/__init__.py
@@ -0,0 +1,13 @@
+import logging
+
+from discord.ext.commands import Bot
+
+from .cog import Sync
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot: Bot) -> None:
+ """Sync cog load."""
+ bot.add_cog(Sync(bot))
+ log.info("Cog loaded: Sync")
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
new file mode 100644
index 000000000..b75fb26cd
--- /dev/null
+++ b/bot/cogs/sync/cog.py
@@ -0,0 +1,200 @@
+import logging
+from typing import Callable, Iterable
+
+from discord import Guild, Member, Role
+from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.cogs.sync import syncers
+
+log = logging.getLogger(__name__)
+
+
+class Sync(Cog):
+ """Captures relevant events and sends them to the site."""
+
+ # The server to synchronize events on.
+ # Note that setting this wrongly will result in things getting deleted
+ # that possibly shouldn't be.
+ SYNC_SERVER_ID = constants.Guild.id
+
+ # An iterable of callables that are called when the bot is ready.
+ ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = (
+ syncers.sync_roles,
+ syncers.sync_users
+ )
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @Cog.listener()
+ async def on_ready(self) -> None:
+ """Syncs the roles/users of the guild with the database."""
+ guild = self.bot.get_guild(self.SYNC_SERVER_ID)
+ if guild is not None:
+ for syncer in self.ON_READY_SYNCERS:
+ syncer_name = syncer.__name__[5:] # drop off `sync_`
+ log.info("Starting `%s` syncer.", syncer_name)
+ total_created, total_updated, total_deleted = await syncer(self.bot, guild)
+ if total_deleted is None:
+ log.info(
+ f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`."
+ )
+ else:
+ log.info(
+ f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, "
+ f"deleted `{total_deleted}`."
+ )
+
+ @Cog.listener()
+ async def on_guild_role_create(self, role: Role) -> None:
+ """Adds newly create role to the database table over the API."""
+ await self.bot.api_client.post(
+ 'bot/roles',
+ json={
+ 'colour': role.colour.value,
+ 'id': role.id,
+ 'name': role.name,
+ 'permissions': role.permissions.value,
+ 'position': role.position,
+ }
+ )
+
+ @Cog.listener()
+ async def on_guild_role_delete(self, role: Role) -> None:
+ """Deletes role from the database when it's deleted from the guild."""
+ await self.bot.api_client.delete(f'bot/roles/{role.id}')
+
+ @Cog.listener()
+ async def on_guild_role_update(self, before: Role, after: Role) -> None:
+ """Syncs role with the database if any of the stored attributes were updated."""
+ if (
+ before.name != after.name
+ or before.colour != after.colour
+ or before.permissions != after.permissions
+ or before.position != after.position
+ ):
+ await self.bot.api_client.put(
+ f'bot/roles/{after.id}',
+ json={
+ 'colour': after.colour.value,
+ 'id': after.id,
+ 'name': after.name,
+ 'permissions': after.permissions.value,
+ 'position': after.position,
+ }
+ )
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """
+ Adds a new user or updates existing user to the database when a member joins the guild.
+
+ If the joining member is a user that is already known to the database (i.e., a user that
+ previously left), it will update the user's information. If the user is not yet known by
+ the database, the user is added.
+ """
+ packed = {
+ 'avatar_hash': member.avatar,
+ 'discriminator': int(member.discriminator),
+ 'id': member.id,
+ 'in_guild': True,
+ 'name': member.name,
+ 'roles': sorted(role.id for role in member.roles)
+ }
+
+ got_error = False
+
+ try:
+ # First try an update of the user to set the `in_guild` field and other
+ # fields that may have changed since the last time we've seen them.
+ await self.bot.api_client.put(f'bot/users/{member.id}', json=packed)
+
+ except ResponseCodeError as e:
+ # If we didn't get 404, something else broke - propagate it up.
+ if e.response.status != 404:
+ raise
+
+ got_error = True # yikes
+
+ if got_error:
+ # If we got `404`, the user is new. Create them.
+ await self.bot.api_client.post('bot/users', json=packed)
+
+ @Cog.listener()
+ async def on_member_remove(self, member: Member) -> None:
+ """Updates the user information when a member leaves the guild."""
+ await self.bot.api_client.put(
+ f'bot/users/{member.id}',
+ json={
+ 'avatar_hash': member.avatar,
+ 'discriminator': int(member.discriminator),
+ 'id': member.id,
+ 'in_guild': False,
+ 'name': member.name,
+ 'roles': sorted(role.id for role in member.roles)
+ }
+ )
+
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
+ """Updates the user information if any of relevant attributes have changed."""
+ if (
+ before.name != after.name
+ or before.avatar != after.avatar
+ or before.discriminator != after.discriminator
+ or before.roles != after.roles
+ ):
+ try:
+ await self.bot.api_client.put(
+ 'bot/users/' + str(after.id),
+ json={
+ 'avatar_hash': after.avatar,
+ 'discriminator': int(after.discriminator),
+ 'id': after.id,
+ 'in_guild': True,
+ 'name': after.name,
+ 'roles': sorted(role.id for role in after.roles)
+ }
+ )
+ except ResponseCodeError as e:
+ if e.response.status != 404:
+ raise
+
+ log.warning(
+ "Unable to update user, got 404. "
+ "Assuming race condition from join event."
+ )
+
+ @commands.group(name='sync')
+ @commands.has_permissions(administrator=True)
+ async def sync_group(self, ctx: Context) -> None:
+ """Run synchronizations between the bot and site manually."""
+
+ @sync_group.command(name='roles')
+ @commands.has_permissions(administrator=True)
+ async def sync_roles_command(self, ctx: Context) -> None:
+ """Manually synchronize the guild's roles with the roles on the site."""
+ initial_response = await ctx.send("📊 Synchronizing roles.")
+ total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild)
+ await initial_response.edit(
+ content=(
+ f"👌 Role synchronization complete, created **{total_created}** "
+ f", updated **{total_created}** roles, and deleted **{total_deleted}** roles."
+ )
+ )
+
+ @sync_group.command(name='users')
+ @commands.has_permissions(administrator=True)
+ async def sync_users_command(self, ctx: Context) -> None:
+ """Manually synchronize the guild's users with the users on the site."""
+ initial_response = await ctx.send("📊 Synchronizing users.")
+ total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild)
+ await initial_response.edit(
+ content=(
+ f"👌 User synchronization complete, created **{total_created}** "
+ f"and updated **{total_created}** users."
+ )
+ )
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
new file mode 100644
index 000000000..2cc5a66e1
--- /dev/null
+++ b/bot/cogs/sync/syncers.py
@@ -0,0 +1,234 @@
+from collections import namedtuple
+from typing import Dict, Set, Tuple
+
+from discord import Guild
+from discord.ext.commands import Bot
+
+# These objects are declared as namedtuples because tuples are hashable,
+# something that we make use of when diffing site roles against guild roles.
+Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
+User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'))
+
+
+def get_roles_for_sync(
+ guild_roles: Set[Role], api_roles: Set[Role]
+) -> Tuple[Set[Role], Set[Role], Set[Role]]:
+ """
+ Determine which roles should be created or updated on the site.
+
+ Arguments:
+ guild_roles (Set[Role]):
+ Roles that were found on the guild at startup.
+
+ api_roles (Set[Role]):
+ Roles that were retrieved from the API at startup.
+
+ Returns:
+ Tuple[Set[Role], Set[Role]. Set[Role]]:
+ A tuple with three elements. The first element represents
+ roles to be created on the site, meaning that they were
+ present on the cached guild but not on the API. The second
+ element represents roles to be updated, meaning they were
+ present on both the cached guild and the API but non-ID
+ fields have changed inbetween. The third represents roles
+ to be deleted on the site, meaning the roles are present on
+ the API but not in the cached guild.
+ """
+ guild_role_ids = {role.id for role in guild_roles}
+ api_role_ids = {role.id for role in api_roles}
+ new_role_ids = guild_role_ids - api_role_ids
+ deleted_role_ids = api_role_ids - guild_role_ids
+
+ # New roles are those which are on the cached guild but not on the
+ # API guild, going by the role ID. We need to send them in for creation.
+ roles_to_create = {role for role in guild_roles if role.id in new_role_ids}
+ roles_to_update = guild_roles - api_roles - roles_to_create
+ roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids}
+ return roles_to_create, roles_to_update, roles_to_delete
+
+
+async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]:
+ """
+ Synchronize roles found on the given `guild` with the ones on the API.
+
+ Arguments:
+ bot (discord.ext.commands.Bot):
+ The bot instance that we're running with.
+
+ guild (discord.Guild):
+ The guild instance from the bot's cache
+ to synchronize roles with.
+
+ Returns:
+ Tuple[int, int, int]:
+ A tuple with three integers representing how many roles were created
+ (element `0`) , how many roles were updated (element `1`), and how many
+ roles were deleted (element `2`) on the API.
+ """
+ roles = await bot.api_client.get('bot/roles')
+
+ # Pack API roles and guild roles into one common format,
+ # which is also hashable. We need hashability to be able
+ # to compare these easily later using sets.
+ api_roles = {Role(**role_dict) for role_dict in roles}
+ guild_roles = {
+ Role(
+ id=role.id, name=role.name,
+ colour=role.colour.value, permissions=role.permissions.value,
+ position=role.position,
+ )
+ for role in guild.roles
+ }
+ roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles)
+
+ for role in roles_to_create:
+ await bot.api_client.post(
+ 'bot/roles',
+ json={
+ 'id': role.id,
+ 'name': role.name,
+ 'colour': role.colour,
+ 'permissions': role.permissions,
+ 'position': role.position,
+ }
+ )
+
+ for role in roles_to_update:
+ await bot.api_client.put(
+ f'bot/roles/{role.id}',
+ json={
+ 'id': role.id,
+ 'name': role.name,
+ 'colour': role.colour,
+ 'permissions': role.permissions,
+ 'position': role.position,
+ }
+ )
+
+ for role in roles_to_delete:
+ await bot.api_client.delete(f'bot/roles/{role.id}')
+
+ return len(roles_to_create), len(roles_to_update), len(roles_to_delete)
+
+
+def get_users_for_sync(
+ guild_users: Dict[int, User], api_users: Dict[int, User]
+) -> Tuple[Set[User], Set[User]]:
+ """
+ Determine which users should be created or updated on the website.
+
+ Arguments:
+ guild_users (Dict[int, User]):
+ A mapping of user IDs to user data, populated from the
+ guild cached on the running bot instance.
+
+ api_users (Dict[int, User]):
+ A mapping of user IDs to user data, populated from the API's
+ current inventory of all users.
+
+ Returns:
+ Tuple[Set[User], Set[User]]:
+ Two user sets as a tuple. The first element represents users
+ to be created on the website, these are users that are present
+ in the cached guild data but not in the API at all, going by
+ their ID. The second element represents users to update. It is
+ populated by users which are present on both the API and the
+ guild, but where the attribute of a user on the API is not
+ equal to the attribute of the user on the guild.
+ """
+ users_to_create = set()
+ users_to_update = set()
+
+ for api_user in api_users.values():
+ guild_user = guild_users.get(api_user.id)
+ if guild_user is not None:
+ if api_user != guild_user:
+ users_to_update.add(guild_user)
+
+ elif api_user.in_guild:
+ # The user is known on the API but not the guild, and the
+ # API currently specifies that the user is a member of the guild.
+ # This means that the user has left since the last sync.
+ # Update the `in_guild` attribute of the user on the site
+ # to signify that the user left.
+ new_api_user = api_user._replace(in_guild=False)
+ users_to_update.add(new_api_user)
+
+ new_user_ids = set(guild_users.keys()) - set(api_users.keys())
+ for user_id in new_user_ids:
+ # The user is known on the guild but not on the API. This means
+ # that the user has joined since the last sync. Create it.
+ new_user = guild_users[user_id]
+ users_to_create.add(new_user)
+
+ return users_to_create, users_to_update
+
+
+async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]:
+ """
+ Synchronize users found in the given `guild` with the ones in the API.
+
+ Arguments:
+ bot (discord.ext.commands.Bot):
+ The bot instance that we're running with.
+
+ guild (discord.Guild):
+ The guild instance from the bot's cache
+ to synchronize roles with.
+
+ Returns:
+ Tuple[int, int, None]:
+ A tuple with two integers, representing how many users were created
+ (element `0`) and how many users were updated (element `1`), and `None`
+ to indicate that a user sync never deletes entries from the API.
+ """
+ current_users = await bot.api_client.get('bot/users')
+
+ # Pack API users and guild users into one common format,
+ # which is also hashable. We need hashability to be able
+ # to compare these easily later using sets.
+ api_users = {
+ user_dict['id']: User(
+ roles=tuple(sorted(user_dict.pop('roles'))),
+ **user_dict
+ )
+ for user_dict in current_users
+ }
+ guild_users = {
+ member.id: User(
+ id=member.id, name=member.name,
+ discriminator=int(member.discriminator), avatar_hash=member.avatar,
+ roles=tuple(sorted(role.id for role in member.roles)), in_guild=True
+ )
+ for member in guild.members
+ }
+
+ users_to_create, users_to_update = get_users_for_sync(guild_users, api_users)
+
+ for user in users_to_create:
+ await bot.api_client.post(
+ 'bot/users',
+ json={
+ 'avatar_hash': user.avatar_hash,
+ 'discriminator': user.discriminator,
+ 'id': user.id,
+ 'in_guild': user.in_guild,
+ 'name': user.name,
+ 'roles': list(user.roles)
+ }
+ )
+
+ for user in users_to_update:
+ await bot.api_client.put(
+ f'bot/users/{user.id}',
+ json={
+ 'avatar_hash': user.avatar_hash,
+ 'discriminator': user.discriminator,
+ 'id': user.id,
+ 'in_guild': user.in_guild,
+ 'name': user.name,
+ 'roles': list(user.roles)
+ }
+ )
+
+ return len(users_to_create), len(users_to_update), None
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index d6957e360..660620284 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -1,19 +1,11 @@
import logging
-import random
import time
-from typing import Optional
from discord import Colour, Embed
-from discord.ext.commands import (
- BadArgument, Bot,
- Context, group
-)
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import (
- Channels, Cooldowns, ERROR_REPLIES, Keys,
- MODERATION_ROLES, Roles, URLs
-)
-from bot.converters import TagContentConverter, TagNameConverter, ValidURL
+from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
+from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -27,108 +19,27 @@ TEST_CHANNELS = (
)
-class Tags:
- """
- Save new tags and fetch existing tags.
- """
+class Tags(Cog):
+ """Save new tags and fetch existing tags."""
def __init__(self, bot: Bot):
self.bot = bot
self.tag_cooldowns = {}
- self.headers = {"X-API-KEY": Keys.site_api}
-
- async def get_tag_data(self, tag_name=None) -> dict:
- """
- Retrieve the tag_data from our API
-
- :param tag_name: the tag to retrieve
- :return:
- if tag_name was provided, returns a dict with tag data.
- if not, returns a list of dicts with all tag data.
-
- """
- params = {}
-
- if tag_name:
- params["tag_name"] = tag_name
-
- response = await self.bot.http_session.get(URLs.site_tags_api, headers=self.headers, params=params)
- tag_data = await response.json()
-
- return tag_data
-
- async def post_tag_data(self, tag_name: str, tag_content: str, image_url: Optional[str]) -> dict:
- """
- Send some tag_data to our API to have it saved in the database.
-
- :param tag_name: The name of the tag to create or edit.
- :param tag_content: The content of the tag.
- :param image_url: The image URL of the tag, can be `None`.
- :return: json response from the API in the following format:
- {
- 'success': bool
- }
- """
-
- params = {
- 'tag_name': tag_name,
- 'tag_content': tag_content,
- 'image_url': image_url
- }
-
- response = await self.bot.http_session.post(URLs.site_tags_api, headers=self.headers, json=params)
- tag_data = await response.json()
-
- return tag_data
-
- async def delete_tag_data(self, tag_name: str) -> dict:
- """
- Delete a tag using our API.
-
- :param tag_name: The name of the tag to delete.
- :return: json response from the API in the following format:
- {
- 'success': bool
- }
- """
-
- params = {}
-
- if tag_name:
- params['tag_name'] = tag_name
-
- response = await self.bot.http_session.delete(URLs.site_tags_api, headers=self.headers, json=params)
- tag_data = await response.json()
-
- return tag_data
@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) -> 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):
- """
- Get a list of all tags or a specified tag.
-
- :param ctx: Discord message context
- :param tag_name:
- If provided, this function shows data for that specific tag.
- If not provided, this function shows the caller a list of all tags.
- """
-
- def _command_on_cooldown(tag_name) -> bool:
+ async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
+ """Get a specified tag, or a list of all tags if no tag is specified."""
+ def _command_on_cooldown(tag_name: str) -> bool:
"""
- Check if the command is currently on cooldown.
- The cooldown duration is set in constants.py.
+ Check if the command is currently on cooldown, on a per-tag, per-channel basis.
- This works on a per-tag, per-channel basis.
- :param tag_name: The name of the command to check.
- :return: True if the command is cooling down. Otherwise False.
+ The cooldown duration is set in constants.py.
"""
-
now = time.time()
cooldown_conditions = (
@@ -148,69 +59,32 @@ class Tags:
f"Cooldown ends in {time_left:.1f} seconds.")
return
- tags = []
+ if tag_name is not None:
+ tag = await self.bot.api_client.get(f'bot/tags/{tag_name}')
+ if ctx.channel.id not in TEST_CHANNELS:
+ self.tag_cooldowns[tag_name] = {
+ "time": time.time(),
+ "channel": ctx.channel.id
+ }
+ await ctx.send(embed=Embed.from_dict(tag['embed']))
- embed: Embed = Embed()
- embed.colour = Colour.red()
- tag_data = await self.get_tag_data(tag_name)
-
- # If we found something, prepare that data
- if tag_data:
- embed.colour = Colour.blurple()
-
- if tag_name:
- log.debug(f"{ctx.author} requested the tag '{tag_name}'")
- embed.title = tag_name
-
- if ctx.channel.id not in TEST_CHANNELS:
- self.tag_cooldowns[tag_name] = {
- "time": time.time(),
- "channel": ctx.channel.id
- }
-
- else:
- embed.title = "**Current tags**"
-
- if isinstance(tag_data, list):
- log.debug(f"{ctx.author} requested a list of all tags")
- tags = [f"**»** {tag['tag_name']}" for tag in tag_data]
- tags = sorted(tags)
-
- else:
- embed.description = tag_data['tag_content']
- if tag_data['image_url'] is not None:
- embed.set_image(url=tag_data['image_url'])
-
- # If its invoked from error handler just ignore it.
- elif hasattr(ctx, "invoked_from_error_handler"):
- return
- # If not, prepare an error message.
else:
- embed.colour = Colour.red()
-
- if isinstance(tag_data, dict):
- log.warning(f"{ctx.author} requested the tag '{tag_name}', but it could not be found.")
- embed.description = f"**{tag_name}** is an unknown tag name. Please check the spelling and try again."
+ tags = await self.bot.api_client.get('bot/tags')
+ if not tags:
+ await ctx.send(embed=Embed(
+ description="**There are no tags in the database!**",
+ colour=Colour.red()
+ ))
else:
- log.warning(f"{ctx.author} requested a list of all tags, but the tags database was empty!")
- embed.description = "**There are no tags in the database!**"
-
- if tag_name:
- embed.set_footer(text="To show a list of all tags, use !tags.")
- embed.title = "Tag not found."
-
- # Paginate if this is a list of all tags
- if tags:
- log.debug(f"Returning a paginated list of all tags.")
- return await LinePaginator.paginate(
- (lines for lines in tags),
- ctx, embed,
- footer_text="To show a tag, type !tags <tagname>.",
- empty=False,
- max_lines=15
- )
-
- return await ctx.send(embed=embed)
+ embed: Embed = Embed(title="**Current tags**")
+ await LinePaginator.paginate(
+ sorted(f"**»** {tag['title']}" for tag in tags),
+ ctx,
+ embed,
+ footer_text="To show a tag, type !tags <tagname>.",
+ empty=False,
+ max_lines=15
+ )
@tags_group.command(name='set', aliases=('add', 'edit', 's'))
@with_role(*MODERATION_ROLES)
@@ -218,96 +92,45 @@ class Tags:
self,
ctx: Context,
tag_name: TagNameConverter,
+ *,
tag_content: TagContentConverter,
- image_url: ValidURL = None
- ):
- """
- Create a new tag or edit an existing one.
-
- :param ctx: discord message context
- :param tag_name: The name of the tag to create or edit.
- :param tag_content: The content of the tag.
- :param image_url: An optional image for the tag.
- """
+ ) -> None:
+ """Create a new tag or update an existing one."""
+ body = {
+ 'title': tag_name.lower().strip(),
+ 'embed': {
+ 'title': tag_name,
+ 'description': tag_content
+ }
+ }
- tag_name = tag_name.lower().strip()
- tag_content = tag_content.strip()
+ await self.bot.api_client.post('bot/tags', json=body)
- embed = Embed()
- embed.colour = Colour.red()
- tag_data = await self.post_tag_data(tag_name, tag_content, image_url)
+ log.debug(f"{ctx.author} successfully added the following tag to our database: \n"
+ f"tag_name: {tag_name}\n"
+ f"tag_content: '{tag_content}'\n")
- if tag_data.get("success"):
- log.debug(f"{ctx.author} successfully added the following tag to our database: \n"
- f"tag_name: {tag_name}\n"
- f"tag_content: '{tag_content}'\n"
- f"image_url: '{image_url}'")
- embed.colour = Colour.blurple()
- embed.title = "Tag successfully added"
- embed.description = f"**{tag_name}** added to tag database."
- else:
- log.error("There was an unexpected database error when trying to add the following tag: \n"
- f"tag_name: {tag_name}\n"
- f"tag_content: '{tag_content}'\n"
- f"image_url: '{image_url}'\n"
- f"response: {tag_data}")
- embed.title = "Database error"
- embed.description = ("There was a problem adding the data to the tags database. "
- "Please try again. If the problem persists, see the error logs.")
-
- return await ctx.send(embed=embed)
+ await ctx.send(embed=Embed(
+ title="Tag successfully added",
+ description=f"**{tag_name}** added to tag database.",
+ colour=Colour.blurple()
+ ))
@tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(Roles.admin, Roles.owner)
- async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter):
- """
- Remove a tag from the database.
-
- :param ctx: discord message context
- :param tag_name: The name of the tag to delete.
- """
-
- tag_name = tag_name.lower().strip()
- embed = Embed()
- embed.colour = Colour.red()
- tag_data = await self.delete_tag_data(tag_name)
+ async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
+ """Remove a tag from the database."""
+ await self.bot.api_client.delete(f'bot/tags/{tag_name}')
- if tag_data.get("success") is True:
- log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
- embed.colour = Colour.blurple()
- embed.title = tag_name
- embed.description = f"Tag successfully removed: {tag_name}."
-
- elif tag_data.get("success") is False:
- log.debug(f"{ctx.author} tried to delete a tag called '{tag_name}', but the tag does not exist.")
- embed.colour = Colour.red()
- embed.title = tag_name
- embed.description = "Tag doesn't appear to exist."
-
- else:
- log.error("There was an unexpected database error when trying to delete the following tag: \n"
- f"tag_name: {tag_name}\n"
- f"response: {tag_data}")
- embed.title = "Database error"
- embed.description = ("There was an unexpected error with deleting the data from the tags database. "
- "Please try again. If the problem persists, see the error logs.")
-
- return await ctx.send(embed=embed)
-
- @get_command.error
- @set_command.error
- @delete_command.error
- async def command_error(self, ctx, error):
- if isinstance(error, BadArgument):
- embed = Embed()
- embed.colour = Colour.red()
- embed.description = str(error)
- embed.title = random.choice(ERROR_REPLIES)
- await ctx.send(embed=embed)
- else:
- log.error(f"Unhandled tag command error: {error} ({error.original})")
+ log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
+ await ctx.send(embed=Embed(
+ title=tag_name,
+ description=f"Tag successfully removed: {tag_name}.",
+ colour=Colour.blurple()
+ ))
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Tags cog load."""
bot.add_cog(Tags(bot))
log.info("Cog loaded: Tags")
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 05298a2ff..7dd0afbbd 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -6,7 +6,7 @@ import struct
from datetime import datetime
from discord import Colour, Message
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from discord.utils import snowflake_time
from bot.cogs.modlog import ModLog
@@ -26,17 +26,15 @@ DELETION_MESSAGE_TEMPLATE = (
DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1)
TOKEN_EPOCH = 1_293_840_000
TOKEN_RE = re.compile(
- r"(?<=(\"|'))" # Lookbehind: Only match if there's a double or single quote in front
r"[^\s\.]+" # Matches token part 1: The user ID string, encoded as base64
r"\." # Matches a literal dot between the token parts
r"[^\s\.]+" # Matches token part 2: The creation timestamp, as an integer
r"\." # Matches a literal dot between the token parts
r"[^\s\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty
- r"(?=(\"|'))" # Lookahead: Only match if there's a double or single quote after
)
-class TokenRemover:
+class TokenRemover(Cog):
"""Scans messages for potential discord.py bot tokens and removes them."""
def __init__(self, bot: Bot):
@@ -44,9 +42,16 @@ class TokenRemover:
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, msg: Message):
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """
+ Check each message for a string that matches Discord's token pattern.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
if msg.author.bot:
return
@@ -83,6 +88,11 @@ class TokenRemover:
@staticmethod
def is_valid_user_id(b64_content: str) -> bool:
+ """
+ Check potential token to see if it contains a valid Discord user ID.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
b64_content += '=' * (-len(b64_content) % 4)
try:
@@ -93,6 +103,11 @@ class TokenRemover:
@staticmethod
def is_valid_timestamp(b64_content: str) -> bool:
+ """
+ Check potential token to see if it contains a valid timestamp.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
b64_content += '=' * (-len(b64_content) % 4)
try:
@@ -103,6 +118,7 @@ class TokenRemover:
return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Token Remover cog load."""
bot.add_cog(TokenRemover(bot))
log.info("Cog loaded: TokenRemover")
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 0c6d9d2ba..62e2fb03f 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -1,40 +1,35 @@
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 discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, STAFF_ROLES
+from bot.decorators import in_channel
log = logging.getLogger(__name__)
-class Utils:
- """
- A selection of utilities which don't have a clear category.
- """
+class Utils(Cog):
+ """A selection of utilities which don't have a clear category."""
- def __init__(self, bot: AutoShardedBot):
+ def __init__(self, bot: Bot):
self.bot = bot
self.base_pep_url = "http://www.python.org/dev/peps/pep-"
self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-"
@command(name='pep', aliases=('get_pep', 'p'))
- async def pep_command(self, ctx: Context, pep_number: str):
- """
- Fetches information about a PEP and sends it to the channel.
- """
-
+ async def pep_command(self, ctx: Context, pep_number: str) -> None:
+ """Fetches information about a PEP and sends it to the channel."""
if pep_number.isdigit():
pep_number = int(pep_number)
else:
- return await ctx.invoke(self.bot.get_command("help"), "pep")
+ await ctx.invoke(self.bot.get_command("help"), "pep")
+ return
# Newer PEPs are written in RST instead of txt
if pep_number > 542:
@@ -90,11 +85,8 @@ class Utils:
@command()
@in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
- async def charinfo(self, ctx, *, characters: str):
- """
- Shows you information on up to 25 unicode characters.
- """
-
+ async def charinfo(self, ctx: Context, *, characters: str) -> None:
+ """Shows you information on up to 25 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
if match:
embed = Embed(
@@ -105,12 +97,14 @@ class Utils:
)
)
embed.colour = Colour.red()
- return await ctx.send(embed=embed)
+ await ctx.send(embed=embed)
+ return
if len(characters) > 25:
embed = Embed(title=f"Too many characters ({len(characters)}/25)")
embed.colour = Colour.red()
- return await ctx.send(embed=embed)
+ await ctx.send(embed=embed)
+ return
def get_info(char):
digit = f"{ord(char):x}"
@@ -133,14 +127,8 @@ class Utils:
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):
+def setup(bot: Bot) -> None:
+ """Utils cog load."""
bot.add_cog(Utils(bot))
log.info("Cog loaded: Utils")
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 56fcd63eb..b0c250603 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,11 +1,11 @@
import logging
from discord import Message, NotFound, Object
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
from bot.cogs.modlog import ModLog
from bot.constants import Channels, Event, Roles
-from bot.decorators import in_channel, without_role
+from bot.decorators import InChannelCheckFailure, in_channel, without_role
log = logging.getLogger(__name__)
@@ -14,8 +14,8 @@ Hello! Welcome to the server, and thanks for verifying yourself!
For your records, these are the documents you accepted:
-`1)` Our rules, here: <https://pythondiscord.com/about/rules>
-`2)` Our privacy policy, here: <https://pythondiscord.com/about/privacy> - you can find information on how to have \
+`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
+`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
your information removed here as well.
Feel free to review them at any point!
@@ -28,19 +28,20 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
"""
-class Verification:
- """
- User verification and role self-management
- """
+class Verification(Cog):
+ """User verification and role self-management."""
def __init__(self, bot: Bot):
self.bot = bot
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, message: Message):
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Check new message event for messages to the checkpoint channel & process."""
if message.author.bot:
return # They're a bot, ignore
@@ -74,11 +75,8 @@ class Verification:
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(Roles.verified)
@in_channel(Channels.verification)
- async def accept_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Accept our rules and gain access to the rest of the server
- """
-
+ async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Accept our rules and gain access to the rest of the server."""
log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules")
try:
@@ -97,11 +95,8 @@ class Verification:
@command(name='subscribe')
@in_channel(Channels.bot)
- async def subscribe_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Subscribe to announcement notifications by assigning yourself the role
- """
-
+ async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Subscribe to announcement notifications by assigning yourself the role."""
has_role = False
for role in ctx.author.roles:
@@ -110,9 +105,8 @@ class Verification:
break
if has_role:
- return await ctx.send(
- f"{ctx.author.mention} You're already subscribed!",
- )
+ await ctx.send(f"{ctx.author.mention} You're already subscribed!")
+ return
log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements")
@@ -125,11 +119,8 @@ class Verification:
@command(name='unsubscribe')
@in_channel(Channels.bot)
- async def unsubscribe_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Unsubscribe from announcement notifications by removing the role from yourself
- """
-
+ async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Unsubscribe from announcement notifications by removing the role from yourself."""
has_role = False
for role in ctx.author.roles:
@@ -138,9 +129,8 @@ class Verification:
break
if not has_role:
- return await ctx.send(
- f"{ctx.author.mention} You're already unsubscribed!"
- )
+ await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
+ return
log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements")
@@ -152,17 +142,21 @@ class Verification:
)
@staticmethod
- def __global_check(ctx: Context):
- """
- Block any command within the verification channel that is not !accept.
- """
+ async def cog_command_error(ctx: Context, error: Exception) -> None:
+ """Check for & ignore any InChannelCheckFailure."""
+ if isinstance(error, InChannelCheckFailure):
+ error.handled = True
+ @staticmethod
+ def bot_check(ctx: Context) -> bool:
+ """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):
+def setup(bot: Bot) -> None:
+ """Verification cog load."""
bot.add_cog(Verification(bot))
log.info("Cog loaded: Verification")
diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py
new file mode 100644
index 000000000..86e1050fa
--- /dev/null
+++ b/bot/cogs/watchchannels/__init__.py
@@ -0,0 +1,18 @@
+import logging
+
+from discord.ext.commands import Bot
+
+from .bigbrother import BigBrother
+from .talentpool import TalentPool
+
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot: Bot) -> None:
+ """Monitoring cogs load."""
+ bot.add_cog(BigBrother(bot))
+ log.info("Cog loaded: BigBrother")
+
+ bot.add_cog(TalentPool(bot))
+ log.info("Cog loaded: TalentPool")
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
new file mode 100644
index 000000000..e191c2dbc
--- /dev/null
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -0,0 +1,100 @@
+import logging
+from collections import ChainMap
+from typing import Union
+
+from discord import User
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.constants import Channels, Roles, Webhooks
+from bot.decorators import with_role
+from bot.utils.moderation import post_infraction
+from .watchchannel import WatchChannel, proxy_user
+
+log = logging.getLogger(__name__)
+
+
+class BigBrother(WatchChannel, Cog, name="Big Brother"):
+ """Monitors users by relaying their messages to a watch channel to assist with moderation."""
+
+ def __init__(self, bot: Bot) -> None:
+ super().__init__(
+ bot,
+ destination=Channels.big_brother_logs,
+ webhook_id=Webhooks.big_brother,
+ api_endpoint='bot/infractions',
+ api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'},
+ logger=log
+ )
+
+ @group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def bigbrother_group(self, ctx: Context) -> None:
+ """Monitors users by relaying their messages to the Big Brother watch channel."""
+ await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+
+ @bigbrother_group.command(name='watched', aliases=('all', 'list'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows the users that are currently being monitored by Big Brother.
+
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await self.list_watched_users(ctx, update_cache)
+
+ @bigbrother_group.command(name='watch', aliases=('w',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """
+ Relay messages sent by the given `user` to the `#big-brother` channel.
+
+ A `reason` for adding the user to Big Brother is required and will be displayed
+ in the header when relaying messages of this user to the watchchannel.
+ """
+ if user.bot:
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
+ return
+
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Updating the user cache failed, can't watch user {user}")
+ return
+
+ if user.id in self.watched_users:
+ await ctx.send(":x: The specified user is already being watched.")
+ return
+
+ response = await post_infraction(
+ ctx, user, type='watch', reason=reason, hidden=True
+ )
+
+ if response is not None:
+ self.watched_users[user.id] = response
+ await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.")
+
+ @bigbrother_group.command(name='unwatch', aliases=('uw',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Stop relaying messages by the given `user`."""
+ active_watches = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ self.api_default_params,
+ {"user__id": str(user.id)}
+ )
+ )
+ if active_watches:
+ [infraction] = active_watches
+
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{infraction['id']}",
+ json={'active': False}
+ )
+
+ await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False)
+
+ await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.")
+
+ self._remove_user(user.id)
+ else:
+ await ctx.send(":x: The specified user is currently not being watched.")
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
new file mode 100644
index 000000000..ffe7693a9
--- /dev/null
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -0,0 +1,231 @@
+import logging
+import textwrap
+from collections import ChainMap
+from typing import Union
+
+from discord import Color, Embed, Member, User
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.api import ResponseCodeError
+from bot.constants import Channels, Guild, Roles, Webhooks
+from bot.decorators import with_role
+from bot.pagination import LinePaginator
+from .watchchannel import WatchChannel, proxy_user
+
+log = logging.getLogger(__name__)
+STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge?
+
+
+class TalentPool(WatchChannel, Cog, name="Talentpool"):
+ """Relays messages of helper candidates to a watch channel to observe them."""
+
+ def __init__(self, bot: Bot) -> None:
+ super().__init__(
+ bot,
+ destination=Channels.talent_pool,
+ webhook_id=Webhooks.talent_pool,
+ api_endpoint='bot/nominations',
+ api_default_params={'active': 'true', 'ordering': '-inserted_at'},
+ logger=log,
+ )
+
+ @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def nomination_group(self, ctx: Context) -> None:
+ """Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
+ await ctx.invoke(self.bot.get_command("help"), "talentpool")
+
+ @nomination_group.command(name='watched', aliases=('all', 'list'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows the users that are currently being monitored in the talent pool.
+
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await self.list_watched_users(ctx, update_cache)
+
+ @nomination_group.command(name='watch', aliases=('w', 'add', 'a'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """
+ Relay messages sent by the given `user` to the `#talent-pool` channel.
+
+ A `reason` for adding the user to the talent pool is required and will be displayed
+ in the header when relaying messages of this user to the channel.
+ """
+ if user.bot:
+ await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
+ return
+
+ if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
+ await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:")
+ return
+
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Failed to update the user cache; can't add {user}")
+ return
+
+ if user.id in self.watched_users:
+ await ctx.send(":x: The specified user is already being watched in the talent pool")
+ return
+
+ # Manual request with `raise_for_status` as False because we want the actual response
+ session = self.bot.api_client.session
+ url = self.bot.api_client._url_for(self.api_endpoint)
+ kwargs = {
+ 'json': {
+ 'actor': ctx.author.id,
+ 'reason': reason,
+ 'user': user.id
+ },
+ 'raise_for_status': False,
+ }
+ async with session.post(url, **kwargs) as resp:
+ response_data = await resp.json()
+
+ if resp.status == 400 and response_data.get('user', False):
+ await ctx.send(":x: The specified user can't be found in the database tables")
+ return
+ else:
+ resp.raise_for_status()
+
+ self.watched_users[user.id] = response_data
+ await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel")
+
+ @nomination_group.command(name='history', aliases=('info', 'search'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None:
+ """Shows the specified user's nomination history."""
+ result = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ 'user__id': str(user.id),
+ 'ordering': "-active,-inserted_at"
+ }
+ )
+ if not result:
+ await ctx.send(":warning: This user has never been nominated")
+ return
+
+ embed = Embed(
+ title=f"Nominations for {user.display_name} `({user.id})`",
+ color=Color.blue()
+ )
+ lines = [self._nomination_to_string(nomination) for nomination in result]
+ await LinePaginator.paginate(
+ lines,
+ ctx=ctx,
+ embed=embed,
+ empty=True,
+ max_lines=3,
+ max_size=1000
+ )
+
+ @nomination_group.command(name='unwatch', aliases=('end', ))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """
+ Ends the active nomination of the specified user with the given reason.
+
+ Providing a `reason` is required.
+ """
+ active_nomination = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ self.api_default_params,
+ {"user__id": str(user.id)}
+ )
+ )
+
+ if not active_nomination:
+ await ctx.send(":x: The specified user does not have an active nomination")
+ return
+
+ [nomination] = active_nomination
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination['id']}",
+ json={'end_reason': reason, 'active': False}
+ )
+ await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
+ self._remove_user(user.id)
+
+ @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def nomination_edit_group(self, ctx: Context) -> None:
+ """Commands to edit nominations."""
+ await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit")
+
+ @nomination_edit_group.command(name='reason')
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
+ """
+ Edits the reason/unnominate reason for the nomination with the given `id` depending on the status.
+
+ If the nomination is active, the reason for nominating the user will be edited;
+ If the nomination is no longer active, the reason for ending the nomination will be edited instead.
+ """
+ try:
+ nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ except ResponseCodeError as e:
+ if e.response.status == 404:
+ self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}")
+ await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
+ return
+ else:
+ raise
+
+ field = "reason" if nomination["active"] else "end_reason"
+
+ self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}")
+
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination_id}",
+ json={field: reason}
+ )
+
+ await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
+
+ def _nomination_to_string(self, nomination_object: dict) -> str:
+ """Creates a string representation of a nomination."""
+ guild = self.bot.get_guild(Guild.id)
+
+ actor_id = nomination_object["actor"]
+ actor = guild.get_member(actor_id)
+
+ active = nomination_object["active"]
+ log.debug(active)
+ log.debug(type(nomination_object["inserted_at"]))
+
+ start_date = self._get_human_readable(nomination_object["inserted_at"])
+ if active:
+ lines = textwrap.dedent(
+ f"""
+ ===============
+ Status: **Active**
+ Date: {start_date}
+ Actor: {actor.mention if actor else actor_id}
+ Reason: {nomination_object["reason"]}
+ Nomination ID: `{nomination_object["id"]}`
+ ===============
+ """
+ )
+ else:
+ end_date = self._get_human_readable(nomination_object["ended_at"])
+ lines = textwrap.dedent(
+ f"""
+ ===============
+ Status: Inactive
+ Date: {start_date}
+ Actor: {actor.mention if actor else actor_id}
+ Reason: {nomination_object["reason"]}
+
+ End date: {end_date}
+ Unwatch reason: {nomination_object["end_reason"]}
+ Nomination ID: `{nomination_object["id"]}`
+ ===============
+ """
+ )
+
+ return lines.strip()
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
new file mode 100644
index 000000000..e78282900
--- /dev/null
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -0,0 +1,357 @@
+import asyncio
+import datetime
+import logging
+import re
+import textwrap
+from abc import abstractmethod
+from collections import defaultdict, deque
+from dataclasses import dataclass
+from typing import Optional
+
+import discord
+from discord import Color, Embed, HTTPException, Message, Object, errors
+from discord.ext.commands import BadArgument, Bot, Cog, Context
+
+from bot.api import ResponseCodeError
+from bot.cogs.modlog import ModLog
+from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
+from bot.pagination import LinePaginator
+from bot.utils import CogABCMeta, messages
+from bot.utils.time import time_since
+
+log = logging.getLogger(__name__)
+
+URL_RE = re.compile(r"(https?://[^\s]+)")
+
+
+def proxy_user(user_id: str) -> Object:
+ """A proxy user object that mocks a real User instance for when the later is not available."""
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ raise BadArgument
+
+ user = Object(user_id)
+ user.mention = user.id
+ user.display_name = f"<@{user.id}>"
+ user.avatar_url_as = lambda static_format: None
+ user.bot = False
+
+ return user
+
+
+@dataclass
+class MessageHistory:
+ """Represents a watch channel's message history."""
+
+ last_author: Optional[int] = None
+ last_channel: Optional[int] = None
+ message_count: int = 0
+
+
+class WatchChannel(metaclass=CogABCMeta):
+ """ABC with functionality for relaying users' messages to a certain channel."""
+
+ @abstractmethod
+ def __init__(
+ self,
+ bot: Bot,
+ destination: int,
+ webhook_id: int,
+ api_endpoint: str,
+ api_default_params: dict,
+ logger: logging.Logger
+ ) -> None:
+ self.bot = bot
+
+ self.destination = destination # E.g., Channels.big_brother_logs
+ self.webhook_id = webhook_id # E.g., Webhooks.big_brother
+ self.api_endpoint = api_endpoint # E.g., 'bot/infractions'
+ self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'}
+ self.log = logger # Logger of the child cog for a correct name in the logs
+
+ self._consume_task = None
+ self.watched_users = defaultdict(dict)
+ self.message_queue = defaultdict(lambda: defaultdict(deque))
+ self.consumption_queue = {}
+ self.retries = 5
+ self.retry_delay = 10
+ self.channel = None
+ self.webhook = None
+ self.message_history = MessageHistory()
+
+ self._start = self.bot.loop.create_task(self.start_watchchannel())
+
+ @property
+ def modlog(self) -> ModLog:
+ """Provides access to the ModLog cog for alert purposes."""
+ return self.bot.get_cog("ModLog")
+
+ @property
+ def consuming_messages(self) -> bool:
+ """Checks if a consumption task is currently running."""
+ if self._consume_task is None:
+ return False
+
+ if self._consume_task.done():
+ exc = self._consume_task.exception()
+ if exc:
+ self.log.exception(
+ f"The message queue consume task has failed with:",
+ exc_info=exc
+ )
+ return False
+
+ return True
+
+ async def start_watchchannel(self) -> None:
+ """Starts the watch channel by getting the channel, webhook, and user cache ready."""
+ await self.bot.wait_until_ready()
+
+ try:
+ self.channel = await self.bot.fetch_channel(self.destination)
+ except HTTPException:
+ self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`")
+
+ try:
+ self.webhook = await self.bot.fetch_webhook(self.webhook_id)
+ except discord.HTTPException:
+ self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
+
+ if self.channel is None or self.webhook is None:
+ self.log.error("Failed to start the watch channel; unloading the cog.")
+
+ message = textwrap.dedent(
+ f"""
+ An error occurred while loading the text channel or webhook.
+
+ TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"}
+ Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"}
+
+ The Cog has been unloaded.
+ """
+ )
+
+ await self.modlog.send_log_message(
+ title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel",
+ text=message,
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Color.red()
+ )
+
+ self.bot.remove_cog(self.__class__.__name__)
+ return
+
+ if not await self.fetch_user_cache():
+ await self.modlog.send_log_message(
+ title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel",
+ text="Could not retrieve the list of watched users from the API and messages will not be relayed.",
+ ping_everyone=True,
+ icon=Icons.token_removed,
+ color=Color.red()
+ )
+
+ async def fetch_user_cache(self) -> bool:
+ """
+ Fetches watched users from the API and updates the watched user cache accordingly.
+
+ This function returns `True` if the update succeeded.
+ """
+ try:
+ data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params)
+ except ResponseCodeError as err:
+ self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err)
+ return False
+
+ self.watched_users = defaultdict(dict)
+
+ for entry in data:
+ user_id = entry.pop('user')
+ self.watched_users[user_id] = entry
+
+ return True
+
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Queues up messages sent by watched users."""
+ if msg.author.id in self.watched_users:
+ if not self.consuming_messages:
+ self._consume_task = self.bot.loop.create_task(self.consume_messages())
+
+ self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
+ self.message_queue[msg.author.id][msg.channel.id].append(msg)
+
+ async def consume_messages(self, delay_consumption: bool = True) -> None:
+ """Consumes the message queues to log watched users' messages."""
+ if delay_consumption:
+ self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue")
+ await asyncio.sleep(BigBrotherConfig.log_delay)
+
+ self.log.trace(f"Started consuming the message queue")
+
+ # If the previous consumption Task failed, first consume the existing comsumption_queue
+ if not self.consumption_queue:
+ self.consumption_queue = self.message_queue.copy()
+ self.message_queue.clear()
+
+ for user_channel_queues in self.consumption_queue.values():
+ for channel_queue in user_channel_queues.values():
+ while channel_queue:
+ msg = channel_queue.popleft()
+
+ self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)")
+ await self.relay_message(msg)
+
+ self.consumption_queue.clear()
+
+ if self.message_queue:
+ self.log.trace("Channel queue not empty: Continuing consuming queues")
+ self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False))
+ else:
+ self.log.trace("Done consuming messages.")
+
+ async def webhook_send(
+ self,
+ content: Optional[str] = None,
+ username: Optional[str] = None,
+ avatar_url: Optional[str] = None,
+ embed: Optional[Embed] = None,
+ ) -> None:
+ """Sends a message to the webhook with the specified kwargs."""
+ try:
+ await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed)
+ except discord.HTTPException as exc:
+ self.log.exception(
+ f"Failed to send a message to the webhook",
+ exc_info=exc
+ )
+
+ async def relay_message(self, msg: Message) -> None:
+ """Relays the message to the relevant watch channel."""
+ limit = BigBrotherConfig.header_message_limit
+
+ if (
+ msg.author.id != self.message_history.last_author
+ or msg.channel.id != self.message_history.last_channel
+ or self.message_history.message_count >= limit
+ ):
+ self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id)
+
+ await self.send_header(msg)
+
+ cleaned_content = msg.clean_content
+
+ if cleaned_content:
+ # Put all non-media URLs in a code block to prevent embeds
+ media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
+ for url in URL_RE.findall(cleaned_content):
+ if url not in media_urls:
+ cleaned_content = cleaned_content.replace(url, f"`{url}`")
+ await self.webhook_send(
+ cleaned_content,
+ username=msg.author.display_name,
+ avatar_url=msg.author.avatar_url
+ )
+
+ if msg.attachments:
+ try:
+ await messages.send_attachments(msg, self.webhook)
+ except (errors.Forbidden, errors.NotFound):
+ e = Embed(
+ description=":x: **This message contained an attachment, but it could not be retrieved**",
+ color=Color.red()
+ )
+ await self.webhook_send(
+ embed=e,
+ username=msg.author.display_name,
+ avatar_url=msg.author.avatar_url
+ )
+ except discord.HTTPException as exc:
+ self.log.exception(
+ f"Failed to send an attachment to the webhook",
+ exc_info=exc
+ )
+
+ self.message_history.message_count += 1
+
+ async def send_header(self, msg: Message) -> None:
+ """Sends a header embed with information about the relayed messages to the watch channel."""
+ user_id = msg.author.id
+
+ guild = self.bot.get_guild(GuildConfig.id)
+ actor = guild.get_member(self.watched_users[user_id]['actor'])
+ actor = actor.display_name if actor else self.watched_users[user_id]['actor']
+
+ inserted_at = self.watched_users[user_id]['inserted_at']
+ time_delta = self._get_time_delta(inserted_at)
+
+ reason = self.watched_users[user_id]['reason']
+
+ embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})")
+ embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}")
+
+ await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
+
+ async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Gives an overview of the watched user list for this channel.
+
+ The optional kwarg `update_cache` specifies whether the cache should
+ be refreshed by polling the API.
+ """
+ if update_cache:
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+ update_cache = False
+
+ lines = []
+ for user_id, user_data in self.watched_users.items():
+ inserted_at = user_data['inserted_at']
+ time_delta = self._get_time_delta(inserted_at)
+ lines.append(f"• <@{user_id}> (added {time_delta})")
+
+ lines = lines or ("There's nothing here yet.",)
+ embed = Embed(
+ title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",
+ color=Color.blue()
+ )
+ await LinePaginator.paginate(lines, ctx, embed, empty=False)
+
+ @staticmethod
+ def _get_time_delta(time_string: str) -> str:
+ """Returns the time in human-readable time delta format."""
+ date_time = datetime.datetime.strptime(
+ time_string,
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ).replace(tzinfo=None)
+ time_delta = time_since(date_time, precision="minutes", max_units=1)
+
+ return time_delta
+
+ @staticmethod
+ def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str:
+ date_time = datetime.datetime.strptime(
+ time_string,
+ "%Y-%m-%dT%H:%M:%S.%fZ"
+ ).replace(tzinfo=None)
+ return date_time.strftime(output_format)
+
+ def _remove_user(self, user_id: int) -> None:
+ """Removes a user from a watch channel."""
+ self.watched_users.pop(user_id, None)
+ self.message_queue.pop(user_id, None)
+ self.consumption_queue.pop(user_id, None)
+
+ def cog_unload(self) -> None:
+ """Takes care of unloading the cog and canceling the consumption task."""
+ self.log.trace(f"Unloading the cog")
+ if not self._consume_task.done():
+ self._consume_task.cancel()
+ try:
+ self._consume_task.result()
+ except asyncio.CancelledError as e:
+ self.log.exception(
+ f"The consume task was canceled. Messages may be lost.",
+ exc_info=e
+ )
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index e8b16b243..ab0ed2472 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -1,15 +1,17 @@
import logging
from io import BytesIO
-from typing import List, Optional, Tuple
+from typing import Callable, List, Optional, Tuple
from urllib import parse
import discord
+from dateutil.relativedelta import relativedelta
from discord import Embed
from discord.ext import commands
-from discord.ext.commands import BucketType, Context, check, group
+from discord.ext.commands import Bot, BucketType, Cog, Context, check, group
from bot.constants import Colours, STAFF_ROLES, Wolfram
from bot.pagination import ImagePaginator
+from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -35,18 +37,7 @@ async def send_embed(
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
- """
-
+ """Generate & send a response embed with Wolfram as the author."""
embed = Embed(colour=colour)
embed.description = message_txt
embed.set_author(name="Wolfram Alpha",
@@ -61,16 +52,12 @@ async def send_embed(
await ctx.send(embed=embed, file=f)
-def custom_cooldown(*ignore: List[int]) -> check:
+def custom_cooldown(*ignore: List[int]) -> Callable:
"""
- 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)
+ Implement per-user and per-guild cooldowns for requests to the Wolfram API.
- :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown
- :return: check
+ A list of roles may be provided to ignore the per-user cooldown
"""
-
async def predicate(ctx: Context) -> bool:
user_bucket = usercd.get_bucket(ctx.message)
@@ -79,9 +66,11 @@ def custom_cooldown(*ignore: List[int]) -> check:
if user_rate:
# Can't use api; cause: member limit
+ delta = relativedelta(seconds=int(user_rate))
+ cooldown = humanize_delta(delta)
message = (
"You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {int(user_rate)}"
+ f"Cooldown: {cooldown}"
)
await send_embed(ctx, message)
return False
@@ -105,8 +94,8 @@ def custom_cooldown(*ignore: List[int]) -> check:
return check(predicate)
-async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
- # Give feedback that the bot is working.
+async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:
+ """Get the Wolfram API pod pages for the provided query."""
async with ctx.channel.typing():
url_str = parse.urlencode({
"input": query,
@@ -121,17 +110,27 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
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"]:
+ # API key not set up correctly
+ if result["error"]["msg"] == "Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ log.warning(
+ "API key seems to be missing, or invalid when "
+ f"processing a wolfram request: {url_str}, Response: {json}"
+ )
+ await send_embed(ctx, message)
+ return
+
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["success"]:
+ message = f"I couldn't find anything for {query}."
+ await send_embed(ctx, message)
+ return
+
if not result["numpods"]:
message = "Could not find any results."
await send_embed(ctx, message)
@@ -149,10 +148,8 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
return pages
-class Wolfram:
- """
- Commands for interacting with the Wolfram|Alpha API.
- """
+class Wolfram(Cog):
+ """Commands for interacting with the Wolfram|Alpha API."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@@ -160,14 +157,7 @@ class Wolfram:
@group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
@custom_cooldown(*STAFF_ROLES)
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
- """
-
+ """Requests all answers on a single image, sends an image of all related pods."""
url_str = parse.urlencode({
"i": query,
"appid": APPID,
@@ -191,6 +181,10 @@ class Wolfram:
message = "No input found"
footer = ""
color = Colours.soft_red
+ elif status == 403:
+ message = "Wolfram API key is invalid or missing."
+ footer = ""
+ color = Colours.soft_red
else:
message = ""
footer = "View original for a bigger picture."
@@ -203,13 +197,10 @@ class Wolfram:
@custom_cooldown(*STAFF_ROLES)
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
+ Requests a drawn image of given query.
- :param ctx: Context
- :param query: str - string request to api
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
"""
-
pages = await get_pod_pages(ctx, self.bot, query)
if not pages:
@@ -225,15 +216,12 @@ class Wolfram:
@wolfram_command.command(name="cut", aliases=("c",))
@custom_cooldown(*STAFF_ROLES)
- async def wolfram_cut_command(self, ctx, *, query: str) -> None:
+ async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None:
"""
- Requests a drawn image of given query
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc
+ Requests a drawn image of given query.
- :param ctx: Context
- :param query: str - string request to api
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
"""
-
pages = await get_pod_pages(ctx, self.bot, query)
if not pages:
@@ -249,14 +237,7 @@ class Wolfram:
@wolfram_command.command(name="short", aliases=("sh", "s"))
@custom_cooldown(*STAFF_ROLES)
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
- """
-
+ """Requests an answer to a simple question."""
url_str = parse.urlencode({
"i": query,
"appid": APPID,
@@ -272,10 +253,12 @@ class Wolfram:
if status == 501:
message = "Failed to get response"
color = Colours.soft_red
-
elif status == 400:
message = "No input found"
color = Colours.soft_red
+ elif response_text == "Error 1: Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ color = Colours.soft_red
else:
message = response_text
color = Colours.soft_orange
@@ -284,5 +267,6 @@ class Wolfram:
def setup(bot: commands.Bot) -> None:
+ """Wolfram cog load."""
bot.add_cog(Wolfram(bot))
log.info("Cog loaded: Wolfram")
diff --git a/bot/constants.py b/bot/constants.py
index 17e60a418..e1c47889c 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -341,13 +341,15 @@ class Channels(metaclass=YAMLGetter):
help_3: int
help_4: int
help_5: int
+ help_6: int
+ help_7: int
helpers: int
message_log: int
mod_alerts: int
modlog: int
+ off_topic_0: int
off_topic_1: int
off_topic_2: int
- off_topic_3: int
python: int
reddit: int
talent_pool: int
@@ -356,6 +358,14 @@ class Channels(metaclass=YAMLGetter):
verification: int
+class Webhooks(metaclass=YAMLGetter):
+ section = "guild"
+ subsection = "webhooks"
+
+ talent_pool: int
+ big_brother: int
+
+
class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
@@ -364,13 +374,12 @@ class Roles(metaclass=YAMLGetter):
announcements: int
champion: int
contributor: int
- developer: int
- devops: int
+ core_developer: int
jammer: int
moderator: int
muted: int
owner: int
- verified: int
+ verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
helpers: int
team_leader: int
@@ -385,37 +394,26 @@ class Guild(metaclass=YAMLGetter):
class Keys(metaclass=YAMLGetter):
section = "keys"
- deploy_bot: str
- deploy_site: str
site_api: str
-class RabbitMQ(metaclass=YAMLGetter):
- section = "rabbitmq"
-
- host: str
- password: str
- port: int
- username: str
-
-
class URLs(metaclass=YAMLGetter):
section = "urls"
+ # Snekbox endpoints
+ snekbox_eval_api: str
+
# Discord API endpoints
discord_api: str
discord_invite_api: str
# Misc endpoints
bot_avatar: str
- deploy: str
- gitlab_bot_repo: str
- status: str
+ github_bot_repo: str
# Site endpoints
site: str
site_api: str
- site_clean_api: str
site_superstarify_api: str
site_logs_api: str
site_logs_view: str
diff --git a/bot/converters.py b/bot/converters.py
index 91f30ac5e..7386187ab 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,6 +1,9 @@
import logging
+from datetime import datetime
from ssl import CertificateError
+from typing import Union
+import dateparser
import discord
from aiohttp import ClientConnectorError
from discord.ext.commands import BadArgument, Context, Converter
@@ -13,17 +16,16 @@ class ValidPythonIdentifier(Converter):
"""
A converter that checks whether the given string is a valid Python identifier.
- This is used to have package names
- that correspond to how you would use
- the package in your code, e.g.
- `import package`. Raises `BadArgument`
- if the argument is not a valid Python
- identifier, and simply passes through
+ This is used to have package names that correspond to how you would use the package in your
+ code, e.g. `import package`.
+
+ Raises `BadArgument` if the argument is not a valid Python identifier, and simply passes through
the given argument otherwise.
"""
@staticmethod
- async def convert(ctx, argument: str):
+ async def convert(ctx: Context, argument: str) -> str:
+ """Checks whether the given string is a valid Python identifier."""
if not argument.isidentifier():
raise BadArgument(f"`{argument}` is not a valid Python identifier")
return argument
@@ -33,14 +35,15 @@ class ValidURL(Converter):
"""
Represents a valid webpage URL.
- This converter checks whether the given
- URL can be reached and requesting it returns
- a status code of 200. If not, `BadArgument`
- is raised. Otherwise, it simply passes through the given URL.
+ This converter checks whether the given URL can be reached and requesting it returns a status
+ code of 200. If not, `BadArgument` is raised.
+
+ Otherwise, it simply passes through the given URL.
"""
@staticmethod
- async def convert(ctx, url: str):
+ async def convert(ctx: Context, url: str) -> str:
+ """This converter checks whether the given URL can be reached with a status code of 200."""
try:
async with ctx.bot.http_session.get(url) as resp:
if resp.status != 200:
@@ -61,26 +64,28 @@ class ValidURL(Converter):
class InfractionSearchQuery(Converter):
- """
- A converter that checks if the argument is a Discord user, and if not, falls back to a string.
- """
+ """A converter that checks if the argument is a Discord user, and if not, falls back to a string."""
@staticmethod
- async def convert(ctx, arg):
+ async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]:
+ """Check if the argument is a Discord user, and if not, falls back to a string."""
try:
maybe_snowflake = arg.strip("<@!>")
- return await ctx.bot.get_user_info(maybe_snowflake)
+ return await ctx.bot.fetch_user(maybe_snowflake)
except (discord.NotFound, discord.HTTPException):
return arg
class Subreddit(Converter):
- """
- Forces a string to begin with "r/" and checks if it's a valid subreddit.
- """
+ """Forces a string to begin with "r/" and checks if it's a valid subreddit."""
@staticmethod
- async def convert(ctx, sub: str):
+ async def convert(ctx: Context, sub: str) -> str:
+ """
+ Force sub to begin with "r/" and check if it's a valid subreddit.
+
+ If sub is a valid subreddit, return it prepended with "r/"
+ """
sub = sub.lower()
if not sub.startswith("r/"):
@@ -101,9 +106,21 @@ class Subreddit(Converter):
class TagNameConverter(Converter):
+ """
+ Ensure that a proposed tag name is valid.
+
+ Valid tag names meet the following conditions:
+ * All ASCII characters
+ * Has at least one non-whitespace character
+ * Not solely numeric
+ * Shorter than 127 characters
+ """
+
@staticmethod
- async def convert(ctx: Context, tag_name: str):
- def is_number(value):
+ async def convert(ctx: Context, tag_name: str) -> str:
+ """Lowercase & strip whitespace from proposed tag_name & ensure it's valid."""
+ def is_number(value: str) -> bool:
+ """Check to see if the input string is numeric."""
try:
float(value)
except ValueError:
@@ -140,8 +157,15 @@ class TagNameConverter(Converter):
class TagContentConverter(Converter):
+ """Ensure proposed tag content is not empty and contains at least one non-whitespace character."""
+
@staticmethod
- async def convert(ctx: Context, tag_content: str):
+ async def convert(ctx: Context, tag_content: str) -> str:
+ """
+ Ensure tag_content is non-empty and contains at least one non-whitespace character.
+
+ If tag_content is valid, return the stripped version.
+ """
tag_content = tag_content.strip()
# The tag contents should not be empty, or filled with whitespace.
@@ -151,3 +175,25 @@ class TagContentConverter(Converter):
raise BadArgument("Tag contents should not be empty, or filled with whitespace.")
return tag_content
+
+
+class ExpirationDate(Converter):
+ """Convert relative expiration date into UTC datetime using dateparser."""
+
+ DATEPARSER_SETTINGS = {
+ 'PREFER_DATES_FROM': 'future',
+ 'TIMEZONE': 'UTC',
+ 'TO_TIMEZONE': 'UTC'
+ }
+
+ async def convert(self, ctx: Context, expiration_string: str) -> datetime:
+ """Convert relative expiration date into UTC datetime."""
+ expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS)
+ if expiry is None:
+ raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`")
+
+ now = datetime.utcnow()
+ if expiry < now:
+ expiry = now + (now - expiry)
+
+ return expiry
diff --git a/bot/decorators.py b/bot/decorators.py
index 1ba2cd59e..33a6bcadd 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,9 +1,9 @@
import logging
import random
-import typing
from asyncio import Lock, sleep
from contextlib import suppress
from functools import wraps
+from typing import Any, Callable, Container, Optional
from weakref import WeakValueDictionary
from discord import Colour, Embed
@@ -18,14 +18,19 @@ log = logging.getLogger(__name__)
class InChannelCheckFailure(CheckFailure):
- pass
+ """Raised when a check fails for a message being sent in a whitelisted channel."""
+ def __init__(self, *channels: int):
+ self.channels = channels
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
-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):
+ super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
+
+
+def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:
+ """Checks that the message is in a whitelisted channel or optionally has a bypass role."""
+ def predicate(ctx: Context) -> bool:
+ """In-channel checker predicate."""
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.")
@@ -41,50 +46,39 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None):
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}."
- )
+ raise InChannelCheckFailure(*channels)
return commands.check(predicate)
-def with_role(*role_ids: int):
- """
- Returns True if the user has any one
- of the roles in role_ids.
- """
-
- async def predicate(ctx: Context):
+def with_role(*role_ids: int) -> Callable:
+ """Returns True if the user has any one of the roles in role_ids."""
+ async def predicate(ctx: Context) -> bool:
+ """With role checker predicate."""
return with_role_check(ctx, *role_ids)
return commands.check(predicate)
-def without_role(*role_ids: int):
- """
- Returns True if the user does not have any
- of the roles in role_ids.
- """
-
- async def predicate(ctx: Context):
+def without_role(*role_ids: int) -> Callable:
+ """Returns True if the user does not have any of the roles in role_ids."""
+ async def predicate(ctx: Context) -> bool:
return without_role_check(ctx, *role_ids)
return commands.check(predicate)
-def locked():
+def locked() -> Callable:
"""
Allows the user to only run one instance of the decorated command at a time.
- Subsequent calls to the command from the same author are
- ignored until the command has completed invocation.
+
+ Subsequent calls to the command from the same author are ignored until the command has completed invocation.
This decorator has to go before (below) the `command` decorator.
"""
-
- def wrap(func):
+ def wrap(func: Callable) -> Callable:
func.__locks = WeakValueDictionary()
@wraps(func)
- async def inner(self, ctx, *args, **kwargs):
+ async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Optional[Any]:
lock = func.__locks.setdefault(ctx.author.id, Lock())
if lock.locked():
embed = Embed()
@@ -104,15 +98,15 @@ def locked():
return wrap
-def redirect_output(destination_channel: int, bypass_roles: typing.Container[int] = None):
- """
- Changes the channel in the context of the command to redirect the output
- to a certain channel, unless the author has a role to bypass redirection
+def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable:
"""
+ Changes the channel in the context of the command to redirect the output to a certain channel.
- def wrap(func):
+ Redirect is bypassed if the author has a role to bypass redirection.
+ """
+ def wrap(func: Callable) -> Callable:
@wraps(func)
- async def inner(self, ctx, *args, **kwargs):
+ async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any:
if ctx.channel.id == destination_channel:
log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting")
return await func(self, ctx, *args, **kwargs)
diff --git a/bot/interpreter.py b/bot/interpreter.py
index 06343db39..a42b45a2d 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -1,5 +1,8 @@
from code import InteractiveInterpreter
from io import StringIO
+from typing import Any
+
+from discord.ext.commands import Bot, Context
CODE_TEMPLATE = """
async def _func():
@@ -8,13 +11,20 @@ async def _func():
class Interpreter(InteractiveInterpreter):
+ """
+ Subclass InteractiveInterpreter to specify custom run functionality.
+
+ Helper class for internal eval.
+ """
+
write_callable = None
- def __init__(self, bot):
+ def __init__(self, bot: Bot):
_locals = {"bot": bot}
super().__init__(_locals)
- async def run(self, code, ctx, io, *args, **kwargs):
+ async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any:
+ """Execute the provided source code as the bot & return the output."""
self.locals["_rvalue"] = []
self.locals["ctx"] = ctx
self.locals["print"] = lambda x: io.write(f"{x}\n")
diff --git a/bot/pagination.py b/bot/pagination.py
index 0ad5b81f1..76082f459 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -2,7 +2,7 @@ import asyncio
import logging
from typing import Iterable, List, Optional, Tuple
-from discord import Embed, Member, Reaction
+from discord import Embed, Member, Message, Reaction
from discord.abc import User
from discord.ext.commands import Context, Paginator
@@ -18,6 +18,8 @@ log = logging.getLogger(__name__)
class EmptyPaginatorEmbed(Exception):
+ """Raised when attempting to paginate with empty contents."""
+
pass
@@ -25,25 +27,24 @@ class LinePaginator(Paginator):
"""
A class that aids in paginating code blocks for Discord messages.
- Attributes
- -----------
- prefix: :class:`str`
+ Available attributes include:
+ * prefix: `str`
The prefix inserted to every page. e.g. three backticks.
- suffix: :class:`str`
+ * suffix: `str`
The suffix appended at the end of every page. e.g. three backticks.
- max_size: :class:`int`
+ * max_size: `int`
The maximum amount of codepoints allowed in a page.
- max_lines: :class:`int`
+ * max_lines: `int`
The maximum amount of lines allowed in a page.
"""
- def __init__(self, prefix='```', suffix='```',
- max_size=2000, max_lines=None):
+ def __init__(
+ self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None
+ ) -> None:
"""
- This function overrides the Paginator.__init__
- from inside discord.ext.commands.
- It overrides in order to allow us to configure
- the maximum number of lines per page.
+ This function overrides the Paginator.__init__ from inside discord.ext.commands.
+
+ It overrides in order to allow us to configure the maximum number of lines per page.
"""
self.prefix = prefix
self.suffix = suffix
@@ -54,28 +55,15 @@ class LinePaginator(Paginator):
self._count = len(prefix) + 1 # prefix + newline
self._pages = []
- def add_line(self, line='', *, empty=False):
- """Adds a line to the current page.
-
- If the line exceeds the :attr:`max_size` then an exception
- is raised.
+ def add_line(self, line: str = '', *, empty: bool = False) -> None:
+ """
+ Adds a line to the current page.
- This function overrides the Paginator.add_line
- from inside discord.ext.commands.
- It overrides in order to allow us to configure
- the maximum number of lines per page.
+ If the line exceeds the `self.max_size` then an exception is raised.
- Parameters
- -----------
- line: str
- The line to add.
- empty: bool
- Indicates if another empty line should be added.
+ This function overrides the `Paginator.add_line` from inside `discord.ext.commands`.
- Raises
- ------
- RuntimeError
- The line was too big for the current :attr:`max_size`.
+ It overrides in order to allow us to configure the maximum number of lines per page.
"""
if len(line) > self.max_size - len(self.prefix) - 2:
raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2))
@@ -97,42 +85,39 @@ class LinePaginator(Paginator):
self._count += 1
@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,
- footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False):
+ 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,
+ footer_text: str = None,
+ url: str = None,
+ exception_on_empty_embed: bool = False
+ ) -> Optional[Message]:
"""
- Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to
- switch page, or to finish with pagination.
+ Use a paginator and set of reactions to provide pagination over a set of lines.
+
+ 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. Pagination will also be removed automatically
- if no reaction is added for five minutes (300 seconds).
+ be used to change page, or to remove pagination from the message.
+
+ Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds).
+
+ Example:
>>> embed = Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
- >>> await LinePaginator.paginate(
- ... (line for line in lines),
- ... ctx, embed
- ... )
- :param lines: The lines to be paginated
- :param ctx: Current context object
- :param embed: A pre-configured embed to be used as a template for each page
- :param prefix: Text to place before each page
- :param suffix: Text to place after each page
- :param max_lines: The maximum number of lines on each page
- :param max_size: The maximum number of characters on each page
- :param empty: Whether to place an empty line between each given line
- :param restrict_to_user: A user to lock pagination operations to for this message, if supplied
- :param exception_on_empty_embed: Should there be an exception if the embed is empty?
- :param url: the url to use for the embed headline
- :param timeout: The amount of time in seconds to disable pagination of no reaction is added
- :param footer_text: Text to prefix the page number in the footer with
+ >>> await LinePaginator.paginate((line for line in lines), ctx, embed)
"""
-
- def event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
-
+ def event_check(reaction_: Reaction, user_: Member) -> bool:
+ """Make sure that this reaction is what we want to operate on."""
no_restrictions = (
# Pagination is not restricted
not restrict_to_user
@@ -301,24 +286,20 @@ class LinePaginator(Paginator):
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=""):
+ def __init__(self, prefix: str = "", suffix: str = ""):
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
- """
-
+ """Adds a line to each page."""
if line:
self._count = len(line)
else:
@@ -327,50 +308,36 @@ class ImagePaginator(Paginator):
self.close_page()
def add_image(self, image: str = None) -> None:
- """
- Adds an image to a page
- :param image: image url to be appended
- """
-
+ """Adds an image to a page."""
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,
- exception_on_empty_embed: bool = False):
+ async def paginate(
+ cls,
+ pages: List[Tuple[str, str]],
+ ctx: Context, embed: Embed,
+ prefix: str = "",
+ suffix: str = "",
+ timeout: int = 300,
+ exception_on_empty_embed: bool = False
+ ) -> Optional[Message]:
"""
- 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.
+ 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
+ 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).
+ Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
+ Example:
>>> 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
- """
-
+ """Checks each reaction added, if it matches our conditions pass the wait_for."""
return all((
# Reaction is on the same message sent
reaction_.message.id == message.id,
diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py
new file mode 100644
index 000000000..60f6becaa
--- /dev/null
+++ b/bot/patches/__init__.py
@@ -0,0 +1,6 @@
+"""Subpackage that contains patches for discord.py."""
+from . import message_edited_at
+
+__all__ = [
+ message_edited_at,
+]
diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py
new file mode 100644
index 000000000..a0154f12d
--- /dev/null
+++ b/bot/patches/message_edited_at.py
@@ -0,0 +1,32 @@
+"""
+# message_edited_at patch.
+
+Date: 2019-09-16
+Author: Scragly
+Added by: Ves Zappa
+
+Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of
+`discord.Messages` are not being handled correctly. This patch fixes that until a new
+release of discord.py is released (and we've updated to it).
+"""
+import logging
+
+from discord import message, utils
+
+log = logging.getLogger(__name__)
+
+
+def _handle_edited_timestamp(self: message.Message, value: str) -> None:
+ """Helper function that takes care of parsing the edited timestamp."""
+ self._edited_timestamp = utils.parse_time(value)
+
+
+def apply_patch() -> None:
+ """Applies the `edited_at` patch to the `discord.message.Message` class."""
+ message.Message._handle_edited_timestamp = _handle_edited_timestamp
+ message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp
+ log.info("Patch applied: message_edited_at")
+
+
+if __name__ == "__main__":
+ apply_patch()
diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py
index 47b927101..c550aed76 100644
--- a/bot/rules/attachments.py
+++ b/bot/rules/attachments.py
@@ -1,24 +1,20 @@
-"""Detects total attachments exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
- relevant_messages = tuple(
+ """Detects total attachments exceeding the limit sent by a single user."""
+ relevant_messages = [last_message] + [
msg
for msg in recent_messages
if (
msg.author == last_message.author
and len(msg.attachments) > 0
)
- )
+ ]
total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages)
if total_recent_attachments > config['max']:
diff --git a/bot/rules/burst.py b/bot/rules/burst.py
index 80c79be60..25c5a2f33 100644
--- a/bot/rules/burst.py
+++ b/bot/rules/burst.py
@@ -1,16 +1,12 @@
-"""Detects repeated messages sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects repeated messages sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index 2cb7b5200..bbe9271b3 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -1,16 +1,12 @@
-"""Detects repeated messages sent by multiple users."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects repeated messages sent by multiple users."""
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/rules/chars.py b/bot/rules/chars.py
index d05e3cd83..1f587422c 100644
--- a/bot/rules/chars.py
+++ b/bot/rules/chars.py
@@ -1,16 +1,12 @@
-"""Detects total message char count exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total message char count exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index e4f957ddb..5bab514f2 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -1,5 +1,3 @@
-"""Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user."""
-
import re
from typing import Dict, Iterable, List, Optional, Tuple
@@ -10,11 +8,9 @@ DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py
index 763fc9983..455764b53 100644
--- a/bot/rules/duplicates.py
+++ b/bot/rules/duplicates.py
@@ -1,16 +1,12 @@
-"""Detects duplicated messages sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects duplicated messages sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/links.py b/bot/rules/links.py
index fa4043fcb..ec75a19c5 100644
--- a/bot/rules/links.py
+++ b/bot/rules/links.py
@@ -1,5 +1,3 @@
-"""Detects total links exceeding the limit sent by a single user."""
-
import re
from typing import Dict, Iterable, List, Optional, Tuple
@@ -10,11 +8,9 @@ LINK_RE = re.compile(r"(https?://[^\s]+)")
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total links exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py
index 45c47b6ba..79725a4b1 100644
--- a/bot/rules/mentions.py
+++ b/bot/rules/mentions.py
@@ -1,16 +1,12 @@
-"""Detects total mentions exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total mentions exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py
index fdad6ffd3..4e66e1359 100644
--- a/bot/rules/newlines.py
+++ b/bot/rules/newlines.py
@@ -1,5 +1,3 @@
-"""Detects total newlines exceeding the set limit sent by a single user."""
-
import re
from typing import Dict, Iterable, List, Optional, Tuple
@@ -7,11 +5,9 @@ from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total newlines exceeding the set limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py
index 2177a73b5..0649540b6 100644
--- a/bot/rules/role_mentions.py
+++ b/bot/rules/role_mentions.py
@@ -1,16 +1,12 @@
-"""Detects total role mentions exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total role mentions exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 4c99d50e8..8184be824 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,3 +1,14 @@
+from abc import ABCMeta
+from typing import Any, Generator, Hashable, Iterable
+
+from discord.ext.commands import CogMeta
+
+
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
+
+ pass
+
class CaseInsensitiveDict(dict):
"""
@@ -7,50 +18,59 @@ class CaseInsensitiveDict(dict):
"""
@classmethod
- def _k(cls, key):
+ def _k(cls, key: Hashable) -> Hashable:
+ """Return lowered key if a string-like is passed, otherwise pass key straight through."""
return key.lower() if isinstance(key, str) else key
def __init__(self, *args, **kwargs):
super(CaseInsensitiveDict, self).__init__(*args, **kwargs)
self._convert_keys()
- def __getitem__(self, key):
+ def __getitem__(self, key: Hashable) -> Any:
+ """Case insensitive __setitem__."""
return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key))
- def __setitem__(self, key, value):
+ def __setitem__(self, key: Hashable, value: Any):
+ """Case insensitive __setitem__."""
super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value)
- def __delitem__(self, key):
+ def __delitem__(self, key: Hashable) -> Any:
+ """Case insensitive __delitem__."""
return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key))
- def __contains__(self, key):
+ def __contains__(self, key: Hashable) -> bool:
+ """Case insensitive __contains__."""
return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key))
- def pop(self, key, *args, **kwargs):
+ def pop(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive pop."""
return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs)
- def get(self, key, *args, **kwargs):
+ def get(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive get."""
return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs)
- def setdefault(self, key, *args, **kwargs):
+ def setdefault(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive setdefault."""
return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs)
- def update(self, E=None, **F):
+ def update(self, E: Any = None, **F) -> None:
+ """Case insensitive update."""
super(CaseInsensitiveDict, self).update(self.__class__(E))
super(CaseInsensitiveDict, self).update(self.__class__(**F))
- def _convert_keys(self):
+ def _convert_keys(self) -> None:
+ """Helper method to lowercase all existing string-like keys."""
for k in list(self.keys()):
v = super(CaseInsensitiveDict, self).pop(k)
self.__setitem__(k, v)
-def chunks(iterable, size):
+def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]:
"""
- Generator that allows you to iterate over any indexable collection in `size`-length chunks
+ Generator that allows you to iterate over any indexable collection in `size`-length chunks.
Found: https://stackoverflow.com/a/312464/4022104
"""
-
for i in range(0, len(iterable), size):
yield iterable[i:i + size]
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 37dc657f7..19f64ff9f 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -6,11 +6,7 @@ log = logging.getLogger(__name__)
def with_role_check(ctx: Context, *role_ids: int) -> bool:
- """
- Returns True if the user has any one
- of the roles in role_ids.
- """
-
+ """Returns True if the user has any one of the roles in role_ids."""
if not ctx.guild: # Return False in a DM
log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
"This command is restricted by the with_role decorator. Rejecting request.")
@@ -27,17 +23,13 @@ def with_role_check(ctx: Context, *role_ids: int) -> bool:
def without_role_check(ctx: Context, *role_ids: int) -> bool:
- """
- Returns True if the user does not have any
- of the roles in role_ids.
- """
-
+ """Returns True if the user does not have any of the roles in role_ids."""
if not ctx.guild: # Return False in a DM
log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
"This command is restricted by the without_role decorator. Rejecting request.")
return False
- author_roles = (role.id for role in ctx.author.roles)
+ author_roles = [role.id for role in ctx.author.roles]
check = all(role not in author_roles for role in role_ids)
log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The result of the without_role check was {check}.")
@@ -45,11 +37,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool:
def in_channel_check(ctx: Context, channel_id: int) -> bool:
- """
- Checks if the command was executed
- inside of the specified channel.
- """
-
+ """Checks if the command was executed inside of the specified channel."""
check = ctx.channel.id == channel_id
log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The result of the in_channel check was {check}.")
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index fc38b0127..549b33ca6 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,9 +1,9 @@
import asyncio
import contextlib
from io import BytesIO
-from typing import Sequence
+from typing import Optional, Sequence, Union
-from discord import Embed, File, Message, TextChannel
+from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook
from discord.abc import Snowflake
from discord.errors import HTTPException
@@ -17,42 +17,18 @@ async def wait_for_deletion(
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.
+ attach_emojis: bool = True,
+ client: Optional[Client] = None
+) -> None:
"""
+ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.
+
+ An `attach_emojis` bool may be specified to determine whether to attach the given
+ `deletion_emojis` to the message in the given `context`
+ A `client` instance may be optionally specified, otherwise client will be taken from the
+ guild of the message.
+ """
if message.guild is None and client is None:
raise ValueError("Message must be sent on a guild")
@@ -62,7 +38,8 @@ async def wait_for_deletion(
for emoji in deletion_emojis:
await message.add_reaction(emoji)
- def check(reaction, user):
+ def check(reaction: Reaction, user: Member) -> bool:
+ """Check that the deletion emoji is reacted by the approprite user."""
return (
reaction.message.id == message.id
and reaction.emoji in deletion_emojis
@@ -70,25 +47,17 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await bot.wait_for(
- 'reaction_add',
- check=check,
- timeout=timeout
- )
+ await bot.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
-async def send_attachments(message: Message, destination: TextChannel):
+async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None:
"""
- Re-uploads each attachment in a message to the given channel.
+ Re-uploads each attachment in a message to the given channel or webhook.
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:
@@ -97,7 +66,16 @@ async def send_attachments(message: Message, destination: TextChannel):
if attachment.size <= MAX_SIZE - 512:
with BytesIO() as file:
await attachment.save(file)
- await destination.send(file=File(file, filename=attachment.filename))
+ attachment_file = File(file, filename=attachment.filename)
+
+ if isinstance(destination, TextChannel):
+ await destination.send(file=attachment_file)
+ else:
+ await destination.send(
+ file=attachment_file,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
else:
large.append(attachment)
except HTTPException as e:
@@ -109,4 +87,11 @@ async def send_attachments(message: Message, destination: TextChannel):
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)
+ if isinstance(destination, TextChannel):
+ await destination.send(embed=embed)
+ else:
+ await destination.send(
+ embed=embed,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py
index 724b455bc..7860f14a1 100644
--- a/bot/utils/moderation.py
+++ b/bot/utils/moderation.py
@@ -1,11 +1,12 @@
import logging
-from typing import Union
+from datetime import datetime
+from typing import Optional, Union
-from aiohttp import ClientError
from discord import Member, Object, User
from discord.ext.commands import Context
-from bot.constants import Keys, URLs
+from bot.api import ResponseCodeError
+from bot.constants import Keys
log = logging.getLogger(__name__)
@@ -13,33 +14,59 @@ 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
-):
-
+ ctx: Context,
+ user: Union[Member, Object, User],
+ type: str,
+ reason: str,
+ expires_at: datetime = None,
+ hidden: bool = False,
+ active: bool = True,
+) -> Optional[dict]:
+ """Posts an infraction to the API."""
payload = {
- "type": type,
+ "actor": ctx.message.author.id,
+ "hidden": hidden,
"reason": reason,
- "user_id": str(user.id),
- "actor_id": str(ctx.message.author.id),
- "hidden": hidden
+ "type": type,
+ "user": user.id,
+ "active": active
}
- if duration:
- payload['duration'] = duration
+ if expires_at:
+ payload['expires_at'] = expires_at.isoformat()
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 = await ctx.bot.api_client.post('bot/infractions', json=payload)
+ except ResponseCodeError as exp:
+ if exp.status == 400 and 'user' in exp.response_json:
+ log.info(
+ f"{ctx.author} tried to add a {type} infraction to `{user.id}`, "
+ "but that user id was not found in the database."
+ )
+ await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.")
+ return
+ else:
+ log.exception("An unexpected ResponseCodeError occurred while adding an infraction:")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ return response
- 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
+async def already_has_active_infraction(ctx: Context, user: Union[Member, Object, User], type: str) -> bool:
+ """Checks if a user already has an active infraction of the given type."""
+ active_infractions = await ctx.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': type,
+ 'user__id': str(user.id)
+ }
+ )
+ if active_infractions:
+ await ctx.send(
+ f":x: According to my records, this user already has a {type} infraction. "
+ f"See infraction **#{active_infractions[0]['id']}**."
+ )
+ return True
+ else:
+ return False
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index ded6401b0..08abd91d7 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -1,13 +1,16 @@
import asyncio
import contextlib
import logging
-from abc import ABC, abstractmethod
-from typing import Dict
+from abc import abstractmethod
+from typing import Coroutine, Dict, Union
+
+from bot.utils import CogABCMeta
log = logging.getLogger(__name__)
-class Scheduler(ABC):
+class Scheduler(metaclass=CogABCMeta):
+ """Task scheduler."""
def __init__(self):
@@ -15,24 +18,23 @@ class Scheduler(ABC):
self.scheduled_tasks: Dict[str, asyncio.Task] = {}
@abstractmethod
- async def _scheduled_task(self, task_object: dict):
+ async def _scheduled_task(self, task_object: dict) -> None:
"""
- 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.
+ A coroutine which handles the scheduling.
+
+ This is added to the scheduled tasks, and should wait the task duration, execute the desired
+ code, then 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):
+ def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict) -> None:
"""
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`.
- """
+ `task_data` is passed to `Scheduler._scheduled_expiration`
+ """
if task_id in self.scheduled_tasks:
return
@@ -40,12 +42,8 @@ class Scheduler(ABC):
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
- """
-
+ def cancel_task(self, task_id: str) -> None:
+ """Un-schedules a task."""
task = self.scheduled_tasks.get(task_id)
if task is None:
@@ -57,14 +55,8 @@ class Scheduler(ABC):
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.
- """
-
+def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine, asyncio.Future]) -> asyncio.Task:
+ """Creates an asyncio.Task object from a coroutine or future object."""
task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop)
# Silently ignore exceptions in a callback (handles the CancelledError nonsense)
@@ -72,6 +64,7 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future):
return task
-def _silent_exception(future):
+def _silent_exception(future: asyncio.Future) -> None:
+ """Suppress future's exception."""
with contextlib.suppress(Exception):
future.exception()
diff --git a/bot/utils/service_discovery.py b/bot/utils/service_discovery.py
deleted file mode 100644
index 8d79096bd..000000000
--- a/bot/utils/service_discovery.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import datetime
-import socket
-import time
-from contextlib import closing
-
-from bot.constants import RabbitMQ
-
-THIRTY_SECONDS = datetime.timedelta(seconds=30)
-
-
-def wait_for_rmq():
- start = datetime.datetime.now()
-
- while True:
- if datetime.datetime.now() - start > THIRTY_SECONDS:
- return False
-
- with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
- if sock.connect_ex((RabbitMQ.host, RabbitMQ.port)) == 0:
- return True
-
- time.sleep(0.5)
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 8e5d4e1bd..c529ccc2b 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -6,10 +6,9 @@ from dateutil.relativedelta import relativedelta
RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-def _stringify_time_unit(value: int, unit: str):
+def _stringify_time_unit(value: int, unit: str) -> str:
"""
- Returns a string to represent a value and time unit,
- ensuring that it uses the right plural form of the unit.
+ Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
>>> _stringify_time_unit(1, "seconds")
"1 second"
@@ -18,7 +17,6 @@ def _stringify_time_unit(value: int, unit: str):
>>> _stringify_time_unit(0, "minutes")
"less than a minute"
"""
-
if value == 1:
return f"{value} {unit[:-1]}"
elif value == 0:
@@ -27,18 +25,13 @@ def _stringify_time_unit(value: int, unit: str):
return f"{value} {unit}"
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6):
+def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
"""
Returns a human-readable version of the relativedelta.
- :param delta: A dateutil.relativedelta.relativedelta object
- :param precision: The smallest unit that should be included.
- :param max_units: The maximum number of time-units to return.
-
- :return: A string like `4 days, 12 hours and 1 second`,
- `1 minute`, or `less than a minute`.
+ precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
+ max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
"""
-
units = (
("years", delta.years),
("months", delta.months),
@@ -73,19 +66,13 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
-def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6):
+def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str:
"""
- Takes a datetime and returns a human-readable string that
- describes how long ago that datetime was.
+ Takes a datetime and returns a human-readable string that describes how long ago that datetime was.
- :param past_datetime: A datetime.datetime object
- :param precision: The smallest unit that should be included.
- :param max_units: The maximum number of time-units to return.
-
- :return: A string like `4 days, 12 hours and 1 second ago`,
- `1 minute ago`, or `less than a minute ago`.
+ precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
+ max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
"""
-
now = datetime.datetime.utcnow()
delta = abs(relativedelta(now, past_datetime))
@@ -94,20 +81,17 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max
return f"{humanized} ago"
-def parse_rfc1123(time_str):
+def parse_rfc1123(time_str: str) -> datetime.datetime:
+ """Parse RFC1123 time string into datetime."""
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)
+async def wait_until(time: datetime.datetime) -> None:
+ """Wait until a given time."""
+ delay = time - datetime.datetime.utcnow()
delay_seconds = delay.total_seconds()
+ # Incorporate a small delay so we don't rapid-fire the event due to time precision errors
if delay_seconds > 1.0:
await asyncio.sleep(delay_seconds)
diff --git a/config-default.yml b/config-default.yml
index af0621ece..403de21ad 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -95,7 +95,7 @@ guild:
bot: 267659945086812160
checkpoint_test: 422077681434099723
defcon: 464469101889454091
- devlog: &DEVLOG 409308876241108992
+ devlog: &DEVLOG 622895325144940554
devtest: &DEVTEST 414574275865870337
help_0: 303906576991780866
help_1: 303906556754395136
@@ -103,6 +103,8 @@ guild:
help_3: 439702951246692352
help_4: 451312046647148554
help_5: 454941769734422538
+ help_6: 587375753306570782
+ help_7: 587375768556797982
helpers: 385474242440986624
message_log: &MESSAGE_LOG 467752170159079424
mod_alerts: 473092532147060736
@@ -125,8 +127,7 @@ guild:
announcements: 463658397560995840
champion: 430492892331769857
contributor: 295488872404484098
- developer: 352427296948486144
- devops: &DEVOPS_ROLE 409416496733880320
+ core_developer: 587606783669829632
jammer: 423054537079783434
moderator: &MOD_ROLE 267629731250176001
muted: &MUTED_ROLE 277914926603829249
@@ -136,6 +137,10 @@ guild:
rockstars: &ROCKSTARS_ROLE 458226413825294336
team_leader: 501324292341104650
+ webhooks:
+ talent_pool: 569145364800602132
+ big_brother: 569133704568373283
+
filter:
@@ -167,6 +172,7 @@ filter:
- 327254708534116352 # Adafruit
- 544525886180032552 # kennethreitz.org
- 590806733924859943 # Discord Hack Week
+ - 423249981340778496 # Kivy
domain_blacklist:
- pornhub.com
@@ -217,28 +223,19 @@ filter:
- *ADMIN_ROLE
- *MOD_ROLE
- *OWNER_ROLE
- - *DEVOPS_ROLE
- *ROCKSTARS_ROLE
keys:
- deploy_bot: !ENV "DEPLOY_BOT_KEY"
- deploy_site: !ENV "DEPLOY_SITE"
site_api: !ENV "BOT_API_KEY"
-rabbitmq:
- host: "pdrmq"
- password: !ENV ["RABBITMQ_DEFAULT_PASS", "guest"]
- port: 5672
- username: !ENV ["RABBITMQ_DEFAULT_USER", "guest"]
-
-
urls:
# PyDis site vars
site: &DOMAIN "pythondiscord.com"
site_api: &API !JOIN ["api.", *DOMAIN]
site_paste: &PASTE !JOIN ["paste.", *DOMAIN]
+ site_staff: &STAFF !JOIN ["staff.", *DOMAIN]
site_schema: &SCHEMA "https://"
site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"]
@@ -251,7 +248,7 @@ urls:
site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"]
site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"]
- site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"]
+ site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"]
site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
@@ -261,9 +258,8 @@ urls:
site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"]
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
- # Env vars
- deploy: !ENV "DEPLOY_URL"
- status: !ENV "STATUS_URL"
+ # Snekbox
+ snekbox_eval_api: "https://snekbox.pythondiscord.com/eval"
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
@@ -271,7 +267,7 @@ urls:
# Misc URLs
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
- gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot"
+ github_bot_repo: "https://github.com/python-discord/bot"
anti_spam:
# Clean messages that violate a rule.
diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile
deleted file mode 100644
index e46db756a..000000000
--- a/docker/base.Dockerfile
+++ /dev/null
@@ -1,17 +0,0 @@
-FROM python:3.6-alpine3.7
-
-RUN apk add --update tini
-RUN apk add --update build-base
-RUN apk add --update libffi-dev
-RUN apk add --update zlib
-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 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
diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile
deleted file mode 100644
index 5a07a612b..000000000
--- a/docker/bot.Dockerfile
+++ /dev/null
@@ -1,17 +0,0 @@
-FROM pythondiscord/bot-base:latest
-
-ENV PIPENV_VENV_IN_PROJECT=1
-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
-
-ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["pipenv", "run", "start"]
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
index 6b3dea508..ed4b719e2 100644
--- a/scripts/deploy-azure.sh
+++ b/scripts/deploy-azure.sh
@@ -4,28 +4,9 @@ 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 .
+ docker build -t pythondiscord/bot:latest .
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
+fi
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
deleted file mode 100644
index 070d0ec26..000000000
--- a/scripts/deploy.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-
-# Build and deploy on master branch
-if [[ $CI_COMMIT_REF_SLUG == 'master' ]]; then
- echo "Connecting to docker hub"
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
-
- 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: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK
-else
- echo "Skipping deploy"
-fi
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/cogs/__init__.py
diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/cogs/sync/__init__.py
diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py
new file mode 100644
index 000000000..c561ba447
--- /dev/null
+++ b/tests/cogs/sync/test_roles.py
@@ -0,0 +1,103 @@
+from bot.cogs.sync.syncers import Role, get_roles_for_sync
+
+
+def test_get_roles_for_sync_empty_return_for_equal_roles():
+ api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)}
+ guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)}
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set())
+
+
+def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff():
+ api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)}
+ guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)}
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ set(),
+ guild_roles,
+ set(),
+ )
+
+
+def test_get_roles_only_returns_roles_that_require_update():
+ api_roles = {
+ Role(id=41, name='old name', colour=33, permissions=0x8, position=1),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
+ }
+ guild_roles = {
+ Role(id=41, name='new name', colour=35, permissions=0x8, position=2),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ set(),
+ {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)},
+ set(),
+ )
+
+
+def test_get_roles_returns_new_roles_in_first_tuple_element():
+ api_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ }
+ guild_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ Role(id=53, name='other role', colour=55, permissions=0, position=2)
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ {Role(id=53, name='other role', colour=55, permissions=0, position=2)},
+ set(),
+ set(),
+ )
+
+
+def test_get_roles_returns_roles_to_update_and_new_roles():
+ api_roles = {
+ Role(id=41, name='old name', colour=35, permissions=0x8, position=1),
+ }
+ guild_roles = {
+ Role(id=41, name='new name', colour=40, permissions=0x16, position=2),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ {Role(id=53, name='other role', colour=55, permissions=0, position=3)},
+ {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)},
+ set(),
+ )
+
+
+def test_get_roles_returns_roles_to_delete():
+ api_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ Role(id=61, name='to delete', colour=99, permissions=0x9, position=2),
+ }
+ guild_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ set(),
+ set(),
+ {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)},
+ )
+
+
+def test_get_roles_returns_roles_to_delete_update_and_new_roles():
+ api_roles = {
+ Role(id=41, name='not changed', colour=35, permissions=0x8, position=1),
+ Role(id=61, name='to delete', colour=99, permissions=0x9, position=2),
+ Role(id=71, name='to update', colour=99, permissions=0x9, position=3),
+ }
+ guild_roles = {
+ Role(id=41, name='not changed', colour=35, permissions=0x8, position=1),
+ Role(id=81, name='to create', colour=99, permissions=0x9, position=4),
+ Role(id=71, name='updated', colour=101, permissions=0x5, position=3),
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)},
+ {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)},
+ {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)},
+ )
diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py
new file mode 100644
index 000000000..a863ae35b
--- /dev/null
+++ b/tests/cogs/sync/test_users.py
@@ -0,0 +1,69 @@
+from bot.cogs.sync.syncers import User, get_users_for_sync
+
+
+def fake_user(**kwargs):
+ kwargs.setdefault('id', 43)
+ kwargs.setdefault('name', 'bob the test man')
+ kwargs.setdefault('discriminator', 1337)
+ kwargs.setdefault('avatar_hash', None)
+ kwargs.setdefault('roles', (666,))
+ kwargs.setdefault('in_guild', True)
+ return User(**kwargs)
+
+
+def test_get_users_for_sync_returns_nothing_for_empty_params():
+ assert get_users_for_sync({}, {}) == (set(), set())
+
+
+def test_get_users_for_sync_returns_nothing_for_equal_users():
+ api_users = {43: fake_user()}
+ guild_users = {43: fake_user()}
+
+ assert get_users_for_sync(guild_users, api_users) == (set(), set())
+
+
+def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff():
+ api_users = {43: fake_user()}
+ guild_users = {43: fake_user(name='new fancy name')}
+
+ assert get_users_for_sync(guild_users, api_users) == (
+ set(),
+ {fake_user(name='new fancy name')}
+ )
+
+
+def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild():
+ api_users = {43: fake_user()}
+ guild_users = {43: fake_user(), 63: fake_user(id=63)}
+
+ assert get_users_for_sync(guild_users, api_users) == (
+ {fake_user(id=63)},
+ set()
+ )
+
+
+def test_get_users_for_sync_updates_in_guild_field_on_user_leave():
+ api_users = {43: fake_user(), 63: fake_user(id=63)}
+ guild_users = {43: fake_user()}
+
+ assert get_users_for_sync(guild_users, api_users) == (
+ set(),
+ {fake_user(id=63, in_guild=False)}
+ )
+
+
+def test_get_users_for_sync_updates_and_creates_users_as_needed():
+ api_users = {43: fake_user()}
+ guild_users = {63: fake_user(id=63)}
+
+ assert get_users_for_sync(guild_users, api_users) == (
+ {fake_user(id=63)},
+ {fake_user(in_guild=False)}
+ )
+
+
+def test_get_users_for_sync_does_not_duplicate_update_users():
+ api_users = {43: fake_user(in_guild=False)}
+ guild_users = {}
+
+ assert get_users_for_sync(guild_users, api_users) == (set(), set())
diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py
new file mode 100644
index 000000000..67900b275
--- /dev/null
+++ b/tests/cogs/test_antispam.py
@@ -0,0 +1,30 @@
+import pytest
+
+from bot.cogs import antispam
+
+
+def test_default_antispam_config_is_valid():
+ validation_errors = antispam.validate_config()
+ assert not validation_errors
+
+
+ ('config', 'expected'),
+ (
+ (
+ {'invalid-rule': {}},
+ {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."}
+ ),
+ (
+ {'burst': {'interval': 10}},
+ {'burst': "Key `max` is required but not set for rule `burst`"}
+ ),
+ (
+ {'burst': {'max': 10}},
+ {'burst': "Key `interval` is required but not set for rule `burst`"}
+ )
+ )
+)
+def test_invalid_antispam_config_returns_validation_errors(config, expected):
+ validation_errors = antispam.validate_config(config)
+ assert validation_errors == expected
diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py
new file mode 100644
index 000000000..85b2d092e
--- /dev/null
+++ b/tests/cogs/test_information.py
@@ -0,0 +1,163 @@
+import asyncio
+import logging
+import textwrap
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from discord import (
+ CategoryChannel,
+ Colour,
+ TextChannel,
+ VoiceChannel,
+)
+
+from bot.cogs import information
+from bot.constants import Emojis
+from bot.decorators import InChannelCheckFailure
+from tests.helpers import AsyncMock
+
+
+def cog(simple_bot):
+ return information.Information(simple_bot)
+
+
+def role(name: str, id_: int):
+ r = MagicMock()
+ r.name = name
+ r.id = id_
+ r.mention = f'&{name}'
+ return r
+
+
+def member(status: str):
+ m = MagicMock()
+ m.status = status
+ return m
+
+
+def ctx(moderator_role, simple_ctx):
+ simple_ctx.author.roles = [moderator_role]
+ simple_ctx.guild.created_at = datetime(2001, 1, 1)
+ simple_ctx.send = AsyncMock()
+ return simple_ctx
+
+
+def test_roles_info_command(cog, ctx):
+ everyone_role = MagicMock()
+ everyone_role.name = '@everyone' # should be excluded in the output
+ ctx.author.roles.append(everyone_role)
+ ctx.guild.roles = ctx.author.roles
+
+ cog.roles_info.can_run = AsyncMock()
+ cog.roles_info.can_run.return_value = True
+
+ coroutine = cog.roles_info.callback(cog, ctx)
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once()
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.title == "Role information"
+ assert embed.colour == Colour.blurple()
+ assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n"
+ assert embed.footer.text == "Total roles: 1"
+
+
+# There is no argument passed in here that we can use to test,
+# so the return value would change constantly.
+@patch('bot.cogs.information.time_since')
+def test_server_info_command(time_since_patch, cog, ctx, moderator_role):
+ time_since_patch.return_value = '2 days ago'
+
+ ctx.guild.created_at = datetime(2001, 1, 1)
+ ctx.guild.features = ('lemons', 'apples')
+ ctx.guild.region = 'The Moon'
+ ctx.guild.roles = [moderator_role]
+ ctx.guild.channels = [
+ TextChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'}
+ ),
+ CategoryChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'}
+ ),
+ VoiceChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'}
+ )
+ ]
+ ctx.guild.members = [
+ member('online'), member('online'),
+ member('idle'),
+ member('dnd'), member('dnd'), member('dnd'), member('dnd'),
+ member('offline'), member('offline'), member('offline')
+ ]
+ ctx.guild.member_count = 1_234
+ ctx.guild.icon_url = 'a-lemon.png'
+
+ coroutine = cog.server_info.callback(cog, ctx)
+ assert asyncio.run(coroutine) is None # no rval
+
+ time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days')
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.colour == Colour.blurple()
+ assert embed.description == textwrap.dedent(f"""
+ **Server information**
+ Created: {time_since_patch.return_value}
+ Voice region: {ctx.guild.region}
+ Features: {', '.join(ctx.guild.features)}
+
+ **Counts**
+ Members: {ctx.guild.member_count:,}
+ Roles: {len(ctx.guild.roles)}
+ Text: 1
+ Voice: 1
+ Channel categories: 1
+
+ **Members**
+ {Emojis.status_online} 2
+ {Emojis.status_idle} 1
+ {Emojis.status_dnd} 4
+ {Emojis.status_offline} 3
+ """)
+ assert embed.thumbnail.url == 'a-lemon.png'
+
+
+def test_user_info_on_other_users_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+ coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once_with(
+ "You may not use this command on users other than yourself."
+ )
+
+
+def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+
+ coroutine = cog.user_info.callback(cog, ctx)
+ message = 'Sorry, but you may only use this command within <#267659945086812160>.'
+ with pytest.raises(InChannelCheckFailure, match=message):
+ assert asyncio.run(coroutine) is None # no rval
+
+
+def test_setup(simple_bot, caplog):
+ information.setup(simple_bot)
+ simple_bot.add_cog.assert_called_once()
+ [record] = caplog.records
+
+ assert record.message == "Cog loaded: Information"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py
new file mode 100644
index 000000000..1efb460fe
--- /dev/null
+++ b/tests/cogs/test_security.py
@@ -0,0 +1,54 @@
+import logging
+from unittest.mock import MagicMock
+
+import pytest
+from discord.ext.commands import NoPrivateMessage
+
+from bot.cogs import security
+
+
+def cog():
+ bot = MagicMock()
+ return security.Security(bot)
+
+
+def context():
+ return MagicMock()
+
+
+def test_check_additions(cog):
+ cog.bot.check.assert_any_call(cog.check_on_guild)
+ cog.bot.check.assert_any_call(cog.check_not_bot)
+
+
+def test_check_not_bot_for_humans(cog, context):
+ context.author.bot = False
+ assert cog.check_not_bot(context)
+
+
+def test_check_not_bot_for_robots(cog, context):
+ context.author.bot = True
+ assert not cog.check_not_bot(context)
+
+
+def test_check_on_guild_outside_of_guild(cog, context):
+ context.guild = None
+
+ with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."):
+ cog.check_on_guild(context)
+
+
+def test_check_on_guild_on_guild(cog, context):
+ context.guild = "lemon's lemonade stand"
+ assert cog.check_on_guild(context)
+
+
+def test_security_cog_load(caplog):
+ bot = MagicMock()
+ security.setup(bot)
+ bot.add_cog.assert_called_once()
+ [record] = caplog.records
+ assert record.message == "Cog loaded: Security"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py
new file mode 100644
index 000000000..9d46b3a05
--- /dev/null
+++ b/tests/cogs/test_token_remover.py
@@ -0,0 +1,133 @@
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+from discord import Colour
+
+from bot.cogs.token_remover import (
+ DELETION_MESSAGE_TEMPLATE,
+ TokenRemover,
+ setup as setup_cog,
+)
+from bot.constants import Channels, Colours, Event, Icons
+from tests.helpers import AsyncMock
+
+
+def token_remover():
+ bot = MagicMock()
+ bot.get_cog.return_value = MagicMock()
+ bot.get_cog.return_value.send_log_message = AsyncMock()
+ return TokenRemover(bot=bot)
+
+
+def message():
+ message = MagicMock()
+ message.author.__str__.return_value = 'lemon'
+ message.author.bot = False
+ message.author.avatar_url_as.return_value = 'picture-lemon.png'
+ message.author.id = 42
+ message.author.mention = '@lemon'
+ message.channel.send = AsyncMock()
+ message.channel.mention = '#lemonade-stand'
+ message.content = ''
+ message.delete = AsyncMock()
+ message.id = 555
+ return message
+
+
+ ('content', 'expected'),
+ (
+ ('MTIz', True), # 123
+ ('YWJj', False), # abc
+ )
+)
+def test_is_valid_user_id(content: str, expected: bool):
+ assert TokenRemover.is_valid_user_id(content) is expected
+
+
+ ('content', 'expected'),
+ (
+ ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag!
+ ('MTIz', False), # 123
+ )
+)
+def test_is_valid_timestamp(content: str, expected: bool):
+ assert TokenRemover.is_valid_timestamp(content) is expected
+
+
+def test_mod_log_property(token_remover):
+ token_remover.bot.get_cog.return_value = 'lemon'
+ assert token_remover.mod_log == 'lemon'
+ token_remover.bot.get_cog.assert_called_once_with('ModLog')
+
+
+def test_ignores_bot_messages(token_remover, message):
+ message.author.bot = True
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('', 'lemon wins'))
+def test_ignores_messages_without_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('foo.bar.baz', 'x.y.'))
+def test_ignores_invalid_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
+ 'content, censored_token',
+ (
+ ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'),
+ )
+)
+def test_censors_valid_tokens(
+ token_remover, message, content, censored_token, caplog
+):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None # still no rval
+
+ # asyncio logs some stuff about its reactor, discard it
+ [_, record] = caplog.records
+ assert record.message == (
+ "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, "
+ f"token was `{censored_token}`"
+ )
+
+ message.delete.assert_called_once_with()
+ message.channel.send.assert_called_once_with(
+ DELETION_MESSAGE_TEMPLATE.format(mention='@lemon')
+ )
+ token_remover.bot.get_cog.assert_called_with('ModLog')
+ message.author.avatar_url_as.assert_called_once_with(static_format='png')
+
+ mod_log = token_remover.bot.get_cog.return_value
+ mod_log.ignore.assert_called_once_with(Event.message_delete, message.id)
+ mod_log.send_log_message.assert_called_once_with(
+ icon_url=Icons.token_removed,
+ colour=Colour(Colours.soft_red),
+ title="Token removed!",
+ text=record.message,
+ thumbnail='picture-lemon.png',
+ channel_id=Channels.mod_alerts
+ )
+
+
+def test_setup(caplog):
+ bot = MagicMock()
+ setup_cog(bot)
+ [record] = caplog.records
+
+ bot.add_cog.assert_called_once()
+ assert record.message == "Cog loaded: TokenRemover"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..d3de4484d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,32 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.constants import Roles
+from tests.helpers import AsyncMock
+
+
+def moderator_role():
+ mock = MagicMock()
+ mock.id = Roles.moderator
+ mock.name = 'Moderator'
+ mock.mention = f'&{mock.name}'
+ return mock
+
+
+def simple_bot():
+ mock = MagicMock()
+ mock._before_invoke = AsyncMock()
+ mock._after_invoke = AsyncMock()
+ mock.can_run = AsyncMock()
+ mock.can_run.return_value = True
+ return mock
+
+
+def simple_ctx(simple_bot):
+ mock = MagicMock()
+ mock.bot = simple_bot
+ return mock
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 000000000..2908294f7
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,29 @@
+import asyncio
+import functools
+from unittest.mock import MagicMock
+
+
+__all__ = ('AsyncMock', 'async_test')
+
+
+# TODO: Remove me on 3.8
+class AsyncMock(MagicMock):
+ async def __call__(self, *args, **kwargs):
+ return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+def async_test(wrapped):
+ """
+ Run a test case via asyncio.
+
+ Example:
+
+ >>> @async_test
+ ... async def lemon_wins():
+ ... assert True
+ """
+
+ @functools.wraps(wrapped)
+ def wrapper(*args, **kwargs):
+ return asyncio.run(wrapped(*args, **kwargs))
+ return wrapper
diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/rules/__init__.py
diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py
new file mode 100644
index 000000000..6f025b3cb
--- /dev/null
+++ b/tests/rules/test_attachments.py
@@ -0,0 +1,52 @@
+import asyncio
+from dataclasses import dataclass
+from typing import Any, List
+
+import pytest
+
+from bot.rules import attachments
+
+
+# Using `MagicMock` sadly doesn't work for this usecase
+# since it's __eq__ compares the MagicMock's ID. We just
+# want to compare the actual attributes we set.
+@dataclass
+class FakeMessage:
+ author: str
+ attachments: List[Any]
+
+
+def msg(total_attachments: int):
+ return FakeMessage(author='lemon', attachments=list(range(total_attachments)))
+
+
+ 'messages',
+ (
+ (msg(0), msg(0), msg(0)),
+ (msg(2), msg(2)),
+ (msg(0),),
+ )
+)
+def test_allows_messages_without_too_many_attachments(messages):
+ last_message, *recent_messages = messages
+ coro = attachments.apply(last_message, recent_messages, {'max': 5})
+ assert asyncio.run(coro) is None
+
+
+ ('messages', 'relevant_messages', 'total'),
+ (
+ ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10),
+ ((msg(6),), [msg(6)], 6),
+ ((msg(1),) * 6, [msg(1)] * 6, 6),
+ )
+)
+def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total):
+ last_message, *recent_messages = messages
+ coro = attachments.apply(last_message, recent_messages, {'max': 5})
+ assert asyncio.run(coro) == (
+ f"sent {total} attachments in 5s",
+ ('lemon',),
+ relevant_messages
+ )
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 000000000..ce69ef187
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,106 @@
+import logging
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from bot import api
+from tests.helpers import async_test
+
+
+def test_loop_is_not_running_by_default():
+ assert not api.loop_is_running()
+
+
+@async_test
+async def test_loop_is_running_in_async_test():
+ assert api.loop_is_running()
+
+
+def error_api_response():
+ response = MagicMock()
+ response.status = 999
+ return response
+
+
+def api_log_handler():
+ return api.APILoggingHandler(None)
+
+
+def debug_log_record():
+ return logging.LogRecord(
+ name='my.logger', level=logging.DEBUG,
+ pathname='my/logger.py', lineno=666,
+ msg="Lemon wins", args=(),
+ exc_info=None
+ )
+
+
+def test_response_code_error_default_initialization(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert error.status is error_api_response.status
+ assert not error.response_json
+ assert not error.response_text
+ assert error.response is error_api_response
+
+
+def test_response_code_error_default_representation(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert str(error) == f"Status: {error_api_response.status} Response: "
+
+
+def test_response_code_error_representation_with_nonempty_response_json(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_json={'hello': 'world'}
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}"
+
+
+def test_response_code_error_representation_with_nonempty_response_text(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_text='Lemon will eat your soul'
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul"
+
+
+@patch('bot.api.APILoggingHandler.ship_off')
+def test_emit_appends_to_queue_with_stopped_event_loop(
+ ship_off_patch, api_log_handler, debug_log_record
+):
+ # This is a coroutine so returns something we should await,
+ # but asyncio complains about that. To ease testing, we patch
+ # `ship_off` to just return a regular value instead.
+ ship_off_patch.return_value = 42
+ api_log_handler.emit(debug_log_record)
+
+ assert api_log_handler.queue == [42]
+
+
+def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler):
+ debug_log_record.levelno = logging.DEBUG - 5
+ api_log_handler.emit(debug_log_record)
+ assert not api_log_handler.queue
+
+
+def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog):
+ api_log_handler.schedule_queued_tasks()
+ # Logs when tasks are scheduled
+ assert not caplog.records
+
+
+@patch('asyncio.create_task')
+def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog):
+ api_log_handler.queue = [555]
+ api_log_handler.schedule_queued_tasks()
+ assert not api_log_handler.queue
+ create_task_patch.assert_called_once_with(555)
+
+ [record] = caplog.records
+ assert record.message == "Scheduled 1 pending logging tasks."
+ assert record.levelno == logging.DEBUG
+ assert record.name == 'bot.api'
+ assert record.__dict__['via_handler']
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 000000000..e4a29d994
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,23 @@
+import inspect
+
+import pytest
+
+from bot import constants
+
+
+ 'section',
+ (
+ cls
+ for (name, cls) in inspect.getmembers(constants)
+ if hasattr(cls, 'section') and isinstance(cls, type)
+ )
+)
+def test_section_configuration_matches_typespec(section):
+ for (name, annotation) in section.__annotations__.items():
+ value = getattr(section, name)
+
+ if getattr(annotation, '_name', None) in ('Dict', 'List'):
+ pytest.skip("Cannot validate containers yet")
+
+ assert isinstance(value, annotation)
diff --git a/tests/test_converters.py b/tests/test_converters.py
new file mode 100644
index 000000000..3cf774c80
--- /dev/null
+++ b/tests/test_converters.py
@@ -0,0 +1,93 @@
+import asyncio
+from datetime import datetime
+from unittest.mock import MagicMock
+
+import pytest
+from discord.ext.commands import BadArgument
+
+from bot.converters import (
+ ExpirationDate,
+ TagContentConverter,
+ TagNameConverter,
+ ValidPythonIdentifier,
+)
+
+
+ ('value', 'expected'),
+ (
+ # sorry aliens
+ ('2199-01-01T00:00:00', datetime(2199, 1, 1)),
+ )
+)
+def test_expiration_date_converter_for_valid(value: str, expected: datetime):
+ converter = ExpirationDate()
+ assert asyncio.run(converter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('hello', 'hello'),
+ (' h ello ', 'h ello')
+ )
+)
+def test_tag_content_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagContentConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('', "Tag contents should not be empty, or filled with whitespace."),
+ (' ', "Tag contents should not be empty, or filled with whitespace.")
+ )
+)
+def test_tag_content_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagContentConverter.convert(context, value))
+
+
+ ('value', 'expected'),
+ (
+ ('tracebacks', 'tracebacks'),
+ ('Tracebacks', 'tracebacks'),
+ (' Tracebacks ', 'tracebacks'),
+ )
+)
+def test_tag_name_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagNameConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('👋', "Don't be ridiculous, you can't use that character!"),
+ ('', "Tag names should not be empty, or filled with whitespace."),
+ (' ', "Tag names should not be empty, or filled with whitespace."),
+ ('42', "Tag names can't be numbers."),
+ # Escape question mark as this is evaluated as regular expression.
+ ('x' * 128, r"Are you insane\? That's way too long!"),
+ )
+)
+def test_tag_name_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagNameConverter.convert(context, value))
+
+
[email protected]('value', ('foo', 'lemon'))
+def test_valid_python_identifier_for_valid(value: str):
+ assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value
+
+
[email protected]('value', ('nested.stuff', '#####'))
+def test_valid_python_identifier_for_invalid(value: str):
+ with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'):
+ asyncio.run(ValidPythonIdentifier.convert(None, value))
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
new file mode 100644
index 000000000..11d6541ae
--- /dev/null
+++ b/tests/test_pagination.py
@@ -0,0 +1,29 @@
+from unittest import TestCase
+
+import pytest
+
+from bot import pagination
+
+
+class LinePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30)
+
+ def test_add_line_raises_on_too_long_lines(self):
+ message = f"Line exceeds maximum page size {self.paginator.max_size - 2}"
+ with pytest.raises(RuntimeError, match=message):
+ self.paginator.add_line('x' * self.paginator.max_size)
+
+ def test_add_line_works_on_small_lines(self):
+ self.paginator.add_line('x' * (self.paginator.max_size - 3))
+
+
+class ImagePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.ImagePaginator()
+
+ def test_add_image_appends_image(self):
+ image = 'lemon'
+ self.paginator.add_image(image)
+
+ assert self.paginator.images == [image]
diff --git a/tests/test_resources.py b/tests/test_resources.py
new file mode 100644
index 000000000..2b17aea64
--- /dev/null
+++ b/tests/test_resources.py
@@ -0,0 +1,18 @@
+import json
+import mimetypes
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+def test_stars_valid():
+ """Validates that `bot/resources/stars.json` contains valid images."""
+
+ path = Path('bot', 'resources', 'stars.json')
+ content = path.read_text()
+ data = json.loads(content)
+
+ for url in data.values():
+ assert urlparse(url).scheme == 'https'
+
+ mimetype, _ = mimetypes.guess_type(url)
+ assert mimetype in ('image/jpeg', 'image/png')
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/utils/__init__.py
diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py
new file mode 100644
index 000000000..7121acebd
--- /dev/null
+++ b/tests/utils/test_checks.py
@@ -0,0 +1,66 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.utils import checks
+
+
+def context():
+ return MagicMock()
+
+
+def test_with_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_without_required_role(context):
+ context.guild = True
+ context.author.roles = []
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_with_required_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.with_role_check(context, role.id)
+
+
+def test_without_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.without_role_check(context)
+
+
+def test_without_role_check_with_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert not checks.without_role_check(context, role.id)
+
+
+def test_without_role_check_without_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.without_role_check(context, role.id + 10)
+
+
+def test_in_channel_check_for_correct_channel(context):
+ context.channel.id = 42
+ assert checks.in_channel_check(context, context.channel.id)
+
+
+def test_in_channel_check_for_incorrect_channel(context):
+ context.channel.id = 42
+ assert not checks.in_channel_check(context, context.channel.id + 10)
diff --git a/tox.ini b/tox.ini
index c6fa513f4..d14819d57 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,19 @@
[flake8]
max-line-length=120
-application_import_names=bot
-exclude=.cache,.venv
-ignore=B311,W503,E226,S311
+docstring-convention=all
import-order-style=pycharm
+application_import_names=bot,tests
+exclude=.cache,.venv,constants.py
+ignore=
+ B311,W503,E226,S311,T000
+ # Missing Docstrings
+ D100,D104,D105,D107,
+ # Docstring Whitespace
+ D203,D212,D214,D215,
+ # Docstring Quotes
+ D301,D302,
+ # Docstring Content
+ D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417
+ # Type Annotations
+ TYP002,TYP003,TYP101,TYP102,TYP204,TYP206
+per-file-ignores=tests/*:D,TYP