diff --git a/.dockerignore b/.dockerignore
index 57b6d8718c..bae84fb2e4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -93,6 +93,7 @@ celerybeat-schedule
.venv
env/
venv/
+venv2/
ENV/
env.bak/
venv.bak/
@@ -145,8 +146,7 @@ app.json
CHANGELOG.md
CODE_OF_CONDUCT.md
CONTRIBUTING.md
-Pipfile
-Pipfile.lock
+requirements.min.txt
Procfile
pyproject.toml
README.md
diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml
index 852b3e1e07..c84c9af773 100644
--- a/.github/workflows/lints.yml
+++ b/.github/workflows/lints.yml
@@ -13,21 +13,16 @@ jobs:
# python-version: [3.6, 3.7]
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- python-version: [3.6, 3.7]
-
steps:
- uses: actions/checkout@v1
- - name: Set up Python ${{ matrix.python-version }}
+ - name: Set up Python 3.7
uses: actions/setup-python@v1
with:
- python-version: ${{ matrix.python-version }}
+ python-version: 3.7
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- python -m pip install bandit flake8 pylint black
+ python -m pip install bandit pylint black
continue-on-error: true
- name: Bandit syntax check
run: bandit ./bot.py cogs/*.py core/*.py -b .bandit_baseline.json
@@ -37,4 +32,3 @@ jobs:
- name: Black and flake8
run: |
black . --check
- flake8 ./bot.py cogs/*.py core/*.py --ignore=E501,E203,W503
\ No newline at end of file
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 8b02280dcb..b3003ccf92 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -1,4 +1,5 @@
-name: "Close stale issues"
+name: "Close Stale Issues"
+
on:
schedule:
- cron: "0 0 * * *"
@@ -10,6 +11,7 @@ jobs:
- uses: actions/stale@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- stale-issue-message: 'This issue is stale because it has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 5 days'
- days-before-stale: 90
+ stale-issue-message: 'This issue is stale because it has been open for 100 days with no activity. Remove stale label or comment or this will be closed in 5 days. Please do not un-stale this issue unless it carries significant contribution.'
+ days-before-stale: 100
days-before-close: 5
+ exempt-issue-label: 'high priority'
diff --git a/.pylintrc b/.pylintrc
index a45837fa82..21087a91f7 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -267,7 +267,7 @@ indent-after-paren=4
indent-string=' '
# Maximum number of characters on a single line.
-max-line-length=100
+max-line-length=99
# Maximum number of lines in a module.
max-module-lines=1000
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 21035e6f3a..0000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-language: python
-
-matrix:
- include:
- - python: '3.7'
- name: "Python 3.7.1 on Xenial Linux"
- - python: '3.6'
- name: "Python 3.6.7 on Xenial Linux"
- - name: "Python 3.7.4 on macOS"
- os: osx
- osx_image: xcode11.2
- language: shell
- - name: "Python 3.7.5 on Windows"
- os: windows
- language: shell
- before_install:
- - choco install python --version=3.7.5
- - python -m pip install --upgrade pip
- env: PATH=/c/Python37:/c/Python37/Scripts:$PATH
-
-install:
- - pip3 install --upgrade pip
- - pip3 install pipenv
- - pipenv install -d
-
-script:
- - pipenv run bandit ./bot.py cogs/*.py core/*.py -b .bandit_baseline.json
- - pipenv run python .lint.py
- - pipenv run flake8 ./bot.py cogs/*.py core/*.py --ignore=E501,E203,W503 --exit-zero
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f9ef96371..54e9d761f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,52 @@ This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.
however, insignificant breaking changes does not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319).
+# v3.4.0
+
+
+### Added
+
+- Thread cooldown!
+ - Set via the new config var `thread_cooldown`.
+ - Specify a time for the recipient to wait before allowed to create another thread.
+- Fallback Category (thanks to DAzVise PR#636)
+ - Automatically created upon reaching the 50 channels limit.
+ - Manually set fallback category with the config var `fallback_category_id`.
+- "enable" and "disable" support for yes or no config vars.
+- Added "perhaps you meant" section to `?config help`.
+- Multi-command alias is now more stable. With support for a single quote escape `\"`.
+- New command `?freply`, which behaves exactly like `?reply` with the addition that you can substitute `{channel}`, `{recipient}`, and `{author}` to be their respective values.
+- New command `?repair`, repair any broken Modmail thread (with help from @officialpiyush).
+- Recipient get feedback when they edit message.
+- Chained delete for DMs now comes with a message.
+- poetry (in case someone needs it).
+
+### Changed
+
+- The look of alias and snippet when previewing.
+- Message ID of the thread embed is saved in DB, instead of the original message.
+- Swapped the position of user and category for `?contact`.
+- The log file will no longer grow infinitely large.
+- Hard limit of maximum 25 steps for alias.
+- `?disable` is now `?disable new`.
+
+### Fixed
+
+- Setting config vars using human time wasn't working.
+- Fixed some bugs with aliases.
+- Fixed a lot of issues with `?edit` and `?delete` and recipient message edit.
+- Masked the error: "AttributeError: 'int' object has no attribute 'name'"
+ - Channel delete event will not be checked until discord.py fixes this issue.
+- Chained reaction add / remove.
+- Chained delete for thread channels.
+
+### Internal
+
+- Commit to black format line width max = 99, consistent with pylint.
+- Alias parser is rewritten without shlex.
+- New checks with thread create / find.
+- No more flake8 and travis.
+
# v3.3.2
### Fixed
diff --git a/Dockerfile b/Dockerfile
index eadf05e4e2..f616ef29f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,10 @@
-FROM python:3.7.4-alpine
+FROM python:3.7-alpine
WORKDIR /modmailbot
COPY . /modmailbot
-RUN pip install --no-cache-dir -r requirements.min.txt
-CMD ["python", "bot.py"]
\ No newline at end of file
+RUN export PIP_NO_CACHE_DIR=false \
+ && apk update \
+ && apk add --update --no-cache --virtual .build-deps alpine-sdk \
+ && pip install pipenv \
+ && pipenv install --deploy --ignore-pipfile \
+ && apk del .build-deps
+CMD ["pipenv", "run", "bot"]
\ No newline at end of file
diff --git a/Pipfile b/Pipfile
index 549ec62653..9173c3a5fe 100644
--- a/Pipfile
+++ b/Pipfile
@@ -7,7 +7,6 @@ verify_ssl = true
black = "==19.3b0"
pylint = "*"
bandit = "==1.6.2"
-flake8 = "==3.7.8"
[packages]
colorama = ">=0.4.0"
@@ -18,7 +17,7 @@ motor = ">=2.0.0"
natural = "==0.2.0"
isodate = ">=0.6.0"
dnspython = "~=1.16.0"
-parsedatetime = "==2.4"
+parsedatetime = "==2.5"
aiohttp = "<3.6.0,>=3.3.0"
python-dotenv = ">=0.10.3"
pipenv = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index adf4fb9de7..06d263645b 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "e5dd1237af339923e2b40a66f8a1f12682e8b1fb4de0d81f4260a7fd03076dac"
+ "sha256": "c2eb0898f236534a02cb1c198d74c82fed052b4445e39f99c1af3e58d22aa435"
},
"pipfile-spec": 6,
"requires": {
@@ -60,10 +60,10 @@
},
"certifi": {
"hashes": [
- "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
- "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
+ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
+ "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
- "version": "==2019.9.11"
+ "version": "==2019.11.28"
},
"chardet": {
"hashes": [
@@ -74,11 +74,11 @@
},
"colorama": {
"hashes": [
- "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
- "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
+ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
+ "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
],
"index": "pypi",
- "version": "==0.4.1"
+ "version": "==0.4.3"
},
"discord.py": {
"hashes": [
@@ -102,12 +102,6 @@
"index": "pypi",
"version": "==0.5.4"
},
- "future": {
- "hashes": [
- "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
- ],
- "version": "==0.18.2"
- },
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
@@ -125,45 +119,34 @@
},
"motor": {
"hashes": [
- "sha256:462fbb824f4289481c158227a2579d6adaf1ec7c70cf7ebe60ed6ceb321e5869",
- "sha256:d035c09ab422bc50bf3efb134f7405694cae76268545bd21e14fb22e2638f84e"
+ "sha256:599719bc6dcddc3b9ea4e09659fb0073d5fadcc24735999b2902f48cef33f909",
+ "sha256:756c587985d166166e644ccd36fb8b586fb987eb42fc0fc60cce9a3d76d809b4",
+ "sha256:97b4fc0a00a84df30f866d18693c503eef46c7642f75218a2c44d74d835be38a"
],
"index": "pypi",
- "version": "==2.0.0"
+ "version": "==2.1.0"
},
"multidict": {
"hashes": [
- "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
- "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3",
- "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef",
- "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b",
- "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73",
- "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc",
- "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3",
- "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd",
- "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351",
- "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941",
- "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d",
- "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1",
- "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b",
- "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a",
- "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3",
- "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7",
- "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0",
- "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0",
- "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014",
- "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5",
- "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036",
- "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d",
- "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a",
- "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce",
- "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1",
- "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a",
- "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9",
- "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7",
- "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b"
- ],
- "version": "==4.5.2"
+ "sha256:09c19f642e055550c9319d5123221b7e07fc79bda58122aa93910e52f2ab2f29",
+ "sha256:0c1a5d5f7aa7189f7b83c4411c2af8f1d38d69c4360d5de3eea129c65d8d7ce2",
+ "sha256:12f22980e7ed0972a969520fb1e55682c9fca89a68b21b49ec43132e680be812",
+ "sha256:258660e9d6b52de1a75097944e12718d3aa59adc611b703361e3577d69167aaf",
+ "sha256:3374a23e707848f27b3438500db0c69eca82929337656fce556bd70031fbda74",
+ "sha256:503b7fce0054c73aa631cc910a470052df33d599f3401f3b77e54d31182525d5",
+ "sha256:6ce55f2c45ffc90239aab625bb1b4864eef33f73ea88487ef968291fbf09fb3f",
+ "sha256:725496dde5730f4ad0a627e1a58e2620c1bde0ad1c8080aae15d583eb23344ce",
+ "sha256:a3721078beff247d0cd4fb19d915c2c25f90907cf8d6cd49d0413a24915577c6",
+ "sha256:ba566518550f81daca649eded8b5c7dd09210a854637c82351410aa15c49324a",
+ "sha256:c42362750a51a15dc905cb891658f822ee5021bfbea898c03aa1ed833e2248a5",
+ "sha256:cf14aaf2ab067ca10bca0b14d5cbd751dd249e65d371734bc0e47ddd8fafc175",
+ "sha256:cf24e15986762f0e75a622eb19cfe39a042e952b8afba3e7408835b9af2be4fb",
+ "sha256:d7b6da08538302c5245cd3103f333655ba7f274915f1f5121c4f4b5fbdb3febe",
+ "sha256:e27e13b9ff0a914a6b8fb7e4947d4ac6be8e4f61ede17edffabd088817df9e26",
+ "sha256:e53b205f8afd76fc6c942ef39e8ee7c519c775d336291d32874082a87802c67c",
+ "sha256:ec804fc5f68695d91c24d716020278fcffd50890492690a7e1fef2e741f7172c"
+ ],
+ "version": "==4.7.1"
},
"natural": {
"hashes": [
@@ -174,11 +157,11 @@
},
"parsedatetime": {
"hashes": [
- "sha256:3d817c58fb9570d1eec1dd46fa9448cd644eeed4fb612684b02dfda3a79cb84b",
- "sha256:9ee3529454bf35c40a77115f5a596771e59e1aee8c53306f346c461b8e913094"
+ "sha256:3b835fc54e472c17ef447be37458b400e3fefdf14bb1ffdedb5d2c853acf4ba1",
+ "sha256:d2e9ddb1e463de871d32088a3f3cea3dc8282b1b2800e081bd0ef86900451667"
],
"index": "pypi",
- "version": "==2.4"
+ "version": "==2.5"
},
"pipenv": {
"hashes": [
@@ -191,36 +174,61 @@
},
"pymongo": {
"hashes": [
- "sha256:09f8196e1cb081713aa3face08d1806dc0a5dd64cb9f67fefc568519253a7ff2",
- "sha256:1be549c0ce2ba8242c149156ae2064b12a5d4704448d49f630b4910606efd474",
- "sha256:1f9fe869e289210250cba4ea20fbd169905b1793e1cd2737f423e107061afa98",
- "sha256:3653cea82d1e35edd0a2355150daf8a27ebf12cf55182d5ad1046bfa288f5140",
- "sha256:4249c6ba45587b959292a727532826c5032d59171f923f7f823788f413c2a5a3",
- "sha256:4ff8f5e7c0a78983c1ee07894fff1b21c0e0ad3a122d9786cc3745fd60e4a2ce",
- "sha256:56b29c638ab924716b48a3e94e3d7ac00b04acec1daa8190c36d61fc714c3629",
- "sha256:56ec9358bbfe5ae3b25e785f8a14619d6799c855a44734c9098bb457174019bf",
- "sha256:5dca250cbf1183c3e7b7b18c882c2b2199bfb20c74c4c68dbf11596808a296da",
- "sha256:61101d1cc92881fac1f9ac7e99b033062f4c210178dc33193c8f5567feecb069",
- "sha256:86624c0205a403fb4fbfedef79c5b4ab27e21fd018fdb6a27cf03b3c32a9e2b9",
- "sha256:88ac09e1b197c3b4531e43054d49c022a3ea1281431b2f4980abafa35d2a5ce2",
- "sha256:8b0339809b12ea292d468524dd1777f1a9637d9bdc0353a9261b88f82537d606",
- "sha256:93dbf7388f6bf9af48dbb32f265b75b3dbc743a7a2ce98e44c88c049c58d85d3",
- "sha256:9b705daec636c560dd2d63935f428a6b3cddfe903fffc0f349e0e91007c893d6",
- "sha256:a090a819fe6fefadc2901d3911c07c76c0935ec5c790a50e9f3c3c47bacd5978",
- "sha256:a102b346f1921237eaa9a31ee89eda57ad3c3973d79be3a456d92524e7df8fec",
- "sha256:a13363869f2f36291d6367069c65d51d7b8d1b2fb410266b0b6b1f3c90d6deb0",
- "sha256:a409a43c76da50881b70cc9ee70a1744f882848e8e93a68fb434254379777fa3",
- "sha256:a76475834a978058425b0163f1bad35a5f70e45929a543075633c3fc1df564c5",
- "sha256:ad474e93525baa6c58d75d63a73143af24c9f93c8e26e8d382f32c4da637901a",
- "sha256:b268c7fa03ac77a8662fab3b2ab0be4beecb82f60f4c24b584e69565691a107f",
- "sha256:cca4e1ab5ba0cd7877d3938167ee8ae9c2986cc0e10d3dcc3243d664d3a83fec",
- "sha256:cef61de3f0f4441ec40266ff2ab42e5c16eaba1dc1fc6e1036f274621c52adc1",
- "sha256:e28153b5d5ca33d4ba0c3bbc0e1ff161b9016e5e5f3f8ca10d6fa49106eb9e04",
- "sha256:f30d7b37804daf0bab1143abc71666c630d7e270f5c14c5a7c300a6699c21108",
- "sha256:f70f0133301cccf9bfd68fd20f67184ef991be578b646e78441106f9e27cc44d",
- "sha256:fa75c21c1d82f20cce62f6fc4a68c2b0f33572ab406df1b17cd77a947d0b2993"
- ],
- "version": "==3.9.0"
+ "sha256:0369136c6e79c5edc16aa5de2b48a1b1c1fe5e6f7fc5915a2deaa98bd6e9dad5",
+ "sha256:08364e1bea1507c516b18b826ec790cb90433aec2f235033ec5eecfd1011633b",
+ "sha256:0af1d2bc8cc9503bf92ec3669a77ec3a6d7938193b583fb867b7e9696eed52e8",
+ "sha256:0cfd1aeeb8c0a634646ab3ebeb4ce6828b94b2e33553a69ff7e6c07c250bf201",
+ "sha256:1b4a13dff15641e58620524db15d7a323d60572b2b187261c5cb58c36d74778d",
+ "sha256:22fbdb908257f9aaaa372a7684f3e094a05ca52eb84f8f381c8b1827c49556fd",
+ "sha256:264272fd1c95fc48002ad85d5e41270831777b4180f2500943e45e12b2a3ab43",
+ "sha256:3372e98eebbfd05ebf020388003f8a4438bed41e0fef1ef696d2c13633c416c8",
+ "sha256:339d24ecdc42745d2dc09b26fda8151988e806ca81134a7bd10513c4031d91e1",
+ "sha256:38281855fc3961ba5510fbb503b8d16cc1fcb326e9f7ba0dd096ed4eb72a7084",
+ "sha256:4acdd2e16392472bfd49ca49038845c95e5254b5af862b55f7f2cc79aa258886",
+ "sha256:4e0c006bc6e98e861b678432e05bf64ba3eb889b6ab7e7bf1ebaecf9f1ba0e58",
+ "sha256:4e4284bcbe4b7be1b37f9641509085b715c478e7fbf8f820358362b5dd359379",
+ "sha256:4e5e94a5f9823f0bd0c56012a57650bc6772636c29d83d253260c26b908fcfd9",
+ "sha256:4e61f30800a40f1770b2ec56bbf5dc0f0e3f7e9250eb05fa4feb9ccb7bbe39ca",
+ "sha256:53577cf57ba9d93b58ab41d45250277828ff83c5286dde14f855e4b17ec19976",
+ "sha256:681cb31e8631882804a6cc3c8cc8f54a74ff3a82261a78e50f20c5eec05ac855",
+ "sha256:6dfc2710f43dd1d66991a0f160d196356732ccc8aa9dbc6875aeba78388fa142",
+ "sha256:72218201b13d8169be5736417987e9a0a3b10d4349e40e4db7a6a5ac670c7ef2",
+ "sha256:7247fbcdbf7ab574eb70743461b3cfc14d9cfae3f27a9afb6ce14d87f67dd0b5",
+ "sha256:72651f4b4adf50201891580506c8cca465d94d38f26ed92abfc56440662c723c",
+ "sha256:87b3aaf12ad6a9b5570b12d2a4b8802757cb3588a903aafd3c25f07f9caf07e3",
+ "sha256:87c28b7b37617c5a01eb396487f7d3b61a453e1fa0475a175ab87712d6f5d52f",
+ "sha256:88efe627b628f36ef53f09abb218d4630f83d8ebde7028689439559475c43dae",
+ "sha256:89bfbca22266f12df7fb80092b7c876734751d02b93789580b68957ad4a8bf56",
+ "sha256:908a3caf348a672b28b8a06fe7b4a27c2fdcf7f873df671e4027d48bcd7f971f",
+ "sha256:9128e7bea85f3a3041306fa14a7aa82a24b47881918500e1b8396dd1c933b5a6",
+ "sha256:9737d6d688a15b8d5c0bfa909638b79261e195be817b9f1be79c722bbb23cd76",
+ "sha256:98a8305da158f46e99e7e51db49a2f8b5fcdd7683ea7083988ccb9c4450507a6",
+ "sha256:99285cd44c756f0900cbdb5fe75f567c0a76a273b7e0467f23cb76f47e60aac0",
+ "sha256:9ed568f8026ffeb00ce31e5351e0d09d704cc19a29549ba4da0ac145d2a26fdf",
+ "sha256:a006162035032021dfd00a879643dc06863dac275f9210d843278566c719eebc",
+ "sha256:a03cb336bc8d25a11ff33b94967478a9775b0d2b23b39e952d9cc6cb93b75d69",
+ "sha256:a863ceb67be163060d1099b7e89b6dd83d6dd50077c7ceae31ac844c4c2baff9",
+ "sha256:b82628eaf0a16c1f50e1c205fd1dd406d7874037dd84643da89e91b5043b5e82",
+ "sha256:bc6446a41fb7eeaf2c808bab961b9bac81db0f5de69eab74eebe1b8b072399f7",
+ "sha256:c42d290ed54096355838421cf9d2a56e150cb533304d2439ef1adf612a986eaf",
+ "sha256:c43879fe427ea6aa6e84dae9fbdc5aa14428a4cfe613fe0fee2cc004bf3f307c",
+ "sha256:c566cbdd1863ba3ccf838656a1403c3c81fdb57cbe3fdd3515be7c9616763d33",
+ "sha256:c5b7a0d7e6ca986de32b269b6dbbd5162c1a776ece72936f55decb4d1b197ee9",
+ "sha256:ca109fe9f74da4930590bb589eb8fdf80e5d19f5cd9f337815cac9309bbd0a76",
+ "sha256:d0260ba68f9bafd8775b2988b5aeace6e69a37593ec256e23e150c808160c05c",
+ "sha256:d2ce33501149b373118fcfec88a292a87ef0b333fb30c7c6aac72fe64700bdf6",
+ "sha256:d582ea8496e2a0e124e927a67dca55c8833f0dbfbc2c84aaf0e5949a2dd30c51",
+ "sha256:d68b9ab0a900582a345fb279675b0ad4fac07d6a8c2678f12910d55083b7240d",
+ "sha256:dbf1fa571db6006907aeaf6473580aaa76041f4f3cd1ff8a0039fd0f40b83f6d",
+ "sha256:e032437a7d2b89dab880c79379d88059cee8019da0ff475d924c4ccab52db88f",
+ "sha256:e0f5798f3ad60695465a093e3d002f609c41fef3dcb97fcefae355d24d3274cf",
+ "sha256:e756355704a2cf91a7f4a649aa0bbf3bbd263018b9ed08f60198c262f4ee24b6",
+ "sha256:e824b4b87bd88cbeb25c8babeadbbaaaf06f02bbb95a93462b7c6193a064974e",
+ "sha256:ea1171470b52487152ed8bf27713cc2480dc8b0cd58e282a1bff742541efbfb8",
+ "sha256:fa19aef44d5ed8f798a8136ff981aedfa508edac3b1bed481eca5dde5f14fd3d",
+ "sha256:fceb6ae5a149a42766efb8344b0df6cfb21b55c55f360170abaddb11d43af0f1"
+ ],
+ "version": "==3.10.0"
},
"python-dateutil": {
"hashes": [
@@ -263,10 +271,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589",
- "sha256:d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136"
+ "sha256:116655188441670978117d0ebb6451eb6a7526f9ae0796cc0dee6bd7356909b0",
+ "sha256:b57776b44f91511866594e477dd10e76a6eb44439cdd7f06dcd30ba4c5bd854f"
],
- "version": "==16.7.7"
+ "version": "==16.7.8"
},
"virtualenv-clone": {
"hashes": [
@@ -303,19 +311,25 @@
},
"yarl": {
"hashes": [
- "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
- "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
- "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
- "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
- "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
- "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
- "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
- "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
- "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
- "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
- "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
- ],
- "version": "==1.3.0"
+ "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
+ "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
+ "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
+ "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
+ "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
+ "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
+ "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
+ "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
+ "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
+ "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
+ "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
+ "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
+ "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
+ "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
+ "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
+ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
+ "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
+ ],
+ "version": "==1.4.2"
}
},
"develop": {
@@ -363,21 +377,6 @@
],
"version": "==7.0"
},
- "entrypoints": {
- "hashes": [
- "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
- "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
- ],
- "version": "==0.3"
- },
- "flake8": {
- "hashes": [
- "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
- "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
- ],
- "index": "pypi",
- "version": "==3.7.8"
- },
"gitdb2": {
"hashes": [
"sha256:1b6df1433567a51a4a9c1a5a0de977aa351a405cc56d7d35f3388bad1f630350",
@@ -434,24 +433,10 @@
},
"pbr": {
"hashes": [
- "sha256:2c8e420cd4ed4cec4e7999ee47409e876af575d4c35a45840d59e8b5f3155ab8",
- "sha256:b32c8ccaac7b1a20c0ce00ce317642e6cf231cf038f9875e0280e28af5bf7ac9"
- ],
- "version": "==5.4.3"
- },
- "pycodestyle": {
- "hashes": [
- "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
- "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
- ],
- "version": "==2.5.0"
- },
- "pyflakes": {
- "hashes": [
- "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
- "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
+ "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b",
+ "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"
],
- "version": "==2.1.1"
+ "version": "==5.4.4"
},
"pylint": {
"hashes": [
@@ -463,21 +448,19 @@
},
"pyyaml": {
"hashes": [
- "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"
- ],
- "version": "==5.1.2"
+ "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
+ "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
+ "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
+ "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
+ "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
+ "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
+ "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
+ "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
+ "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
+ "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
+ "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
+ ],
+ "version": "==5.2"
},
"six": {
"hashes": [
diff --git a/README.md b/README.md
index 74e4070f2d..d162f03bc3 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
diff --git a/bot.py b/bot.py
index b7faf32673..b5ccdaccd5 100644
--- a/bot.py
+++ b/bot.py
@@ -1,4 +1,5 @@
-__version__ = "3.3.2"
+__version__ = "3.4.0"
+
import asyncio
import logging
@@ -7,7 +8,6 @@
import sys
import typing
from datetime import datetime
-from itertools import zip_longest
from types import SimpleNamespace
import discord
@@ -19,9 +19,10 @@
from aiohttp import ClientSession
from emoji import UNICODE_EMOJI
from motor.motor_asyncio import AsyncIOMotorClient
-from pkg_resources import parse_version
from pymongo.errors import ConfigurationError
+from pkg_resources import parse_version
+
try:
# noinspection PyUnresolvedReferences
from colorama import init
@@ -33,7 +34,7 @@
from core import checks
from core.clients import ApiClient, PluginDatabaseClient
from core.config import ConfigManager
-from core.utils import human_join, parse_alias
+from core.utils import human_join, normalize_alias
from core.models import PermissionLevel, SafeFormatter, getLogger, configure_logging
from core.thread import ThreadManager
from core.time import human_timedelta
@@ -77,7 +78,7 @@ def __init__(self):
"Your MONGO_URI might be copied wrong, try re-copying from the source again. "
"Otherwise noted in the following message:"
)
- logger.critical(str(e))
+ logger.critical(e)
sys.exit(0)
self.plugin_db = PluginDatabaseClient(self)
@@ -149,7 +150,7 @@ def session(self) -> ClientSession:
return self._session
@property
- def api(self):
+ def api(self) -> ApiClient:
if self._api is None:
self._api = ApiClient(self)
return self._api
@@ -171,9 +172,7 @@ def run(self, *args, **kwargs):
for task in asyncio.all_tasks(self.loop):
task.cancel()
try:
- self.loop.run_until_complete(
- asyncio.gather(*asyncio.all_tasks(self.loop))
- )
+ self.loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(self.loop)))
except asyncio.CancelledError:
logger.debug("All pending tasks has been cancelled.")
finally:
@@ -187,9 +186,7 @@ def owner_ids(self):
owner_ids = set(map(int, str(owner_ids).split(",")))
if self.owner_id is not None:
owner_ids.add(self.owner_id)
- permissions = self.config["level_permissions"].get(
- PermissionLevel.OWNER.name, []
- )
+ permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, [])
for perm in permissions:
owner_ids.add(int(perm))
return owner_ids
@@ -216,8 +213,7 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]:
channel = self.main_category.channels[0]
self.config["log_channel_id"] = channel.id
logger.warning(
- "No log channel set, setting #%s to be the log channel.",
- channel.name,
+ "No log channel set, setting #%s to be the log channel.", channel.name
)
return channel
except IndexError:
@@ -302,9 +298,7 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]:
category_id = self.config["main_category_id"]
if category_id is not None:
try:
- cat = discord.utils.get(
- self.modmail_guild.categories, id=int(category_id)
- )
+ cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id))
if cat is not None:
return cat
except ValueError:
@@ -354,9 +348,7 @@ def command_perm(self, command_name: str) -> PermissionLevel:
try:
return PermissionLevel[level.upper()]
except KeyError:
- logger.warning(
- "Invalid override_command_level for command %s.", command_name
- )
+ logger.warning("Invalid override_command_level for command %s.", command_name)
self.config["override_command_level"].pop(command_name)
command = self.get_command(command_name)
@@ -405,11 +397,7 @@ async def setup_indexes(self):
logger.info('Creating "text" index for logs collection.')
logger.info("Name: %s", index_name)
await coll.create_index(
- [
- ("messages.content", "text"),
- ("messages.author.name", "text"),
- ("key", "text"),
- ]
+ [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")]
)
logger.debug("Successfully configured and verified database indexes.")
@@ -428,8 +416,7 @@ async def on_ready(self):
logger.info("Logged in as: %s", self.user)
logger.info("Bot ID: %s", self.user.id)
owners = ", ".join(
- getattr(self.get_user(owner_id), "name", str(owner_id))
- for owner_id in self.owner_ids
+ getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.owner_ids
)
logger.info("Owners: %s", owners)
logger.info("Prefix: %s", self.prefix)
@@ -447,11 +434,14 @@ async def on_ready(self):
logger.line()
for recipient_id, items in tuple(closures.items()):
- after = (
- datetime.fromisoformat(items["time"]) - datetime.utcnow()
- ).total_seconds()
- if after < 0:
+ after = (datetime.fromisoformat(items["time"]) - datetime.utcnow()).total_seconds()
+ if after <= 0:
+ logger.debug("Closing thread for recipient %s.", recipient_id)
after = 0
+ else:
+ logger.debug(
+ "Thread for recipient %s will be closed after %s seconds.", recipient_id, after
+ )
thread = await self.threads.find(recipient_id=int(recipient_id))
@@ -462,8 +452,6 @@ async def on_ready(self):
await self.config.update()
continue
- logger.debug("Closing thread for recipient %s.", recipient_id)
-
await thread.close(
closer=self.get_user(items["closer_id"]),
after=after,
@@ -475,9 +463,7 @@ async def on_ready(self):
for log in await self.api.get_open_logs():
if self.get_channel(int(log["channel_id"])) is None:
- logger.debug(
- "Unable to resolve thread with channel %s.", log["channel_id"]
- )
+ logger.debug("Unable to resolve thread with channel %s.", log["channel_id"])
log_data = await self.api.post_log(
log["channel_id"],
{
@@ -494,13 +480,10 @@ async def on_ready(self):
},
)
if log_data:
- logger.debug(
- "Successfully closed thread with channel %s.", log["channel_id"]
- )
+ logger.debug("Successfully closed thread with channel %s.", log["channel_id"])
else:
logger.debug(
- "Failed to close thread with channel %s, skipping.",
- log["channel_id"],
+ "Failed to close thread with channel %s, skipping.", log["channel_id"]
)
self.metadata_loop = tasks.Loop(
@@ -523,7 +506,7 @@ async def convert_emoji(self, name: str) -> str:
try:
name = await converter.convert(ctx, name.strip(":"))
except commands.BadArgument as e:
- logger.warning("%s is not a valid emoji. %s.", str(e))
+ logger.warning("%s is not a valid emoji. %s.", e)
raise
return name
@@ -550,150 +533,199 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]:
return sent_emoji, blocked_emoji
- async def _process_blocked(
- self, message: discord.Message
- ) -> typing.Tuple[bool, str]:
- sent_emoji, blocked_emoji = await self.retrieve_emoji()
-
- if str(message.author.id) in self.blocked_whitelisted_users:
- if str(message.author.id) in self.blocked_users:
- self.blocked_users.pop(str(message.author.id))
- await self.config.update()
-
- return False, sent_emoji
-
- now = datetime.utcnow()
-
+ def check_account_age(self, author: discord.Member) -> bool:
account_age = self.config.get("account_age")
- guild_age = self.config.get("guild_age")
-
- if account_age is None:
- account_age = isodate.Duration()
- if guild_age is None:
- guild_age = isodate.Duration()
-
- reason = self.blocked_users.get(str(message.author.id)) or ""
- min_guild_age = min_account_age = now
+ now = datetime.utcnow()
try:
- min_account_age = message.author.created_at + account_age
+ min_account_age = author.created_at + account_age
except ValueError:
logger.warning("Error with 'account_age'.", exc_info=True)
- self.config.remove("account_age")
-
- try:
- joined_at = getattr(message.author, "joined_at", None)
- if joined_at is not None:
- min_guild_age = joined_at + guild_age
- except ValueError:
- logger.warning("Error with 'guild_age'.", exc_info=True)
- self.config.remove("guild_age")
+ min_account_age = author.created_at + self.config.remove("account_age")
if min_account_age > now:
# User account has not reached the required time
- reaction = blocked_emoji
- changed = False
delta = human_timedelta(min_account_age)
- logger.debug("Blocked due to account age, user %s.", message.author.name)
+ logger.debug("Blocked due to account age, user %s.", author.name)
- if str(message.author.id) not in self.blocked_users:
- new_reason = (
- f"System Message: New Account. Required to wait for {delta}."
- )
- self.blocked_users[str(message.author.id)] = new_reason
- changed = True
+ if str(author.id) not in self.blocked_users:
+ new_reason = f"System Message: New Account. Required to wait for {delta}."
+ self.blocked_users[str(author.id)] = new_reason
- if reason.startswith("System Message: New Account.") or changed:
- await message.channel.send(
- embed=discord.Embed(
- title="Message not sent!",
- description=f"Your must wait for {delta} "
- f"before you can contact me.",
- color=self.error_color,
- )
- )
+ return False
+ return True
- elif min_guild_age > now:
+ def check_guild_age(self, author: discord.Member) -> bool:
+ guild_age = self.config.get("guild_age")
+ now = datetime.utcnow()
+
+ if not hasattr(author, "joined_at"):
+ logger.warning("Not in guild, cannot verify guild_age, %s.", author.name)
+ return True
+
+ try:
+ min_guild_age = author.joined_at + guild_age
+ except ValueError:
+ logger.warning("Error with 'guild_age'.", exc_info=True)
+ min_guild_age = author.joined_at + self.config.remove("guild_age")
+
+ if min_guild_age > now:
# User has not stayed in the guild for long enough
- reaction = blocked_emoji
- changed = False
delta = human_timedelta(min_guild_age)
- logger.debug("Blocked due to guild age, user %s.", message.author.name)
+ logger.debug("Blocked due to guild age, user %s.", author.name)
- if str(message.author.id) not in self.blocked_users:
- new_reason = (
- f"System Message: Recently Joined. Required to wait for {delta}."
- )
- self.blocked_users[str(message.author.id)] = new_reason
- changed = True
+ if str(author.id) not in self.blocked_users:
+ new_reason = f"System Message: Recently Joined. Required to wait for {delta}."
+ self.blocked_users[str(author.id)] = new_reason
- if reason.startswith("System Message: Recently Joined.") or changed:
- await message.channel.send(
- embed=discord.Embed(
- title="Message not sent!",
- description=f"Your must wait for {delta} "
- f"before you can contact me.",
- color=self.error_color,
- )
- )
+ return False
+ return True
- elif str(message.author.id) in self.blocked_users:
- if reason.startswith("System Message: New Account.") or reason.startswith(
- "System Message: Recently Joined."
- ):
- # Met the age limit already, otherwise it would've been caught by the previous if's
- reaction = sent_emoji
- logger.debug(
- "No longer internally blocked, user %s.", message.author.name
+ def check_manual_blocked(self, author: discord.Member) -> bool:
+ if str(author.id) not in self.blocked_users:
+ return True
+
+ blocked_reason = self.blocked_users.get(str(author.id)) or ""
+ now = datetime.utcnow()
+
+ if blocked_reason.startswith("System Message:"):
+ # Met the limits already, otherwise it would've been caught by the previous checks
+ logger.debug("No longer internally blocked, user %s.", author.name)
+ self.blocked_users.pop(str(author.id))
+ return True
+ # etc "blah blah blah... until 2019-10-14T21:12:45.559948."
+ end_time = re.search(r"until ([^`]+?)\.$", blocked_reason)
+ if end_time is None:
+ # backwards compat
+ end_time = re.search(r"%([^%]+?)%", blocked_reason)
+ if end_time is not None:
+ logger.warning(
+ r"Deprecated time message for user %s, block and unblock again to update.",
+ author.name,
)
- self.blocked_users.pop(str(message.author.id))
- else:
- reaction = blocked_emoji
- # etc "blah blah blah... until 2019-10-14T21:12:45.559948."
- end_time = re.search(r"until ([^`]+?)\.$", reason)
- if end_time is None:
- # backwards compat
- end_time = re.search(r"%([^%]+?)%", reason)
- if end_time is not None:
- logger.warning(
- r"Deprecated time message for user %s, block and unblock again to update.",
- message.author,
- )
- if end_time is not None:
- after = (
- datetime.fromisoformat(end_time.group(1)) - now
- ).total_seconds()
- if after <= 0:
- # No longer blocked
- reaction = sent_emoji
- self.blocked_users.pop(str(message.author.id))
- logger.debug("No longer blocked, user %s.", message.author.name)
- else:
- logger.debug("User blocked, user %s.", message.author.name)
- else:
- logger.debug("User blocked, user %s.", message.author.name)
+ if end_time is not None:
+ after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds()
+ if after <= 0:
+ # No longer blocked
+ self.blocked_users.pop(str(author.id))
+ logger.debug("No longer blocked, user %s.", author.name)
+ return True
+ logger.debug("User blocked, user %s.", author.name)
+ return False
+
+ async def _process_blocked(self, message):
+ _, blocked_emoji = await self.retrieve_emoji()
+ if await self.is_blocked(message.author, channel=message.channel, send_message=True):
+ await self.add_reaction(message, blocked_emoji)
+ return True
+ return False
+
+ async def is_blocked(
+ self,
+ author: discord.User,
+ *,
+ channel: discord.TextChannel = None,
+ send_message: bool = False,
+ ) -> typing.Tuple[bool, str]:
+
+ member = self.guild.get_member(author.id)
+ if member is None:
+ logger.debug("User not in guild, %s.", author.id)
else:
- reaction = sent_emoji
+ author = member
+
+ if str(author.id) in self.blocked_whitelisted_users:
+ if str(author.id) in self.blocked_users:
+ self.blocked_users.pop(str(author.id))
+ await self.config.update()
+ return False
+
+ blocked_reason = self.blocked_users.get(str(author.id)) or ""
+
+ if not self.check_account_age(author) or not self.check_guild_age(author):
+ new_reason = self.blocked_users.get(str(author.id))
+ if new_reason != blocked_reason:
+ if send_message:
+ await channel.send(
+ embed=discord.Embed(
+ title="Message not sent!",
+ description=new_reason,
+ color=self.error_color,
+ )
+ )
+ return True
+
+ if not self.check_manual_blocked(author):
+ return True
await self.config.update()
- return str(message.author.id) in self.blocked_users, reaction
+ return False
+
+ async def get_thread_cooldown(self, author: discord.Member):
+ thread_cooldown = self.config.get("thread_cooldown")
+ now = datetime.utcnow()
+
+ if thread_cooldown == isodate.Duration():
+ return
+
+ last_log = await self.api.get_latest_user_logs(author.id)
+
+ if last_log is None:
+ logger.debug("Last thread wasn't found, %s.", author.name)
+ return
+
+ last_log_closed_at = last_log.get("closed_at")
+
+ if not last_log_closed_at:
+ logger.debug("Last thread was not closed, %s.", author.name)
+ return
+
+ try:
+ cooldown = datetime.fromisoformat(last_log_closed_at) + thread_cooldown
+ except ValueError:
+ logger.warning("Error with 'thread_cooldown'.", exc_info=True)
+ cooldown = datetime.fromisoformat(last_log_closed_at) + self.config.remove(
+ "thread_cooldown"
+ )
+
+ if cooldown > now:
+ # User messaged before thread cooldown ended
+ delta = human_timedelta(cooldown)
+ logger.debug("Blocked due to thread cooldown, user %s.", author.name)
+ return delta
+ return
@staticmethod
- async def add_reaction(msg, reaction):
+ async def add_reaction(msg, reaction: discord.Reaction) -> bool:
if reaction != "disable":
try:
await msg.add_reaction(reaction)
- except (discord.HTTPException, discord.InvalidArgument):
- logger.warning("Failed to add reaction %s.", reaction, exc_info=True)
+ except (discord.HTTPException, discord.InvalidArgument) as e:
+ logger.warning("Failed to add reaction %s: %s.", reaction, e)
+ return False
+ return True
async def process_dm_modmail(self, message: discord.Message) -> None:
"""Processes messages sent to the bot."""
- blocked, reaction = await self._process_blocked(message)
+ blocked = await self._process_blocked(message)
if blocked:
- return await self.add_reaction(message, reaction)
+ return
+ sent_emoji, blocked_emoji = await self.retrieve_emoji()
+
thread = await self.threads.find(recipient=message.author)
if thread is None:
+ delta = await self.get_thread_cooldown(message.author)
+ if delta:
+ await message.channel.send(
+ embed=discord.Embed(
+ title="Message not sent!",
+ description=f"You must wait for {delta} before you can contact me again.",
+ color=self.error_color,
+ )
+ )
+ return
+
if self.config["dm_disabled"] >= 1:
embed = discord.Embed(
title=self.config["disabled_new_thread_title"],
@@ -701,17 +733,15 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
description=self.config["disabled_new_thread_response"],
)
embed.set_footer(
- text=self.config["disabled_new_thread_footer"],
- icon_url=self.guild.icon_url,
+ text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon_url
)
logger.info(
- "A new thread was blocked from %s due to disabled Modmail.",
- message.author,
+ "A new thread was blocked from %s due to disabled Modmail.", message.author
)
- _, blocked_emoji = await self.retrieve_emoji()
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
- thread = self.threads.create(message.author)
+
+ thread = await self.threads.create(message.author)
else:
if self.config["dm_disabled"] == 2:
embed = discord.Embed(
@@ -724,15 +754,18 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
icon_url=self.guild.icon_url,
)
logger.info(
- "A message was blocked from %s due to disabled Modmail.",
- message.author,
+ "A message was blocked from %s due to disabled Modmail.", message.author
)
- _, blocked_emoji = await self.retrieve_emoji()
await self.add_reaction(message, blocked_emoji)
return await message.channel.send(embed=embed)
- await self.add_reaction(message, reaction)
- await thread.send(message)
+ try:
+ await thread.send(message)
+ except Exception:
+ logger.error("Failed to send message:", exc_info=True)
+ await self.add_reaction(message, blocked_emoji)
+ else:
+ await self.add_reaction(message, sent_emoji)
async def get_contexts(self, message, *, cls=commands.Context):
"""
@@ -742,7 +775,7 @@ async def get_contexts(self, message, *, cls=commands.Context):
view = StringView(message.content)
ctx = cls(prefix=self.prefix, view=view, bot=self, message=message)
- ctx.thread = await self.threads.find(channel=ctx.channel)
+ thread = await self.threads.find(channel=ctx.channel)
if self._skip_check(message.author.id, self.user.id):
return [ctx]
@@ -758,33 +791,23 @@ async def get_contexts(self, message, *, cls=commands.Context):
# Check if there is any aliases being called.
alias = self.aliases.get(invoker)
if alias is not None:
- aliases = parse_alias(alias)
+ ctxs = []
+ aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :])
if not aliases:
logger.warning("Alias %s is invalid, removing.", invoker)
self.aliases.pop(invoker)
- else:
- len_ = len(f"{invoked_prefix}{invoker}")
- contents = parse_alias(message.content[len_:])
- if not contents:
- contents = [message.content[len_:]]
-
- ctxs = []
- for alias, content in zip_longest(aliases, contents):
- if alias is None:
- break
- ctx = cls(prefix=self.prefix, view=view, bot=self, message=message)
- ctx.thread = await self.threads.find(channel=ctx.channel)
-
- if content is not None:
- view = StringView(f"{alias} {content.strip()}")
- else:
- view = StringView(alias)
- ctx.view = view
- ctx.invoked_with = view.get_word()
- ctx.command = self.all_commands.get(ctx.invoked_with)
- ctxs += [ctx]
- return ctxs
+ for alias in aliases:
+ view = StringView(invoked_prefix + alias)
+ ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message)
+ ctx_.thread = thread
+ discord.utils.find(view.skip_string, prefixes)
+ ctx_.invoked_with = view.get_word().lower()
+ ctx_.command = self.all_commands.get(ctx_.invoked_with)
+ ctxs += [ctx_]
+ return ctxs
+
+ ctx.thread = thread
ctx.invoked_with = invoker
ctx.command = self.all_commands.get(invoker)
return [ctx]
@@ -856,19 +879,14 @@ async def process_commands(self, message):
# Process snippets
if cmd in self.snippets:
- thread = await self.threads.find(channel=message.channel)
snippet = self.snippets[cmd]
- if thread:
- snippet = self.formatter.format(snippet, recipient=thread.recipient)
- message.content = f"{self.prefix}reply {snippet}"
+ message.content = f"{self.prefix}freply {snippet}"
ctxs = await self.get_contexts(message)
for ctx in ctxs:
if ctx.command:
if not any(
- 1
- for check in ctx.command.checks
- if hasattr(check, "permission_level")
+ 1 for check in ctx.command.checks if hasattr(check, "permission_level")
):
logger.debug(
"Command %s has no permissions check, adding invalid level.",
@@ -899,9 +917,6 @@ async def on_typing(self, channel, user, _):
if user.bot:
return
- async def _void(*_args, **_kwargs):
- pass
-
if isinstance(channel, discord.DMChannel):
if not self.config.get("user_typing"):
return
@@ -916,17 +931,11 @@ async def _void(*_args, **_kwargs):
thread = await self.threads.find(channel=channel)
if thread is not None and thread.recipient:
- if (
- await self._process_blocked(
- SimpleNamespace(
- author=thread.recipient, channel=SimpleNamespace(send=_void)
- )
- )
- )[0]:
+ if await self.is_blocked(thread.recipient):
return
await thread.recipient.trigger_typing()
- async def on_raw_reaction_add(self, payload):
+ async def handle_reaction_events(self, payload, *, add):
user = self.get_user(payload.user_id)
if user.bot:
return
@@ -948,40 +957,76 @@ async def on_raw_reaction_add(self, payload):
close_emoji = await self.convert_emoji(self.config["close_emoji"])
if isinstance(channel, discord.DMChannel):
- if str(reaction) == str(close_emoji): # closing thread
- if not self.config.get("recipient_thread_close"):
- return
- thread = await self.threads.find(recipient=user)
- ts = message.embeds[0].timestamp if message.embeds else None
+ thread = await self.threads.find(recipient=user)
+ if not thread:
+ return
+ if (
+ add
+ and message.embeds
+ and str(reaction) == str(close_emoji)
+ and self.config.get("recipient_thread_close")
+ ):
+ ts = message.embeds[0].timestamp
if thread and ts == thread.channel.created_at:
# the reacted message is the corresponding thread creation embed
- await thread.close(closer=user)
+ # closing thread
+ return await thread.close(closer=user)
+ if not thread.recipient.dm_channel:
+ await thread.recipient.create_dm()
+ try:
+ linked_message = await thread.find_linked_message_from_dm(
+ message, either_direction=True
+ )
+ except ValueError as e:
+ logger.warning("Failed to find linked message for reactions: %s", e)
+ return
else:
- if not message.embeds:
+ thread = await self.threads.find(channel=channel)
+ if not thread:
+ return
+ try:
+ _, linked_message = await thread.find_linked_messages(
+ message.id, either_direction=True
+ )
+ except ValueError as e:
+ logger.warning("Failed to find linked message for reactions: %s", e)
return
- message_id = str(message.embeds[0].author.url).split("/")[-1]
- if message_id.isdigit():
- thread = await self.threads.find(channel=message.channel)
- channel = thread.recipient.dm_channel
- if not channel:
- channel = await thread.recipient.create_dm()
- async for msg in channel.history():
- if msg.id == int(message_id):
- await msg.add_reaction(reaction)
+
+ if add:
+ if await self.add_reaction(linked_message, reaction):
+ await self.add_reaction(message, reaction)
+ else:
+ try:
+ await linked_message.remove_reaction(reaction, self.user)
+ await message.remove_reaction(reaction, self.user)
+ except (discord.HTTPException, discord.InvalidArgument) as e:
+ logger.warning("Failed to remove reaction: %s", e)
+
+ async def on_raw_reaction_add(self, payload):
+ await self.handle_reaction_events(payload, add=True)
+
+ async def on_raw_reaction_remove(self, payload):
+ await self.handle_reaction_events(payload, add=False)
async def on_guild_channel_delete(self, channel):
if channel.guild != self.modmail_guild:
return
- audit_logs = self.modmail_guild.audit_logs()
- entry = await audit_logs.find(lambda e: e.target.id == channel.id)
- mod = entry.user
+ try:
+ audit_logs = self.modmail_guild.audit_logs()
+ entry = await audit_logs.find(lambda a: a.target == channel)
+ mod = entry.user
+ except AttributeError as e:
+ # discord.py broken implementation with discord API
+ # TODO: waiting for dpy
+ logger.warning("Failed to retrieve audit log: %s.", e)
+ return
if mod == self.user:
return
if isinstance(channel, discord.CategoryChannel):
- if self.main_category.id == channel.id:
+ if self.main_category == channel:
logger.debug("Main category was deleted.")
self.config.remove("main_category_id")
await self.config.update()
@@ -990,14 +1035,14 @@ async def on_guild_channel_delete(self, channel):
if not isinstance(channel, discord.TextChannel):
return
- if self.log_channel is None or self.log_channel.id == channel.id:
+ if self.log_channel is None or self.log_channel == channel:
logger.info("Log channel deleted.")
self.config.remove("log_channel_id")
await self.config.update()
return
thread = await self.threads.find(channel=channel)
- if thread:
+ if thread and thread.channel == channel:
logger.debug("Manually closed channel %s.", channel.name)
await thread.close(closer=mod, silent=True, delete_channel=False)
@@ -1023,36 +1068,43 @@ async def on_member_join(self, member):
async def on_message_delete(self, message):
"""Support for deleting linked messages"""
+ # TODO: use audit log to check if modmail deleted the message
if message.embeds and not isinstance(message.channel, discord.DMChannel):
- message_id = str(message.embeds[0].author.url).split("/")[-1]
- if message_id.isdigit():
- thread = await self.threads.find(channel=message.channel)
-
- channel = thread.recipient.dm_channel
-
- async for msg in channel.history():
- if msg.embeds and msg.embeds[0].author:
- url = str(msg.embeds[0].author.url)
- if message_id == url.split("/")[-1]:
- return await msg.delete()
+ thread = await self.threads.find(channel=message.channel)
+ try:
+ await thread.delete_message(message)
+ except ValueError as e:
+ if str(e) not in {"DM message not found.", " Malformed thread message."}:
+ logger.warning("Failed to find linked message to delete: %s", e)
+ else:
+ thread = await self.threads.find(recipient=message.author)
+ message = await thread.find_linked_message_from_dm(message)
+ embed = message.embeds[0]
+ embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=embed.footer.icon_url)
+ await message.edit(embed=embed)
async def on_bulk_message_delete(self, messages):
await discord.utils.async_all(self.on_message_delete(msg) for msg in messages)
async def on_message_edit(self, before, after):
- if before.author.bot:
+ if after.author.bot:
return
- if isinstance(before.channel, discord.DMChannel):
+ if before.content == after.content:
+ return
+
+ if isinstance(after.channel, discord.DMChannel):
thread = await self.threads.find(recipient=before.author)
- async for msg in thread.channel.history():
- if msg.embeds:
- embed = msg.embeds[0]
- matches = str(embed.author.url).split("/")
- if matches and matches[-1] == str(before.id):
- embed.description = after.content
- await msg.edit(embed=embed)
- await self.api.edit_message(str(after.id), after.content)
- break
+ try:
+ await thread.edit_dm_message(after, after.content)
+ except ValueError:
+ _, blocked_emoji = await self.retrieve_emoji()
+ await self.add_reaction(after, blocked_emoji)
+ else:
+ embed = discord.Embed(
+ description="Successfully Edited Message", color=self.main_color
+ )
+ embed.set_footer(text=f"Message ID: {after.id}")
+ await after.channel.send(embed=embed)
async def on_error(self, event_method, *args, **kwargs):
logger.error("Ignoring exception in %s.", event_method)
@@ -1064,9 +1116,7 @@ async def on_command_error(self, context, exception):
[c.__name__ for c in exception.converters]
)
await context.trigger_typing()
- await context.send(
- embed=discord.Embed(color=self.error_color, description=msg)
- )
+ await context.send(embed=discord.Embed(color=self.error_color, description=msg))
elif isinstance(exception, commands.BadArgument):
await context.trigger_typing()
@@ -1082,9 +1132,7 @@ async def on_command_error(self, context, exception):
if not await check(context):
if hasattr(check, "fail_msg"):
await context.send(
- embed=discord.Embed(
- color=self.error_color, description=check.fail_msg
- )
+ embed=discord.Embed(color=self.error_color, description=check.fail_msg)
)
if hasattr(check, "permission_level"):
corrected_permission_level = self.command_perm(
@@ -1160,7 +1208,7 @@ async def before_post_metadata(self):
self.metadata_loop.cancel()
-if __name__ == "__main__":
+def main():
try:
# noinspection PyUnresolvedReferences
import uvloop
@@ -1172,3 +1220,7 @@ async def before_post_metadata(self):
bot = ModmailBot()
bot.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cogs/modmail.py b/cogs/modmail.py
index 8ce3f838fe..6531e61e70 100644
--- a/cogs/modmail.py
+++ b/cogs/modmail.py
@@ -1,4 +1,5 @@
import asyncio
+import re
from datetime import datetime
from itertools import zip_longest
from typing import Optional, Union
@@ -6,7 +7,7 @@
import discord
from discord.ext import commands
-from discord.utils import escape_markdown, escape_mentions
+from discord.utils import escape_markdown
from dateutil import parser
from natural.date import duration
@@ -14,14 +15,9 @@
from core import checks
from core.models import PermissionLevel, getLogger
from core.paginator import EmbedPaginatorSession
+from core.thread import Thread
from core.time import UserFriendlyTime, human_timedelta
-from core.utils import (
- format_preview,
- User,
- create_not_found_embed,
- format_description,
- trigger_typing,
-)
+from core.utils import *
logger = getLogger(__name__)
@@ -61,9 +57,7 @@ async def setup(self, ctx):
return await ctx.send(embed=embed)
overwrites = {
- self.bot.modmail_guild.default_role: discord.PermissionOverwrite(
- read_messages=False
- ),
+ self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False),
self.bot.modmail_guild.me: discord.PermissionOverwrite(read_messages=True),
}
@@ -102,15 +96,13 @@ async def setup(self, ctx):
)
embed.add_field(
- name="Thanks for using the bot!",
+ name="Thanks for using our bot!",
value="If you like what you see, consider giving the "
- "[repo a star](https://github.com/kyb3r/modmail) :star: or if you are "
- "feeling generous, check us out on [Patreon](https://patreon.com/kyber)!",
+ "[repo a star](https://github.com/kyb3r/modmail) :star: and if you are "
+ "feeling extra generous, buy us coffee on [Patreon](https://patreon.com/kyber) :heart:!",
)
- embed.set_footer(
- text=f'Type "{self.bot.prefix}help" for a complete list of commands.'
- )
+ embed.set_footer(text=f'Type "{self.bot.prefix}help" for a complete list of commands.')
await log_channel.send(embed=embed)
self.bot.config["main_category_id"] = category.id
@@ -119,17 +111,14 @@ async def setup(self, ctx):
await self.bot.config.update()
await ctx.send(
"**Successfully set up server.**\n"
- "Consider setting permission levels "
- "to give access to roles or users the ability to use Modmail.\n\n"
+ "Consider setting permission levels to give access to roles "
+ "or users the ability to use Modmail.\n\n"
f"Type:\n- `{self.bot.prefix}permissions` and `{self.bot.prefix}permissions add` "
"for more info on setting permissions.\n"
f"- `{self.bot.prefix}config help` for a list of available customizations."
)
- if (
- not self.bot.config["command_permissions"]
- and not self.bot.config["level_permissions"]
- ):
+ if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]:
await self.bot.update_perms(PermissionLevel.REGULAR, -1)
for owner_ids in self.bot.owner_ids:
await self.bot.update_perms(PermissionLevel.OWNER, owner_ids)
@@ -161,28 +150,24 @@ async def snippet(self, ctx, *, name: str.lower = None):
if name is not None:
val = self.bot.snippets.get(name)
if val is None:
- embed = create_not_found_embed(
- name, self.bot.snippets.keys(), "Snippet"
+ embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet")
+ else:
+ embed = discord.Embed(
+ title=f'Snippet - "{name}":', description=val, color=self.bot.main_color
)
- return await ctx.send(embed=embed)
- return await ctx.send(escape_mentions(val))
+ return await ctx.send(embed=embed)
if not self.bot.snippets:
embed = discord.Embed(
- color=self.bot.error_color,
- description="You dont have any snippets at the moment.",
- )
- embed.set_footer(
- text=f"Do {self.bot.prefix}help snippet for more commands."
+ color=self.bot.error_color, description="You dont have any snippets at the moment."
)
+ embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.')
embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url)
return await ctx.send(embed=embed)
embeds = []
- for i, names in enumerate(
- zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)
- ):
+ for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)):
description = format_description(i, names)
embed = discord.Embed(color=self.bot.main_color, description=description)
embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url)
@@ -200,8 +185,15 @@ async def snippet_raw(self, ctx, *, name: str.lower):
val = self.bot.snippets.get(name)
if val is None:
embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet")
- return await ctx.send(embed=embed)
- return await ctx.send(escape_markdown(escape_mentions(val)).replace("<", "\\<"))
+ else:
+ val = truncate(escape_code_block(val), 2048 - 7)
+ embed = discord.Embed(
+ title=f'Raw snippet - "{name}":',
+ description=f"```\n{val}```",
+ color=self.bot.main_color,
+ )
+
+ return await ctx.send(embed=embed)
@snippet.command(name="add")
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -209,6 +201,11 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte
"""
Add a snippet.
+ Simply to add a snippet, do: ```
+ {prefix}snippet add hey hello there :)
+ ```
+ then when you type `{prefix}hey`, "hello there :)" will get sent to the recipient.
+
To add a multi-word snippet name, use quotes: ```
{prefix}snippet add "two word" this is a two word snippet.
```
@@ -225,7 +222,7 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte
embed = discord.Embed(
title="Error",
color=self.bot.error_color,
- description=f"An alias with the same name already exists: `{name}`.",
+ description=f"An alias that shares the same name exists: `{name}`.",
)
return await ctx.send(embed=embed)
@@ -233,7 +230,7 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte
embed = discord.Embed(
title="Error",
color=self.bot.error_color,
- description=f"Snippet names cannot be longer than 120 characters.",
+ description="Snippet names cannot be longer than 120 characters.",
)
return await ctx.send(embed=embed)
@@ -243,7 +240,7 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte
embed = discord.Embed(
title="Added snippet",
color=self.bot.main_color,
- description=f"Successfully created snippet.",
+ description="Successfully created snippet.",
)
return await ctx.send(embed=embed)
@@ -290,9 +287,7 @@ async def snippet_edit(self, ctx, name: str.lower, *, value):
@commands.command()
@checks.has_permissions(PermissionLevel.MODERATOR)
@checks.thread_only()
- async def move(
- self, ctx, category: discord.CategoryChannel, *, specifics: str = None
- ):
+ async def move(self, ctx, category: discord.CategoryChannel, *, specifics: str = None):
"""
Move a thread to another category.
@@ -317,10 +312,7 @@ async def move(
await thread.recipient.send(embed=embed)
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
async def send_scheduled_close_message(self, ctx, after, silent=False):
human_delta = human_timedelta(after.dt)
@@ -336,9 +328,7 @@ async def send_scheduled_close_message(self, ctx, after, silent=False):
if after.arg and not silent:
embed.add_field(name="Message", value=after.arg)
- embed.set_footer(
- text="Closing will be cancelled " "if a thread message is sent."
- )
+ embed.set_footer(text="Closing will be cancelled if a thread message is sent.")
embed.timestamp = after.dt
await ctx.send(embed=embed)
@@ -380,8 +370,7 @@ async def close(self, ctx, *, after: UserFriendlyTime = None):
if thread.close_task is not None or thread.auto_close_task is not None:
await thread.cancel_closure(all=True)
embed = discord.Embed(
- color=self.bot.error_color,
- description="Scheduled close has been cancelled.",
+ color=self.bot.error_color, description="Scheduled close has been cancelled."
)
else:
embed = discord.Embed(
@@ -394,9 +383,7 @@ async def close(self, ctx, *, after: UserFriendlyTime = None):
if after and after.dt > now:
await self.send_scheduled_close_message(ctx, after, silent)
- await thread.close(
- closer=ctx.author, after=close_after, message=message, silent=silent
- )
+ await thread.close(closer=ctx.author, after=close_after, message=message, silent=silent)
@staticmethod
def parse_user_or_role(ctx, user_or_role):
@@ -418,7 +405,7 @@ async def notify(
"""
Notify a user or role when the next thread message received.
- Once a thread message is received, `user_or_role` will only be pinged once.
+ Once a thread message is received, `user_or_role` will be pinged once.
Leave `user_or_role` empty to notify yourself.
`@here` and `@everyone` can be substituted with `here` and `everyone`.
@@ -426,7 +413,7 @@ async def notify(
"""
mention = self.parse_user_or_role(ctx, user_or_role)
if mention is None:
- raise commands.BadArgument(f"{user_or_role} is not a valid role.")
+ raise commands.BadArgument(f"{user_or_role} is not a valid user or role.")
thread = ctx.thread
@@ -445,8 +432,7 @@ async def notify(
await self.bot.config.update()
embed = discord.Embed(
color=self.bot.main_color,
- description=f"{mention} will be mentioned "
- "on the next message received.",
+ description=f"{mention} will be mentioned on the next message received.",
)
return await ctx.send(embed=embed)
@@ -483,8 +469,7 @@ async def unnotify(
mentions.remove(mention)
await self.bot.config.update()
embed = discord.Embed(
- color=self.bot.main_color,
- description=f"{mention} will no longer be notified.",
+ color=self.bot.main_color, description=f"{mention} will no longer be notified."
)
return await ctx.send(embed=embed)
@@ -505,7 +490,7 @@ async def subscribe(
"""
mention = self.parse_user_or_role(ctx, user_or_role)
if mention is None:
- raise commands.BadArgument(f"{user_or_role} is not a valid role.")
+ raise commands.BadArgument(f"{user_or_role} is not a valid user or role.")
thread = ctx.thread
@@ -517,15 +502,14 @@ async def subscribe(
if mention in mentions:
embed = discord.Embed(
color=self.bot.error_color,
- description=f"{mention} is already " "subscribed to this thread.",
+ description=f"{mention} is already subscribed to this thread.",
)
else:
mentions.append(mention)
await self.bot.config.update()
embed = discord.Embed(
color=self.bot.main_color,
- description=f"{mention} will now be "
- "notified of all messages received.",
+ description=f"{mention} will now be notified of all messages received.",
)
return await ctx.send(embed=embed)
@@ -556,14 +540,14 @@ async def unsubscribe(
if mention not in mentions:
embed = discord.Embed(
color=self.bot.error_color,
- description=f"{mention} is not already " "subscribed to this thread.",
+ description=f"{mention} is not already subscribed to this thread.",
)
else:
mentions.remove(mention)
await self.bot.config.update()
embed = discord.Embed(
color=self.bot.main_color,
- description=f"{mention} is now unsubscribed " "to this thread.",
+ description=f"{mention} is now unsubscribed from this thread.",
)
return await ctx.send(embed=embed)
@@ -574,10 +558,7 @@ async def nsfw(self, ctx):
"""Flags a Modmail thread as NSFW (not safe for work)."""
await ctx.channel.edit(nsfw=True)
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -586,10 +567,7 @@ async def sfw(self, ctx):
"""Flags a Modmail thread as SFW (safe for work)."""
await ctx.channel.edit(nsfw=False)
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
@@ -597,9 +575,7 @@ async def sfw(self, ctx):
async def loglink(self, ctx):
"""Retrieves the link to the current thread's logs."""
log_link = await self.bot.api.get_log_link(ctx.channel.id)
- await ctx.send(
- embed=discord.Embed(color=self.bot.main_color, description=log_link)
- )
+ await ctx.send(embed=discord.Embed(color=self.bot.main_color, description=log_link))
def format_log_embeds(self, logs, avatar_url):
embeds = []
@@ -618,13 +594,9 @@ def format_log_embeds(self, logs, avatar_url):
username += entry["recipient"]["discriminator"]
embed = discord.Embed(color=self.bot.main_color, timestamp=created_at)
- embed.set_author(
- name=f"{title} - {username}", icon_url=avatar_url, url=log_url
- )
+ embed.set_author(name=f"{title} - {username}", icon_url=avatar_url, url=log_url)
embed.url = log_url
- embed.add_field(
- name="Created", value=duration(created_at, now=datetime.utcnow())
- )
+ embed.add_field(name="Created", value=duration(created_at, now=datetime.utcnow()))
closer = entry.get("closer")
if closer is None:
closer_msg = "Unknown"
@@ -635,9 +607,7 @@ def format_log_embeds(self, logs, avatar_url):
if entry["recipient"]["id"] != entry["creator"]["id"]:
embed.add_field(name="Created by", value=f"<@{entry['creator']['id']}>")
- embed.add_field(
- name="Preview", value=format_preview(entry["messages"]), inline=False
- )
+ embed.add_field(name="Preview", value=format_preview(entry["messages"]), inline=False)
if closer is not None:
# BUG: Currently, logviewer can't display logs without a closer.
@@ -677,11 +647,11 @@ async def logs(self, ctx, *, user: User = None):
if not any(not log["open"] for log in logs):
embed = discord.Embed(
color=self.bot.error_color,
- description="This user does not " "have any previous logs.",
+ description="This user does not have any previous logs.",
)
return await ctx.send(embed=embed)
- logs = reversed([e for e in logs if not e["open"]])
+ logs = reversed([log for log in logs if not log["open"]])
embeds = self.format_log_embeds(logs, avatar_url=icon_url)
@@ -699,11 +669,7 @@ async def logs_closed_by(self, ctx, *, user: User = None):
"""
user = user if user is not None else ctx.author
- query = {
- "guild_id": str(self.bot.guild_id),
- "open": False,
- "closer.id": str(user.id),
- }
+ query = {"guild_id": str(self.bot.guild_id), "open": False, "closer.id": str(user.id)}
projection = {"messages": {"$slice": 5}}
@@ -714,7 +680,7 @@ async def logs_closed_by(self, ctx, *, user: User = None):
if not embeds:
embed = discord.Embed(
color=self.bot.error_color,
- description="No log entries have been found for that query",
+ description="No log entries have been found for that query.",
)
return await ctx.send(embed=embed)
@@ -818,10 +784,32 @@ async def reply(self, ctx, *, msg: str = ""):
async with ctx.typing():
await ctx.thread.reply(ctx.message)
- @commands.command()
+ @commands.command(aliases=["formatreply"])
+ @checks.has_permissions(PermissionLevel.SUPPORTER)
+ @checks.thread_only()
+ async def freply(self, ctx, *, msg: str = ""):
+ """
+ Reply to a Modmail thread with variables.
+
+ Works just like `{prefix}reply`, however with the addition of three variables:
+ - `{{channel}}` - the `discord.TextChannel` object
+ - `{{recipient}}` - the `discord.User` object of the recipient
+ - `{{author}}` - the `discord.User` object of the author
+
+ Supports attachments and images as well as
+ automatically embedding image URLs.
+ """
+ msg = self.bot.formatter.format(
+ msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author
+ )
+ ctx.message.content = msg
+ async with ctx.typing():
+ await ctx.thread.reply(ctx.message)
+
+ @commands.command(aliases=["anonreply", "anonymousreply"])
@checks.has_permissions(PermissionLevel.SUPPORTER)
@checks.thread_only()
- async def anonreply(self, ctx, *, msg: str = ""):
+ async def areply(self, ctx, *, msg: str = ""):
"""
Reply to a thread anonymously.
@@ -849,24 +837,6 @@ async def note(self, ctx, *, msg: str = ""):
msg = await ctx.thread.note(ctx.message)
await msg.pin()
- async def find_linked_message(self, ctx, message_id):
- linked_message_id = None
-
- async for msg in ctx.channel.history():
- if message_id is None and msg.embeds:
- embed = msg.embeds[0]
- if embed.color.value != self.bot.mod_color or not embed.author.url:
- continue
- # TODO: use regex to find the linked message id
- linked_message_id = str(embed.author.url).split("/")[-1]
- break
- elif message_id and msg.id == message_id:
- url = msg.embeds[0].author.url
- linked_message_id = str(url).split("/")[-1]
- break
-
- return linked_message_id
-
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
@checks.thread_only()
@@ -874,14 +844,16 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str):
"""
Edit a message that was sent using the reply or anonreply command.
- If no `message_id` is provided, the
- last message sent by a staff will be edited.
+ If no `message_id` is provided,
+ the last message sent by a staff will be edited.
+
+ Note: attachments **cannot** be edited.
"""
thread = ctx.thread
- linked_message_id = await self.find_linked_message(ctx, message_id)
-
- if linked_message_id is None:
+ try:
+ await thread.edit_message(message_id, message)
+ except ValueError:
return await ctx.send(
embed=discord.Embed(
title="Failed",
@@ -890,25 +862,17 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str):
)
)
- await asyncio.gather(
- thread.edit_message(linked_message_id, message),
- self.bot.api.edit_message(linked_message_id, message),
- )
-
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
async def contact(
self,
ctx,
- category: Optional[discord.CategoryChannel] = None,
- *,
user: Union[discord.Member, discord.User],
+ *,
+ category: discord.CategoryChannel = None,
):
"""
Create a thread with a specified member.
@@ -922,8 +886,7 @@ async def contact(
if user.bot:
embed = discord.Embed(
- color=self.bot.error_color,
- description="Cannot start a thread with a bot.",
+ color=self.bot.error_color, description="Cannot start a thread with a bot."
)
return await ctx.send(embed=embed)
@@ -937,25 +900,19 @@ async def contact(
await ctx.channel.send(embed=embed)
else:
- thread = self.bot.threads.create(
- user, creator=ctx.author, category=category
- )
+ thread = await self.bot.threads.create(user, creator=ctx.author, category=category)
if self.bot.config["dm_disabled"] >= 1:
logger.info("Contacting user %s when Modmail DM is disabled.", user)
embed = discord.Embed(
title="Created Thread",
- description=f"Thread started by {ctx.author.mention} "
- f"for {user.mention}.",
+ description=f"Thread started by {ctx.author.mention} for {user.mention}.",
color=self.bot.main_color,
)
await thread.wait_until_ready()
await thread.channel.send(embed=embed)
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
await asyncio.sleep(3)
await ctx.message.delete()
@@ -965,11 +922,7 @@ async def contact(
async def blocked(self, ctx):
"""Retrieve a list of blocked users."""
- embeds = [
- discord.Embed(
- title="Blocked Users", color=self.bot.main_color, description=""
- )
- ]
+ embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")]
users = []
@@ -1059,12 +1012,10 @@ async def blocked_whitelist(self, ctx, *, user: User = None):
return await ctx.send(embed=embed)
- @commands.command(usage="[user] [duration] [close message]")
+ @commands.command(usage="[user] [duration] [reason]")
@checks.has_permissions(PermissionLevel.MODERATOR)
@trigger_typing
- async def block(
- self, ctx, user: Optional[User] = None, *, after: UserFriendlyTime = None
- ):
+ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTime = None):
"""
Block a user from using Modmail.
@@ -1083,7 +1034,7 @@ async def block(
elif after is None:
raise commands.MissingRequiredArgument(SimpleNamespace(name="user"))
else:
- raise commands.BadArgument(f'User "{after.arg}" not found')
+ raise commands.BadArgument(f'User "{after.arg}" not found.')
mention = getattr(user, "mention", f"`{user.id}`")
@@ -1115,8 +1066,8 @@ async def block(
old_reason = msg.strip().rstrip(".")
embed = discord.Embed(
title="Success",
- description=f"{mention} was previously blocked "
- f"{old_reason}.\n{mention} is now blocked {reason}",
+ description=f"{mention} was previously blocked {old_reason}.\n"
+ f"{mention} is now blocked {reason}",
color=self.bot.main_color,
)
else:
@@ -1162,14 +1113,14 @@ async def unblock(self, ctx, *, user: User = None):
reason = msg[16:].strip().rstrip(".") or "no reason"
embed = discord.Embed(
title="Success",
- description=f"{mention} was previously blocked internally "
- f"{reason}.\n{mention} is no longer blocked.",
+ description=f"{mention} was previously blocked internally {reason}.\n"
+ f"{mention} is no longer blocked.",
color=self.bot.main_color,
)
embed.set_footer(
text="However, if the original system block reason still applies, "
- f"{name} will be automatically blocked again. Use "
- f'"{self.bot.prefix}blocked whitelist {user.id}" to whitelist the user.'
+ f"{name} will be automatically blocked again. "
+ f'Use "{self.bot.prefix}blocked whitelist {user.id}" to whitelist the user.'
)
else:
embed = discord.Embed(
@@ -1179,9 +1130,7 @@ async def unblock(self, ctx, *, user: User = None):
)
else:
embed = discord.Embed(
- title="Error",
- description=f"{mention} is not blocked.",
- color=self.bot.error_color,
+ title="Error", description=f"{mention} is not blocked.", color=self.bot.error_color
)
return await ctx.send(embed=embed)
@@ -1189,7 +1138,7 @@ async def unblock(self, ctx, *, user: User = None):
@commands.command()
@checks.has_permissions(PermissionLevel.SUPPORTER)
@checks.thread_only()
- async def delete(self, ctx, message_id: Optional[int] = None):
+ async def delete(self, ctx, message_id: int = None):
"""
Delete a message that was sent using the reply command or a note.
@@ -1200,17 +1149,9 @@ async def delete(self, ctx, message_id: Optional[int] = None):
"""
thread = ctx.thread
- if message_id is not None:
- try:
- message_id = int(message_id)
- except ValueError:
- raise commands.BadArgument(
- "An integer message ID needs to be specified."
- )
-
- linked_message_id = await self.find_linked_message(ctx, message_id)
-
- if linked_message_id is None:
+ try:
+ await thread.delete_message(message_id)
+ except ValueError:
return await ctx.send(
embed=discord.Embed(
title="Failed",
@@ -1219,12 +1160,119 @@ async def delete(self, ctx, message_id: Optional[int] = None):
)
)
- await thread.delete_message(linked_message_id)
sent_emoji, _ = await self.bot.retrieve_emoji()
- try:
- await ctx.message.add_reaction(sent_emoji)
- except (discord.HTTPException, discord.InvalidArgument):
- pass
+ await self.bot.add_reaction(ctx.message, sent_emoji)
+
+ @commands.command()
+ @checks.has_permissions(PermissionLevel.SUPPORTER)
+ async def repair(self, ctx):
+ """
+ Repair a thread broken by Discord.
+ """
+ sent_emoji, blocked_emoji = await self.bot.retrieve_emoji()
+
+ if ctx.thread:
+ user_id = match_user_id(ctx.channel.topic)
+ if user_id == -1:
+ logger.info("Setting current channel's topic to User ID.")
+ await ctx.channel.edit(topic=f"User ID: {ctx.thread.id}")
+ return await self.bot.add_reaction(ctx.message, sent_emoji)
+
+ logger.info("Attempting to fix a broken thread %s.", ctx.channel.name)
+
+ # Search cache for channel
+ user_id, thread = next(
+ ((k, v) for k, v in self.bot.threads.cache.items() if v.channel == ctx.channel),
+ (-1, None),
+ )
+ if thread is not None:
+ logger.debug("Found thread with tempered ID.")
+ await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}")
+ return await self.bot.add_reaction(ctx.message, sent_emoji)
+
+ # find genesis message to retrieve User ID
+ async for message in ctx.channel.history(limit=10, oldest_first=True):
+ if (
+ message.author == self.bot.user
+ and message.embeds
+ and message.embeds[0].color
+ and message.embeds[0].color.value == self.bot.main_color
+ and message.embeds[0].footer.text
+ ):
+ user_id = match_user_id(message.embeds[0].footer.text)
+ if user_id != -1:
+ recipient = self.bot.get_user(user_id)
+ if recipient is None:
+ self.bot.threads.cache[user_id] = thread = Thread(
+ self.bot.threads, user_id, ctx.channel
+ )
+ else:
+ self.bot.threads.cache[user_id] = thread = Thread(
+ self.bot.threads, recipient, ctx.channel
+ )
+ thread.ready = True
+ logger.info(
+ "Setting current channel's topic to User ID and created new thread."
+ )
+ await ctx.channel.edit(
+ reason="Fix broken Modmail thread", topic=f"User ID: {user_id}"
+ )
+ return await self.bot.add_reaction(ctx.message, sent_emoji)
+
+ else:
+ logger.warning("No genesis message found.")
+
+ # match username from channel name
+ # username-1234, username-1234_1, username-1234_2
+ m = re.match(r"^(.+)-(\d{4})(?:_\d+)?$", ctx.channel.name)
+ if m is not None:
+ users = set(
+ filter(
+ lambda member: member.name == m.group(1)
+ and member.discriminator == m.group(2),
+ ctx.guild.members,
+ )
+ )
+ if len(users) == 1:
+ user = users.pop()
+ name = format_channel_name(
+ user, self.bot.modmail_guild, exclude_channel=ctx.channel
+ )
+ recipient = self.bot.get_user(user.id)
+ if user.id in self.bot.threads.cache:
+ thread = self.bot.threads.cache[user.id]
+ if thread.channel:
+ embed = discord.Embed(
+ title="Delete Channel",
+ description="This thread channel is no longer in use. "
+ f"All messages will be directed to {ctx.channel.mention} instead.",
+ color=self.bot.error_color,
+ )
+ embed.set_footer(
+ text='Please manually delete this channel, do not use "{prefix}close".'
+ )
+ try:
+ await thread.channel.send(embed=embed)
+ except discord.HTTPException:
+ pass
+ if recipient is None:
+ self.bot.threads.cache[user.id] = thread = Thread(
+ self.bot.threads, user_id, ctx.channel
+ )
+ else:
+ self.bot.threads.cache[user.id] = thread = Thread(
+ self.bot.threads, recipient, ctx.channel
+ )
+ thread.ready = True
+ logger.info("Setting current channel's topic to User ID and created new thread.")
+ await ctx.channel.edit(
+ reason="Fix broken Modmail thread", name=name, topic=f"User ID: {user.id}"
+ )
+ return await self.bot.add_reaction(ctx.message, sent_emoji)
+
+ elif len(users) >= 2:
+ logger.info("Multiple users with the same name and discriminator.")
+ return await self.bot.add_reaction(ctx.message, blocked_emoji)
@commands.command()
@checks.has_permissions(PermissionLevel.ADMINISTRATOR)
@@ -1236,7 +1284,7 @@ async def enable(self, ctx):
"""
embed = discord.Embed(
title="Success",
- description=f"Modmail will now accept all DM messages.",
+ description="Modmail will now accept all DM messages.",
color=self.bot.main_color,
)
@@ -1249,15 +1297,26 @@ async def enable(self, ctx):
@commands.group(invoke_without_command=True)
@checks.has_permissions(PermissionLevel.ADMINISTRATOR)
async def disable(self, ctx):
+ """
+ Disable partial or full Modmail thread functions.
+
+ To stop all new threads from being created, do `{prefix}disable new`.
+ To stop all existing threads from DMing Modmail, do `{prefix}disable all`.
+ To check if the DM function for Modmail is enabled, do `{prefix}isenable`.
+ """
+ await ctx.send_help(ctx.command)
+
+ @disable.command(name="new")
+ @checks.has_permissions(PermissionLevel.ADMINISTRATOR)
+ async def disable_new(self, ctx):
"""
Stop accepting new Modmail threads.
No new threads can be created through DM.
- To stop all existing threads from DMing Modmail, do `{prefix}disable all`.
"""
embed = discord.Embed(
title="Success",
- description=f"Modmail will not create any new threads.",
+ description="Modmail will not create any new threads.",
color=self.bot.main_color,
)
if self.bot.config["dm_disabled"] < 1:
@@ -1276,7 +1335,7 @@ async def disable_all(self, ctx):
"""
embed = discord.Embed(
title="Success",
- description=f"Modmail will not accept any DM messages.",
+ description="Modmail will not accept any DM messages.",
color=self.bot.main_color,
)
@@ -1296,19 +1355,19 @@ async def isenable(self, ctx):
if self.bot.config["dm_disabled"] == 1:
embed = discord.Embed(
title="New Threads Disabled",
- description=f"Modmail is not creating new threads.",
+ description="Modmail is not creating new threads.",
color=self.bot.error_color,
)
elif self.bot.config["dm_disabled"] == 2:
embed = discord.Embed(
title="All DM Disabled",
- description=f"Modmail is not accepting any DM messages for new and existing threads.",
+ description="Modmail is not accepting any DM messages for new and existing threads.",
color=self.bot.error_color,
)
else:
embed = discord.Embed(
title="Enabled",
- description=f"Modmail is accepting all DM messages.",
+ description="Modmail is accepting all DM messages.",
color=self.bot.main_color,
)
diff --git a/cogs/plugins.py b/cogs/plugins.py
index 15db8214e4..e4543d2f1e 100644
--- a/cogs/plugins.py
+++ b/cogs/plugins.py
@@ -41,9 +41,7 @@ def __init__(self, user, repo, name, branch=None):
@property
def path(self):
- return (
- PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}"
- )
+ return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}"
@property
def abs_path(self):
@@ -76,8 +74,7 @@ def from_string(cls, s, strict=False):
m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s)
if m is not None:
return Plugin(*m.groups())
- else:
- raise InvalidPluginError("Cannot decipher %s.", s)
+ raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple
def __hash__(self):
return hash((self.user, self.repo, self.name, self.branch))
@@ -129,14 +126,10 @@ async def initial_load_plugins(self):
# For backwards compat
plugin = Plugin.from_string(plugin_name)
except InvalidPluginError:
- logger.error(
- "Failed to parse plugin name: %s.", plugin_name, exc_info=True
- )
+ logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True)
continue
- logger.info(
- "Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)
- )
+ logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin))
self.bot.config["plugins"].append(str(plugin))
try:
@@ -212,9 +205,7 @@ async def load_plugin(self, plugin):
if stderr:
logger.debug("[stderr]\n%s.", stderr.decode())
logger.error(
- "Failed to download requirements for %s.",
- plugin.ext_string,
- exc_info=True,
+ "Failed to download requirements for %s.", plugin.ext_string, exc_info=True
)
raise InvalidPluginError(
f"Unable to download requirements: ```\n{stderr.decode()}\n```"
@@ -250,9 +241,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False):
if check_version:
required_version = details.get("bot_version", False)
- if required_version and self.bot.version < parse_version(
- required_version
- ):
+ if required_version and self.bot.version < parse_version(required_version):
embed = discord.Embed(
description="Your bot's version is too low. "
f"This plugin requires version `{required_version}`.",
@@ -293,7 +282,8 @@ async def plugins_add(self, ctx, *, plugin_name: str):
"""
Install a new plugin for the bot.
- `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`).
+ `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`,
+ or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`).
"""
plugin = await self.parse_user_input(ctx, plugin_name, check_version=True)
@@ -302,8 +292,7 @@ async def plugins_add(self, ctx, *, plugin_name: str):
if str(plugin) in self.bot.config["plugins"]:
embed = discord.Embed(
- description="This plugin is already installed.",
- color=self.bot.error_color,
+ description="This plugin is already installed.", color=self.bot.error_color
)
return await ctx.send(embed=embed)
@@ -324,10 +313,10 @@ async def plugins_add(self, ctx, *, plugin_name: str):
try:
await self.download_plugin(plugin, force=True)
except Exception:
- logger.warning(f"Unable to download plugin %s.", plugin, exc_info=True)
+ logger.warning("Unable to download plugin %s.", plugin, exc_info=True)
embed = discord.Embed(
- description=f"Failed to download plugin, check logs for error.",
+ description="Failed to download plugin, check logs for error.",
color=self.bot.error_color,
)
@@ -343,10 +332,10 @@ async def plugins_add(self, ctx, *, plugin_name: str):
try:
await self.load_plugin(plugin)
except Exception:
- logger.warning(f"Unable to load plugin %s.", plugin, exc_info=True)
+ logger.warning("Unable to load plugin %s.", plugin, exc_info=True)
embed = discord.Embed(
- description=f"Failed to download plugin, check logs for error.",
+ description="Failed to download plugin, check logs for error.",
color=self.bot.error_color,
)
@@ -409,8 +398,7 @@ async def plugins_remove(self, ctx, *, plugin_name: str):
pass # dir not empty
embed = discord.Embed(
- description="The plugin is successfully uninstalled.",
- color=self.bot.main_color,
+ description="The plugin is successfully uninstalled.", color=self.bot.main_color
)
await ctx.send(embed=embed)
@@ -436,8 +424,7 @@ async def update_plugin(self, ctx, plugin_name):
await self.load_plugin(plugin)
logger.debug("Updated %s.", plugin_name)
embed = discord.Embed(
- description=f"Successfully updated {plugin.name}.",
- color=self.bot.main_color,
+ description=f"Successfully updated {plugin.name}.", color=self.bot.main_color
)
return await ctx.send(embed=embed)
@@ -454,6 +441,7 @@ async def plugins_update(self, ctx, *, plugin_name: str = None):
"""
if plugin_name is None:
+ # pylint: disable=redefined-argument-from-local
for plugin_name in self.bot.config["plugins"]:
await self.update_plugin(ctx, plugin_name)
else:
@@ -483,8 +471,7 @@ async def plugins_loaded(self, ctx):
if not self.loaded_plugins:
embed = discord.Embed(
- description="There are no plugins currently loaded.",
- color=self.bot.error_color,
+ description="There are no plugins currently loaded.", color=self.bot.error_color
)
return await ctx.send(embed=embed)
@@ -510,13 +497,9 @@ async def plugins_loaded(self, ctx):
paginator = EmbedPaginatorSession(ctx, *embeds)
await paginator.run()
- @plugins.group(
- invoke_without_command=True, name="registry", aliases=["list", "info"]
- )
+ @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"])
@checks.has_permissions(PermissionLevel.OWNER)
- async def plugins_registry(
- self, ctx, *, plugin_name: typing.Union[int, str] = None
- ):
+ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None):
"""
Shows a list of all approved plugins.
@@ -539,9 +522,7 @@ async def plugins_registry(
if index >= len(registry):
index = len(registry) - 1
else:
- index = next(
- (i for i, (n, _) in enumerate(registry) if plugin_name == n), 0
- )
+ index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0)
if not index and plugin_name is not None:
embed = discord.Embed(
@@ -553,18 +534,17 @@ async def plugins_registry(
if matches:
embed.add_field(
- name="Perhaps you meant:",
- value="\n".join(f"`{m}`" for m in matches),
+ name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)
)
return await ctx.send(embed=embed)
- for plugin_name, details in registry:
- details = self.registry[plugin_name]
+ for name, details in registry:
+ details = self.registry[name]
user, repo = details["repository"].split("/", maxsplit=1)
branch = details.get("branch")
- plugin = Plugin(user, repo, plugin_name, branch)
+ plugin = Plugin(user, repo, name, branch)
embed = discord.Embed(
color=self.bot.main_color,
@@ -574,8 +554,7 @@ async def plugins_registry(
)
embed.add_field(
- name="Installation",
- value=f"```{self.bot.prefix}plugins add {plugin_name}```",
+ name="Installation", value=f"```{self.bot.prefix}plugins add {name}```"
)
embed.set_author(
@@ -592,11 +571,9 @@ async def plugins_registry(
embed.set_footer(text="This plugin is currently loaded.")
else:
required_version = details.get("bot_version", False)
- if required_version and self.bot.version < parse_version(
- required_version
- ):
+ if required_version and self.bot.version < parse_version(required_version):
embed.set_footer(
- text=f"Your bot is unable to install this plugin, "
+ text="Your bot is unable to install this plugin, "
f"minimum required version is v{required_version}."
)
else:
@@ -628,9 +605,7 @@ async def plugins_registry_compact(self, ctx):
plugin = Plugin(user, repo, plugin_name, branch)
- desc = discord.utils.escape_markdown(
- details["description"].replace("\n", "")
- )
+ desc = discord.utils.escape_markdown(details["description"].replace("\n", ""))
name = f"[`{plugin.name}`]({plugin.link})"
fmt = f"{name} - {desc}"
diff --git a/cogs/utility.py b/cogs/utility.py
index 9610582334..f8460c7d52 100644
--- a/cogs/utility.py
+++ b/cogs/utility.py
@@ -16,7 +16,7 @@
import discord
from discord.enums import ActivityType, Status
from discord.ext import commands, tasks
-from discord.utils import escape_markdown, escape_mentions
+from discord.ext.commands.view import StringView
from aiohttp import ClientResponseError
from pkg_resources import parse_version
@@ -67,11 +67,7 @@ async def format_cog_help(self, cog, *, no_cog=False):
embed.add_field(name="Commands", value=format_ or "No commands.")
continued = " (Continued)" if embeds else ""
- name = (
- cog.qualified_name + " - Help"
- if not no_cog
- else "Miscellaneous Commands"
- )
+ name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands"
embed.set_author(name=name + continued, icon_url=bot.user.avatar_url)
embed.set_footer(
@@ -92,11 +88,7 @@ async def send_bot_help(self, mapping):
bot = self.context.bot
# always come first
- default_cogs = [
- bot.get_cog("Modmail"),
- bot.get_cog("Utility"),
- bot.get_cog("Plugins"),
- ]
+ default_cogs = [bot.get_cog("Modmail"), bot.get_cog("Utility"), bot.get_cog("Plugins")]
default_cogs.extend(c for c in cogs if c not in default_cogs)
@@ -105,16 +97,12 @@ async def send_bot_help(self, mapping):
if no_cog_commands:
embeds.extend(await self.format_cog_help(no_cog_commands, no_cog=True))
- session = EmbedPaginatorSession(
- self.context, *embeds, destination=self.get_destination()
- )
+ session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination())
return await session.run()
async def send_cog_help(self, cog):
embeds = await self.format_cog_help(cog)
- session = EmbedPaginatorSession(
- self.context, *embeds, destination=self.get_destination()
- )
+ session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination())
return await session.run()
async def _get_help_embed(self, topic):
@@ -172,35 +160,48 @@ async def send_error_message(self, error):
command = self.context.kwargs.get("command")
val = self.context.bot.snippets.get(command)
if val is not None:
- return await self.get_destination().send(
- escape_mentions(f"**`{command}` is a snippet, " f"content:**\n\n{val}")
+ embed = discord.Embed(
+ title=f"{command} is a snippet.", color=self.context.bot.main_color
)
+ embed.add_field(name=f"`{command}` will send:", value=val)
+ return await self.get_destination().send(embed=embed)
val = self.context.bot.aliases.get(command)
if val is not None:
values = utils.parse_alias(val)
- if len(values) == 1:
+ if not values:
embed = discord.Embed(
- title=f"{command} is an alias.",
- color=self.context.bot.main_color,
- description=f"`{command}` points to `{escape_markdown(values[0])}`.",
+ title="Error",
+ color=self.context.bot.error_color,
+ description=f"Alias `{command}` is invalid, this alias will now be deleted."
+ "This alias will now be deleted.",
)
+ embed.add_field(name=f"{command}` used to be:", value=val)
+ self.context.bot.aliases.pop(command)
+ await self.context.bot.config.update()
else:
- embed = discord.Embed(
- title=f"{command} is an alias.",
- color=self.context.bot.main_color,
- description=f"**`{command}` points to the following steps:**",
- )
- for i, val in enumerate(values, start=1):
- embed.description += f"\n{i}: {escape_markdown(val)}"
+ if len(values) == 1:
+ embed = discord.Embed(
+ title=f"{command} is an alias.", color=self.context.bot.main_color
+ )
+ embed.add_field(name=f"`{command}` points to:", value=values[0])
+ else:
+ embed = discord.Embed(
+ title=f"{command} is an alias.",
+ color=self.context.bot.main_color,
+ description=f"**`{command}` points to the following steps:**",
+ )
+ for i, val in enumerate(values, start=1):
+ embed.add_field(name=f"Step {i}:", value=val)
+
embed.set_footer(
- text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" for more '
- "details on aliases."
+ text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" '
+ "for more details on aliases."
)
return await self.get_destination().send(embed=embed)
- logger.warning("CommandNotFound: %s", str(error))
+ logger.warning("CommandNotFound: %s", error)
embed = discord.Embed(color=self.context.bot.error_color)
embed.set_footer(text=f'Command/Category "{command}" not found.')
@@ -213,9 +214,7 @@ async def send_error_message(self, error):
closest = get_close_matches(command, choices)
if closest:
- embed.add_field(
- name=f"Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)
- )
+ embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest))
else:
embed.title = "Cannot find command or category"
embed.set_footer(
@@ -239,7 +238,7 @@ def __init__(self, bot):
},
)
self.bot.help_command.cog = self
- self.loop_presence.start()
+ self.loop_presence.start() # pylint: disable=no-member
def cog_unload(self):
self.bot.help_command = self._original_help_command
@@ -250,7 +249,7 @@ def cog_unload(self):
async def changelog(self, ctx, version: str.lower = ""):
"""Shows the changelog of the Modmail."""
changelog = await Changelog.from_url(self.bot)
- version = version.lstrip("vV") if version else changelog.latest_version.version
+ version = version.lstrip("v") if version else changelog.latest_version.version
try:
index = [v.version for v in changelog.versions].index(version)
@@ -277,7 +276,7 @@ async def changelog(self, ctx, version: str.lower = ""):
f"View the changelog here: {changelog.latest_version.changelog_url}#v{version[::2]}"
)
- @commands.command(aliases=["bot", "info"])
+ @commands.command(aliases=["info"])
@checks.has_permissions(PermissionLevel.REGULAR)
@utils.trigger_typing
async def about(self, ctx):
@@ -305,12 +304,11 @@ async def about(self, ctx):
if self.bot.version.is_prerelease:
stable = next(
- filter(
- lambda v: not parse_version(v.version).is_prerelease,
- changelog.versions,
- )
+ filter(lambda v: not parse_version(v.version).is_prerelease, changelog.versions)
+ )
+ footer = (
+ f"You are on the prerelease version • the latest version is v{stable.version}."
)
- footer = f"You are on the prerelease version • the latest version is v{stable.version}."
elif self.bot.version < parse_version(latest.version):
footer = f"A newer version is available v{latest.version}."
else:
@@ -359,14 +357,13 @@ async def sponsors(self, ctx):
@checks.has_permissions(PermissionLevel.OWNER)
@utils.trigger_typing
async def debug(self, ctx):
- """Shows the recent application-logs of the bot."""
+ """Shows the recent application logs of the bot."""
log_file_name = self.bot.token.split(".")[0]
with open(
os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- f"../temp/{log_file_name}.log",
+ os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"
),
"r+",
) as f:
@@ -383,23 +380,23 @@ async def debug(self, ctx):
messages = []
- # Using Scala formatting because it's similar to Python for exceptions
+ # Using Haskell formatting because it's similar to Python for exceptions
# and it does a fine job formatting the logs.
- msg = "```Scala\n"
+ msg = "```Haskell\n"
for line in logs.splitlines(keepends=True):
- if msg != "```Scala\n":
+ if msg != "```Haskell\n":
if len(line) + len(msg) + 3 > 2000:
msg += "```"
messages.append(msg)
- msg = "```Scala\n"
+ msg = "```Haskell\n"
msg += line
if len(msg) + 3 > 2000:
msg = msg[:1993] + "[...]```"
messages.append(msg)
- msg = "```Scala\n"
+ msg = "```Haskell\n"
- if msg != "```Scala\n":
+ if msg != "```Haskell\n":
msg += "```"
messages.append(msg)
@@ -421,17 +418,14 @@ async def debug_hastebin(self, ctx):
with open(
os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- f"../temp/{log_file_name}.log",
+ os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"
),
"rb+",
) as f:
logs = BytesIO(f.read().strip())
try:
- async with self.bot.session.post(
- haste_url + "/documents", data=logs
- ) as resp:
+ async with self.bot.session.post(haste_url + "/documents", data=logs) as resp:
data = await resp.json()
try:
key = data["key"]
@@ -447,8 +441,7 @@ async def debug_hastebin(self, ctx):
embed = discord.Embed(
title="Debug Logs",
color=self.bot.main_color,
- description="Something's wrong. "
- "We're unable to upload your logs to hastebin.",
+ description="Something's wrong. We're unable to upload your logs to hastebin.",
)
embed.set_footer(text="Go to Heroku to see your logs.")
await ctx.send(embed=embed)
@@ -463,8 +456,7 @@ async def debug_clear(self, ctx):
with open(
os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- f"../temp/{log_file_name}.log",
+ os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log"
),
"w",
):
@@ -527,9 +519,7 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""):
else:
msg += f"{activity.name}."
- embed = discord.Embed(
- title="Activity Changed", description=msg, color=self.bot.main_color
- )
+ embed = discord.Embed(title="Activity Changed", description=msg, color=self.bot.main_color)
return await ctx.send(embed=embed)
@commands.command()
@@ -566,14 +556,10 @@ async def status(self, ctx, *, status_type: str.lower):
await self.bot.config.update()
msg = f"Status set to: {status.value}."
- embed = discord.Embed(
- title="Status Changed", description=msg, color=self.bot.main_color
- )
+ embed = discord.Embed(title="Status Changed", description=msg, color=self.bot.main_color)
return await ctx.send(embed=embed)
- async def set_presence(
- self, *, status=None, activity_type=None, activity_message=None
- ):
+ async def set_presence(self, *, status=None, activity_type=None, activity_message=None):
if status is None:
status = self.bot.config.get("status")
@@ -582,9 +568,7 @@ async def set_presence(
activity_type = self.bot.config.get("activity_type")
url = None
- activity_message = (
- activity_message or self.bot.config["activity_message"]
- ).strip()
+ activity_message = (activity_message or self.bot.config["activity_message"]).strip()
if activity_type is not None and not activity_message:
logger.warning(
'No activity message found whilst activity is provided, defaults to "Modmail".'
@@ -600,9 +584,7 @@ async def set_presence(
url = self.bot.config["twitch_url"]
if activity_type is not None:
- activity = discord.Activity(
- type=activity_type, name=activity_message, url=url
- )
+ activity = discord.Activity(type=activity_type, name=activity_message, url=url)
else:
activity = None
await self.bot.change_presence(activity=activity, status=status)
@@ -664,9 +646,7 @@ async def mention(self, ctx, *, mention: str = None):
if mention is None:
embed = discord.Embed(
- title="Current mention:",
- color=self.bot.main_color,
- description=str(current),
+ title="Current mention:", color=self.bot.main_color, description=str(current)
)
else:
embed = discord.Embed(
@@ -761,9 +741,7 @@ async def config_set(self, ctx, key: str.lower, *, value: str):
embed = exc.embed
else:
embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description=f"{key} is an invalid key.",
+ title="Error", color=self.bot.error_color, description=f"{key} is an invalid key."
)
valid_keys = [f"`{k}`" for k in sorted(keys)]
embed.add_field(name="Valid keys", value=", ".join(valid_keys))
@@ -785,9 +763,7 @@ async def config_remove(self, ctx, *, key: str.lower):
)
else:
embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description=f"{key} is an invalid key.",
+ title="Error", color=self.bot.error_color, description=f"{key} is an invalid key."
)
valid_keys = [f"`{k}`" for k in sorted(keys)]
embed.add_field(name="Valid keys", value=", ".join(valid_keys))
@@ -808,9 +784,7 @@ async def config_get(self, ctx, *, key: str.lower = None):
if key in keys:
desc = f"`{key}` is set to `{self.bot.config[key]}`"
embed = discord.Embed(color=self.bot.main_color, description=desc)
- embed.set_author(
- name="Config variable", icon_url=self.bot.user.avatar_url
- )
+ embed.set_author(name="Config variable", icon_url=self.bot.user.avatar_url)
else:
embed = discord.Embed(
@@ -825,12 +799,9 @@ async def config_get(self, ctx, *, key: str.lower = None):
else:
embed = discord.Embed(
color=self.bot.main_color,
- description="Here is a list of currently "
- "set configuration variable(s).",
- )
- embed.set_author(
- name="Current config(s):", icon_url=self.bot.user.avatar_url
+ description="Here is a list of currently set configuration variable(s).",
)
+ embed.set_author(name="Current config(s):", icon_url=self.bot.user.avatar_url)
config = self.bot.config.filter_default(self.bot.config)
for name, value in config.items():
@@ -848,11 +819,18 @@ async def config_help(self, ctx, key: str.lower = None):
if key is not None and not (
key in self.bot.config.public_keys or key in self.bot.config.protected_keys
):
+ closest = get_close_matches(
+ key, {**self.bot.config.public_keys, **self.bot.config.protected_keys}
+ )
embed = discord.Embed(
title="Error",
color=self.bot.error_color,
description=f"`{key}` is an invalid key.",
)
+ if closest:
+ embed.add_field(
+ name=f"Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)
+ )
return await ctx.send(embed=embed)
config_help = self.bot.config.config_help
@@ -874,13 +852,10 @@ def fmt(val):
if current_key == key:
index = i
embed = discord.Embed(
- title=f"Configuration description on {current_key}:",
- color=self.bot.main_color,
+ title=f"Configuration description on {current_key}:", color=self.bot.main_color
)
embed.add_field(name="Default:", value=fmt(info["default"]), inline=False)
- embed.add_field(
- name="Information:", value=fmt(info["description"]), inline=False
- )
+ embed.add_field(name="Information:", value=fmt(info["description"]), inline=False)
if info["examples"]:
example_text = ""
for example in info["examples"]:
@@ -929,9 +904,7 @@ async def alias(self, ctx, *, name: str.lower = None):
if name is not None:
val = self.bot.aliases.get(name)
if val is None:
- embed = utils.create_not_found_embed(
- name, self.bot.aliases.keys(), "Alias"
- )
+ embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias")
return await ctx.send(embed=embed)
values = utils.parse_alias(val)
@@ -940,34 +913,37 @@ async def alias(self, ctx, *, name: str.lower = None):
embed = discord.Embed(
title="Error",
color=self.bot.error_color,
- description=f"Alias `{name}` is invalid, it used to be `{escape_markdown(val)}`. "
- f"This alias will now be deleted.",
+ description=f"Alias `{name}` is invalid, this alias will now be deleted."
+ "This alias will now be deleted.",
)
+ embed.add_field(name=f"{name}` used to be:", value=utils.truncate(val, 1024))
self.bot.aliases.pop(name)
await self.bot.config.update()
return await ctx.send(embed=embed)
if len(values) == 1:
embed = discord.Embed(
- color=self.bot.main_color,
- description=f"`{name}` points to `{escape_markdown(values[0])}`.",
+ title=f'Alias - "{name}":', description=values[0], color=self.bot.main_color
)
+ return await ctx.send(embed=embed)
+
else:
- embed = discord.Embed(
- color=self.bot.main_color,
- description=f"**`{name}` points to the following steps:**",
- )
+ embeds = []
for i, val in enumerate(values, start=1):
- embed.description += f"\n{i}: {escape_markdown(val)}"
-
- return await ctx.send(embed=embed)
+ embed = discord.Embed(
+ color=self.bot.main_color,
+ title=f'Alias - "{name}" - Step {i}:',
+ description=val,
+ )
+ embeds += [embed]
+ session = EmbedPaginatorSession(ctx, *embeds)
+ return await session.run()
if not self.bot.aliases:
embed = discord.Embed(
- color=self.bot.error_color,
- description="You dont have any aliases at the moment.",
+ color=self.bot.error_color, description="You dont have any aliases at the moment."
)
- embed.set_footer(text=f"Do {self.bot.prefix}help alias for more commands.")
+ embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.')
embed.set_author(name="Aliases", icon_url=ctx.guild.icon_url)
return await ctx.send(embed=embed)
@@ -992,7 +968,74 @@ async def alias_raw(self, ctx, *, name: str.lower):
if val is None:
embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias")
return await ctx.send(embed=embed)
- return await ctx.send(escape_markdown(escape_mentions(val)).replace("<", "\\<"))
+
+ val = utils.truncate(utils.escape_code_block(val), 2048 - 7)
+ embed = discord.Embed(
+ title=f'Raw alias - "{name}":', description=f"```\n{val}```", color=self.bot.main_color
+ )
+
+ return await ctx.send(embed=embed)
+
+ async def make_alias(self, name, value, action):
+ values = utils.parse_alias(value)
+ if not values:
+ embed = discord.Embed(
+ title="Error",
+ color=self.bot.error_color,
+ description="Invalid multi-step alias, try wrapping each steps in quotes.",
+ )
+ embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.')
+ return embed
+
+ if len(values) > 25:
+ embed = discord.Embed(
+ title="Error", description="Too many steps, max=25.", color=self.bot.error_color
+ )
+ return embed
+
+ save_aliases = []
+
+ multiple_alias = len(values) > 1
+
+ embed = discord.Embed(title=f"{action} alias", color=self.bot.main_color)
+
+ if not multiple_alias:
+ embed.add_field(name=f"`{name}` points to:", value=utils.truncate(values[0], 1024))
+ else:
+ embed.description = f"`{name}` now points to the following steps:"
+
+ for i, val in enumerate(values, start=1):
+ view = StringView(val)
+ linked_command = view.get_word().lower()
+ message = view.read_rest()
+
+ if not self.bot.get_command(linked_command):
+ alias_command = self.bot.aliases.get(linked_command)
+ if alias_command is not None:
+ save_aliases.extend(utils.normalize_alias(alias_command, message))
+ else:
+ embed = discord.Embed(title="Error", color=self.bot.error_color)
+
+ if multiple_alias:
+ embed.description = (
+ "The command you are attempting to point "
+ f"to does not exist: `{linked_command}`."
+ )
+ else:
+ embed.description = (
+ "The command you are attempting to point "
+ f"to on step {i} does not exist: `{linked_command}`."
+ )
+
+ return embed
+ else:
+ save_aliases.append(val)
+ if multiple_alias:
+ embed.add_field(name=f"Step {i}:", value=utils.truncate(val, 1024))
+
+ self.bot.aliases[name] = " && ".join(f'"{a}"' for a in save_aliases)
+ await self.bot.config.update()
+ return embed
@alias.command(name="add")
@checks.has_permissions(PermissionLevel.MODERATOR)
@@ -1036,76 +1079,11 @@ async def alias_add(self, ctx, name: str.lower, *, value):
embed = discord.Embed(
title="Error",
color=self.bot.error_color,
- description=f"Alias names cannot be longer than 120 characters.",
- )
-
- if embed is not None:
- return await ctx.send(embed=embed)
-
- values = utils.parse_alias(value)
-
- if not values:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="Invalid multi-step alias, try wrapping each steps in quotes.",
+ description="Alias names cannot be longer than 120 characters.",
)
- embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.')
- return await ctx.send(embed=embed)
-
- if len(values) == 1:
- linked_command, *messages = values[0].split(maxsplit=1)
- if not self.bot.get_command(linked_command):
- alias_command = self.bot.aliases.get(linked_command)
- if alias_command is not None:
- if messages:
- values = [f"{alias_command} {messages[0]}"]
- else:
- values = [alias_command]
- else:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="The command you are attempting to point "
- f"to does not exist: `{linked_command}`.",
- )
- return await ctx.send(embed=embed)
-
- embed = discord.Embed(
- title="Added alias",
- color=self.bot.main_color,
- description=f'`{name}` points to: "{values[0]}".',
- )
-
- else:
- embed = discord.Embed(
- title="Added alias",
- color=self.bot.main_color,
- description=f"`{name}` now points to the following steps:",
- )
-
- for i, val in enumerate(values, start=1):
- linked_command, *messages = val.split(maxsplit=1)
- if not self.bot.get_command(linked_command):
- alias_command = self.bot.aliases.get(linked_command)
- if alias_command is not None:
- if messages:
- values = [f"{alias_command} {messages[0]}"]
- else:
- values = [alias_command]
- else:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="The command you are attempting to point "
- f"to n step {i} does not exist: `{linked_command}`.",
- )
- return await ctx.send(embed=embed)
- embed.description += f"\n{i}: {val}"
-
- self.bot.aliases[name] = " && ".join(values)
- await self.bot.config.update()
+ if embed is None:
+ embed = await self.make_alias(name, value, "Added")
return await ctx.send(embed=embed)
@alias.command(name="remove", aliases=["del", "delete"])
@@ -1137,68 +1115,7 @@ async def alias_edit(self, ctx, name: str.lower, *, value):
embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias")
return await ctx.send(embed=embed)
- values = utils.parse_alias(value)
-
- if not values:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="Invalid multi-step alias, try wrapping each steps in quotes.",
- )
- embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.')
- return await ctx.send(embed=embed)
-
- if len(values) == 1:
- linked_command, *messages = values[0].split(maxsplit=1)
- if not self.bot.get_command(linked_command):
- alias_command = self.bot.aliases.get(linked_command)
- if alias_command is not None:
- if messages:
- values = [f"{alias_command} {messages[0]}"]
- else:
- values = [alias_command]
- else:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="The command you are attempting to point "
- f"to does not exist: `{linked_command}`.",
- )
- return await ctx.send(embed=embed)
- embed = discord.Embed(
- title="Edited alias",
- color=self.bot.main_color,
- description=f'`{name}` now points to: "{values[0]}".',
- )
-
- else:
- embed = discord.Embed(
- title="Edited alias",
- color=self.bot.main_color,
- description=f"`{name}` now points to the following steps:",
- )
-
- for i, val in enumerate(values, start=1):
- linked_command, *messages = val.split(maxsplit=1)
- if not self.bot.get_command(linked_command):
- alias_command = self.bot.aliases.get(linked_command)
- if alias_command is not None:
- if messages:
- values = [f"{alias_command} {messages[0]}"]
- else:
- values = [alias_command]
- else:
- embed = discord.Embed(
- title="Error",
- color=self.bot.error_color,
- description="The command you are attempting to point "
- f"to on step {i} does not exist: `{linked_command}`.",
- )
- return await ctx.send(embed=embed)
- embed.description += f"\n{i}: {val}"
-
- self.bot.aliases[name] = "&&".join(values)
- await self.bot.config.update()
+ embed = await self.make_alias(name, value, "Edited")
return await ctx.send(embed=embed)
@commands.group(aliases=["perms"], invoke_without_command=True)
@@ -1256,9 +1173,7 @@ def _parse_level(name):
@permissions.command(name="override")
@checks.has_permissions(PermissionLevel.OWNER)
- async def permissions_override(
- self, ctx, command_name: str.lower, *, level_name: str
- ):
+ async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str):
"""
Change a permission level for a specific command.
@@ -1297,9 +1212,7 @@ async def permissions_override(
command.qualified_name,
level.name,
)
- self.bot.config["override_command_level"][
- command.qualified_name
- ] = level.name
+ self.bot.config["override_command_level"][command.qualified_name] = level.name
await self.bot.config.update()
embed = discord.Embed(
@@ -1332,7 +1245,7 @@ async def permissions_add(
- `{prefix}perms add command "plugin enabled" @role`
- `{prefix}perms add command help 984301093849028`
- Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead,
+ Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead.
"""
if type_ not in {"command", "level"}:
@@ -1371,9 +1284,7 @@ async def permissions_add(
key = self.bot.modmail_guild.get_member(value)
if key is not None:
logger.info("Granting %s access to Modmail category.", key.name)
- await self.bot.main_category.set_permissions(
- key, read_messages=True
- )
+ await self.bot.main_category.set_permissions(key, read_messages=True)
embed = discord.Embed(
title="Success",
@@ -1410,7 +1321,7 @@ async def permissions_remove(
- `{prefix}perms remove override block`
- `{prefix}perms remove override "snippet add"`
- Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead,
+ Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead.
"""
if type_ not in {"command", "level", "override"} or (
type_ != "override" and user_or_role is None
@@ -1470,21 +1381,13 @@ async def permissions_remove(
self.bot.modmail_guild.default_role, read_messages=False
)
elif isinstance(user_or_role, discord.Role):
- logger.info(
- "Denying %s access to Modmail category.", user_or_role.name
- )
- await self.bot.main_category.set_permissions(
- user_or_role, overwrite=None
- )
+ logger.info("Denying %s access to Modmail category.", user_or_role.name)
+ await self.bot.main_category.set_permissions(user_or_role, overwrite=None)
else:
member = self.bot.modmail_guild.get_member(value)
if member is not None and member != self.bot.modmail_guild.me:
- logger.info(
- "Denying %s access to Modmail category.", member.name
- )
- await self.bot.main_category.set_permissions(
- member, overwrite=None
- )
+ logger.info("Denying %s access to Modmail category.", member.name)
+ await self.bot.main_category.set_permissions(member, overwrite=None)
embed = discord.Embed(
title="Success",
@@ -1534,11 +1437,7 @@ def _get_perm(self, ctx, name, type_):
@permissions.command(name="get", usage="[@user] or [command/level/override] [name]")
@checks.has_permissions(PermissionLevel.OWNER)
async def permissions_get(
- self,
- ctx,
- user_or_role: Union[discord.Role, utils.User, str],
- *,
- name: str = None,
+ self, ctx, user_or_role: Union[discord.Role, utils.User, str], *, name: str = None
):
"""
View the currently-set permissions.
@@ -1564,7 +1463,7 @@ async def permissions_get(
- `{prefix}perms get override block`
- `{prefix}perms get override permissions add`
- Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead,
+ Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead.
"""
if name is None and user_or_role not in {"command", "level", "override"}:
@@ -1588,9 +1487,7 @@ async def permissions_get(
if value in permissions:
levels.append(level.name)
- mention = getattr(
- user_or_role, "name", getattr(user_or_role, "id", user_or_role)
- )
+ mention = getattr(user_or_role, "name", getattr(user_or_role, "id", user_or_role))
desc_cmd = (
", ".join(map(lambda x: f"`{x}`", cmds))
if cmds
@@ -1640,14 +1537,10 @@ async def permissions_get(
)
)
else:
- for items in zip_longest(
- *(iter(sorted(overrides.items())),) * 15
- ):
+ for items in zip_longest(*(iter(sorted(overrides.items())),) * 15):
description = "\n".join(
": ".join((f"`{name}`", level))
- for name, level in takewhile(
- lambda x: x is not None, items
- )
+ for name, level in takewhile(lambda x: x is not None, items)
)
embed = discord.Embed(
color=self.bot.main_color, description=description
@@ -1703,9 +1596,7 @@ async def permissions_get(
return await ctx.send(embed=embed)
if user_or_role == "command":
- embeds.append(
- self._get_perm(ctx, command.qualified_name, "command")
- )
+ embeds.append(self._get_perm(ctx, command.qualified_name, "command"))
else:
embeds.append(self._get_perm(ctx, level.name, "level"))
else:
@@ -1714,9 +1605,7 @@ async def permissions_get(
for command in self.bot.walk_commands():
if command not in done:
done.add(command)
- embeds.append(
- self._get_perm(ctx, command.qualified_name, "command")
- )
+ embeds.append(self._get_perm(ctx, command.qualified_name, "command"))
else:
for perm_level in PermissionLevel:
embeds.append(self._get_perm(ctx, perm_level.name, "level"))
@@ -1758,12 +1647,10 @@ async def oauth_whitelist(self, ctx, target: Union[discord.Role, utils.User]):
embed.title = "Success"
if not hasattr(target, "mention"):
- target = self.bot.get_user(target.id) or self.bot.modmail_guild.get_role(
- target.id
- )
+ target = self.bot.get_user(target.id) or self.bot.modmail_guild.get_role(target.id)
embed.description = (
- f"{'Un-w' if removed else 'W'}hitelisted " f"{target.mention} to view logs."
+ f"{'Un-w' if removed else 'W'}hitelisted {target.mention} to view logs."
)
await ctx.send(embed=embed)
@@ -1788,12 +1675,8 @@ async def oauth_show(self, ctx):
embed = discord.Embed(color=self.bot.main_color)
embed.title = "Oauth Whitelist"
- embed.add_field(
- name="Users", value=" ".join(u.mention for u in users) or "None"
- )
- embed.add_field(
- name="Roles", value=" ".join(r.mention for r in roles) or "None"
- )
+ embed.add_field(name="Users", value=" ".join(u.mention for u in users) or "None")
+ embed.add_field(name="Roles", value=" ".join(r.mention for r in roles) or "None")
await ctx.send(embed=embed)
@@ -1840,7 +1723,7 @@ def paginate(text: str):
exec(to_compile, env) # pylint: disable=exec-used
except Exception as exc:
await ctx.send(f"```py\n{exc.__class__.__name__}: {exc}\n```")
- return await ctx.message.add_reaction("\u2049")
+ return await self.bot.add_reaction(ctx.message, "\u2049")
func = env["func"]
try:
@@ -1849,7 +1732,7 @@ def paginate(text: str):
except Exception:
value = stdout.getvalue()
await ctx.send(f"```py\n{value}{traceback.format_exc()}\n```")
- return await ctx.message.add_reaction("\u2049")
+ return await self.bot.add_reaction(ctx.message, "\u2049")
else:
value = stdout.getvalue()
diff --git a/core/_color_data.py b/core/_color_data.py
index ad98d3856f..0ac42d5c1f 100644
--- a/core/_color_data.py
+++ b/core/_color_data.py
@@ -43,9 +43,7 @@
}
# Normalize name to "discord:" to avoid name collisions.
-DISCORD_COLORS_NORM = {
- "discord:" + name: value for name, value in DISCORD_COLORS.items()
-}
+DISCORD_COLORS_NORM = {"discord:" + name: value for name, value in DISCORD_COLORS.items()}
# These colors are from Tableau
diff --git a/core/changelog.py b/core/changelog.py
index 91856600e9..ace825482f 100644
--- a/core/changelog.py
+++ b/core/changelog.py
@@ -51,9 +51,7 @@ def __init__(self, bot, branch: str, version: str, lines: str):
self.version = version.lstrip("vV")
self.lines = lines.strip()
self.fields = {}
- self.changelog_url = (
- f"https://github.com/kyb3r/modmail/blob/{branch}/CHANGELOG.md"
- )
+ self.changelog_url = f"https://github.com/kyb3r/modmail/blob/{branch}/CHANGELOG.md"
self.description = ""
self.parse()
@@ -91,9 +89,7 @@ def embed(self) -> Embed:
"""
embed = Embed(color=self.bot.main_color, description=self.description)
embed.set_author(
- name=f"v{self.version} - Changelog",
- icon_url=self.bot.user.avatar_url,
- url=self.url,
+ name=f"v{self.version} - Changelog", icon_url=self.bot.user.avatar_url, url=self.url
)
for name, value in self.fields.items():
@@ -138,9 +134,7 @@ def __init__(self, bot, branch: str, text: str):
self.bot = bot
self.text = text
logger.debug("Fetching changelog from GitHub.")
- self.versions = [
- Version(bot, branch, *m) for m in self.VERSION_REGEX.findall(text)
- ]
+ self.versions = [Version(bot, branch, *m) for m in self.VERSION_REGEX.findall(text)]
@property
def latest_version(self) -> Version:
@@ -174,10 +168,7 @@ async def from_url(cls, bot, url: str = "") -> "Changelog":
The newly created `Changelog` parsed from the `url`.
"""
branch = "master" if not bot.version.is_prerelease else "development"
- url = (
- url
- or f"https://raw.githubusercontent.com/kyb3r/modmail/{branch}/CHANGELOG.md"
- )
+ url = url or f"https://raw.githubusercontent.com/kyb3r/modmail/{branch}/CHANGELOG.md"
async with await bot.session.get(url) as resp:
return cls(bot, branch, await resp.text())
diff --git a/core/checks.py b/core/checks.py
index 94f2c40904..46f8424b96 100644
--- a/core/checks.py
+++ b/core/checks.py
@@ -5,9 +5,7 @@
logger = getLogger(__name__)
-def has_permissions_predicate(
- permission_level: PermissionLevel = PermissionLevel.REGULAR
-):
+def has_permissions_predicate(permission_level: PermissionLevel = PermissionLevel.REGULAR):
async def predicate(ctx):
return await check_permissions(ctx, ctx.command.qualified_name)
diff --git a/core/clients.py b/core/clients.py
index 8d89331664..54cc28c9be 100644
--- a/core/clients.py
+++ b/core/clients.py
@@ -66,9 +66,7 @@ async def request(
`str` if the returned data is not a valid json data,
the raw response.
"""
- async with self.session.request(
- method, url, headers=headers, json=payload
- ) as resp:
+ async with self.session.request(method, url, headers=headers, json=payload) as resp:
if return_response:
return resp
try:
@@ -93,6 +91,13 @@ async def get_user_logs(self, user_id: Union[str, int]) -> list:
return await self.logs.find(query, projection).to_list(None)
+ async def get_latest_user_logs(self, user_id: Union[str, int]):
+ query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False}
+ projection = {"messages": {"$slice": 5}}
+ logger.debug("Retrieving user %s latest logs.", user_id)
+
+ return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)])
+
async def get_responded_logs(self, user_id: Union[str, int]) -> list:
query = {
"open": False,
@@ -120,7 +125,9 @@ async def get_log_link(self, channel_id: Union[str, int]) -> str:
prefix = self.bot.config["log_url_prefix"].strip("/")
if prefix == "NONE":
prefix = ""
- return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}"
+ return (
+ f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}"
+ )
async def create_log_entry(
self, recipient: Member, channel: TextChannel, creator: Member
@@ -184,13 +191,9 @@ async def update_config(self, data: dict):
{"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset}
)
if toset:
- return await self.db.config.update_one(
- {"bot_id": self.bot.user.id}, {"$set": toset}
- )
+ return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$set": toset})
if unset:
- return await self.db.config.update_one(
- {"bot_id": self.bot.user.id}, {"$unset": unset}
- )
+ return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$unset": unset})
async def edit_message(self, message_id: Union[int, str], new_content: str) -> None:
await self.logs.update_one(
@@ -201,13 +204,17 @@ async def edit_message(self, message_id: Union[int, str], new_content: str) -> N
async def append_log(
self,
message: Message,
- channel_id: Union[str, int] = "",
+ *,
+ message_id: str = "",
+ channel_id: str = "",
type_: str = "thread_message",
) -> dict:
channel_id = str(channel_id) or str(message.channel.id)
+ message_id = str(message_id) or str(message.id)
+
data = {
"timestamp": str(message.created_at),
- "message_id": str(message.id),
+ "message_id": message_id,
"author": {
"id": str(message.author.id),
"name": message.author.name,
@@ -230,16 +237,12 @@ async def append_log(
}
return await self.logs.find_one_and_update(
- {"channel_id": channel_id},
- {"$push": {f"messages": data}},
- return_document=True,
+ {"channel_id": channel_id}, {"$push": {"messages": data}}, return_document=True
)
async def post_log(self, channel_id: Union[int, str], data: dict) -> dict:
return await self.logs.find_one_and_update(
- {"channel_id": str(channel_id)},
- {"$set": {k: v for k, v in data.items()}},
- return_document=True,
+ {"channel_id": str(channel_id)}, {"$set": data}, return_document=True
)
diff --git a/core/config.py b/core/config.py
index bfc4a3b675..a0eda4a2dc 100644
--- a/core/config.py
+++ b/core/config.py
@@ -13,7 +13,7 @@
from core._color_data import ALL_COLORS
from core.models import InvalidConfigError, Default, getLogger
-from core.time import UserFriendlyTime
+from core.time import UserFriendlyTimeSync
from core.utils import strtobool
logger = getLogger(__name__)
@@ -27,14 +27,16 @@ class ConfigManager:
"twitch_url": "https://www.twitch.tv/discordmodmail/",
# bot settings
"main_category_id": None,
+ "fallback_category_id": None,
"prefix": "?",
"mention": "@here",
"main_color": str(discord.Color.blurple()),
"error_color": str(discord.Color.red()),
"user_typing": False,
"mod_typing": False,
- "account_age": None,
- "guild_age": None,
+ "account_age": isodate.Duration(),
+ "guild_age": isodate.Duration(),
+ "thread_cooldown": isodate.Duration(),
"reply_without_command": False,
"anon_reply_without_command": False,
# logging
@@ -45,7 +47,7 @@ class ConfigManager:
"close_emoji": "🔒",
"recipient_thread_close": False,
"thread_auto_close_silently": False,
- "thread_auto_close": None,
+ "thread_auto_close": isodate.Duration(),
"thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.",
"thread_creation_response": "The staff team will get back to you as soon as possible.",
"thread_creation_footer": "Your message has been sent",
@@ -115,7 +117,7 @@ class ConfigManager:
colors = {"mod_color", "recipient_color", "main_color", "error_color"}
- time_deltas = {"account_age", "guild_age", "thread_auto_close"}
+ time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown"}
booleans = {
"user_typing",
@@ -146,9 +148,7 @@ def populate_cache(self) -> dict:
data = deepcopy(self.defaults)
# populate from env var and .env file
- data.update(
- {k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}
- )
+ data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys})
config_json = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json"
)
@@ -165,9 +165,7 @@ def populate_cache(self) -> dict:
}
)
except json.JSONDecodeError:
- logger.critical(
- "Failed to load config.json env values.", exc_info=True
- )
+ logger.critical("Failed to load config.json env values.", exc_info=True)
self._cache = data
config_help_json = os.path.join(
@@ -228,17 +226,16 @@ def get(self, key: str, convert=True) -> typing.Any:
value = int(self.remove(key).lstrip("#"), base=16)
elif key in self.time_deltas:
- if value is None:
- return
- try:
- value = isodate.parse_duration(value)
- except isodate.ISO8601Error:
- logger.warning(
- "The {account} age limit needs to be a "
- 'ISO-8601 duration formatted duration, not "%s".',
- value,
- )
- value = self.remove(key)
+ if not isinstance(value, isodate.Duration):
+ try:
+ value = isodate.parse_duration(value)
+ except isodate.ISO8601Error:
+ logger.warning(
+ "The {account} age limit needs to be a "
+ 'ISO-8601 duration formatted duration, not "%s".',
+ value,
+ )
+ value = self.remove(key)
elif key in self.booleans:
try:
@@ -248,7 +245,7 @@ def get(self, key: str, convert=True) -> typing.Any:
elif key in self.special_types:
if value is None:
- return
+ return None
if key == "status":
try:
@@ -302,15 +299,14 @@ def set(self, key: str, item: typing.Any, convert=True) -> None:
isodate.parse_duration(item)
except isodate.ISO8601Error:
try:
- converter = UserFriendlyTime()
- time = self.bot.loop.run_until_complete(
- converter.convert(None, item)
- )
+ converter = UserFriendlyTimeSync()
+ time = converter.convert(None, item)
if time.arg:
raise ValueError
except BadArgument as exc:
raise InvalidConfigError(*exc.args)
- except Exception:
+ except Exception as e:
+ logger.debug(e)
raise InvalidConfigError(
"Unrecognized time, please use ISO-8601 duration format "
'string or a simpler "human readable" time.'
@@ -343,9 +339,7 @@ def items(self) -> typing.Iterable:
return self._cache.items()
@classmethod
- def filter_valid(
- cls, data: typing.Dict[str, typing.Any]
- ) -> typing.Dict[str, typing.Any]:
+ def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
return {
k.lower(): v
for k, v in data.items()
@@ -353,9 +347,7 @@ def filter_valid(
}
@classmethod
- def filter_default(
- cls, data: typing.Dict[str, typing.Any]
- ) -> typing.Dict[str, typing.Any]:
+ def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
# TODO: use .get to prevent errors
filtered = {}
for k, v in data.items():
diff --git a/core/config_help.json b/core/config_help.json
index 0a6cee5076..db9218d2b3 100644
--- a/core/config_help.json
+++ b/core/config_help.json
@@ -17,7 +17,20 @@
"`{prefix}config set main_category_id 9234932582312` (`9234932582312` is the category ID)"
],
"notes": [
- "If the Modmail category ended up being non-existent/invalid, Modmail will break. To fix this, run `{prefix}setup` again or set `main_category_id` to a valid category."
+ "If the Modmail category ended up being non-existent/invalid, Modmail will break. To fix this, run `{prefix}setup` again or set `main_category_id` to a valid category.",
+ "When the Modmail category is full, new channels will be created in the fallback category.",
+ "See also: `fallback_category_id`."
+ ]
+ },
+ "fallback_category_id": {
+ "default": "`Fallback Modmail` (created when the main category is full)",
+ "description": "This is the category that will hold the threads when the main category is full.\n\nTo change the Fallback category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).",
+ "examples": [
+ "`{prefix}config set fallback_category_id 9234932582312` (`9234932582312` is the category ID)"
+ ],
+ "notes": [
+ "If the Fallback category ended up being non-existent/invalid, Modmail will create a new one. To fix this, set `fallback_category_id` to a valid category.",
+ "See also: `main_category_id`."
]
},
"prefix": {
@@ -222,6 +235,17 @@
"See also: `thread_auto_close_silently`, `thread_auto_close_response`."
]
},
+ "thread_cooldown": {
+ "default": "Never",
+ "description": "Specify the time required for the recipient to wait before allowed to create a new thread.",
+ "examples": [
+ "`{prefix}config set thread_cooldown P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))",
+ "`{prefix}config set thread_cooldown 3 days and 5 hours` (accepted readable time)"
+ ],
+ "notes": [
+ "To disable thread cooldown, do `{prefix}config del thread_cooldown`."
+ ]
+ },
"thread_auto_close_response": {
"default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"",
"description": "This is the message to display when the thread when the thread auto-closes.",
diff --git a/core/models.py b/core/models.py
index 5a1b455f61..e19086b198 100644
--- a/core/models.py
+++ b/core/models.py
@@ -1,13 +1,15 @@
-import _string
import logging
import re
import sys
from enum import IntEnum
+from logging.handlers import RotatingFileHandler
from string import Formatter
import discord
from discord.ext import commands
+import _string
+
try:
from colorama import Fore, Style
except ImportError:
@@ -34,9 +36,7 @@ def __init__(self, msg, *args):
@property
def embed(self):
# Single reference of Color.red()
- return discord.Embed(
- title="Error", description=self.msg, color=discord.Color.red()
- )
+ return discord.Embed(title="Error", description=self.msg, color=discord.Color.red())
class ModmailLogger(logging.Logger):
@@ -82,10 +82,7 @@ def line(self, level="info"):
if self.isEnabledFor(level):
self._log(
level,
- Fore.BLACK
- + Style.BRIGHT
- + "-------------------------"
- + Style.RESET_ALL,
+ Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL,
[],
)
@@ -97,8 +94,7 @@ def line(self, level="info"):
ch = logging.StreamHandler(stream=sys.stdout)
ch.setLevel(log_level)
formatter = logging.Formatter(
- "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s",
- datefmt="%m/%d/%y %H:%M:%S",
+ "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S"
)
ch.setFormatter(formatter)
@@ -125,7 +121,7 @@ def format(self, record):
def configure_logging(name, level=None):
global ch_debug, log_level
- ch_debug = logging.FileHandler(name, mode="a+")
+ ch_debug = RotatingFileHandler(name, mode="a+", maxBytes=48000, backupCount=1)
formatter_debug = FileFormatter(
"%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s",
diff --git a/core/paginator.py b/core/paginator.py
index 835337dbed..7ba1c98b60 100644
--- a/core/paginator.py
+++ b/core/paginator.py
@@ -73,7 +73,7 @@ async def create_base(self, item) -> None:
for reaction in self.reaction_map:
if len(self.pages) == 2 and reaction in "⏮⏭":
continue
- await self.base.add_reaction(reaction)
+ await self.ctx.bot.add_reaction(self.base, reaction)
async def _create_base(self, item) -> None:
raise NotImplementedError
@@ -177,10 +177,7 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]:
self.running = False
sent_emoji, _ = await self.ctx.bot.retrieve_emoji()
- try:
- await self.ctx.message.add_reaction(sent_emoji)
- except (HTTPException, InvalidArgument):
- pass
+ await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji)
if delete:
return await self.base.delete()
@@ -214,30 +211,28 @@ def __init__(self, ctx: commands.Context, *embeds, **options):
footer_text = footer_text + " • " + embed.footer.text
embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url)
- def add_page(self, embed: Embed) -> None:
- if isinstance(embed, Embed):
- self.pages.append(embed)
+ def add_page(self, item: Embed) -> None:
+ if isinstance(item, Embed):
+ self.pages.append(item)
else:
raise TypeError("Page must be an Embed object.")
- async def _create_base(self, embed: Embed) -> None:
- self.base = await self.destination.send(embed=embed)
+ async def _create_base(self, item: Embed) -> None:
+ self.base = await self.destination.send(embed=item)
async def _show_page(self, page):
await self.base.edit(embed=page)
class MessagePaginatorSession(PaginatorSession):
- def __init__(
- self, ctx: commands.Context, *messages, embed: Embed = None, **options
- ):
+ def __init__(self, ctx: commands.Context, *messages, embed: Embed = None, **options):
self.embed = embed
self.footer_text = self.embed.footer.text if embed is not None else None
super().__init__(ctx, *messages, **options)
- def add_page(self, msg: str) -> None:
- if isinstance(msg, str):
- self.pages.append(msg)
+ def add_page(self, item: str) -> None:
+ if isinstance(item, str):
+ self.pages.append(item)
else:
raise TypeError("Page must be a str object.")
@@ -248,9 +243,9 @@ def _set_footer(self):
footer_text = footer_text + " • " + self.footer_text
self.embed.set_footer(text=footer_text, icon_url=self.embed.footer.icon_url)
- async def _create_base(self, msg: str) -> None:
+ async def _create_base(self, item: str) -> None:
self._set_footer()
- self.base = await self.ctx.send(content=msg, embed=self.embed)
+ self.base = await self.ctx.send(content=item, embed=self.embed)
async def _show_page(self, page) -> None:
self._set_footer()
diff --git a/core/thread.py b/core/thread.py
index 03cefcae3d..0926a0acd0 100644
--- a/core/thread.py
+++ b/core/thread.py
@@ -1,6 +1,5 @@
import asyncio
import re
-import string
import typing
from datetime import datetime, timedelta
from types import SimpleNamespace
@@ -12,7 +11,7 @@
from core.models import getLogger
from core.time import human_timedelta
-from core.utils import is_image_url, days, match_user_id, truncate
+from core.utils import is_image_url, days, match_user_id, truncate, format_channel_name
logger = getLogger(__name__)
@@ -43,14 +42,15 @@ def __init__(
self.auto_close_task = None
def __repr__(self):
- return (
- f'Thread(recipient="{self.recipient or self.id}", '
- f"channel={self.channel.id})"
- )
+ return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id})'
async def wait_until_ready(self) -> None:
"""Blocks execution until the thread is fully set up."""
- await self._ready_event.wait()
+ # timeout after 3 seconds
+ try:
+ await asyncio.wait_for(self._ready_event.wait(), timeout=3)
+ except asyncio.TimeoutError:
+ return
@property
def id(self) -> int:
@@ -85,9 +85,7 @@ async def setup(self, *, creator=None, category=None):
# in case it creates a channel outside of category
overwrites = {
- self.bot.modmail_guild.default_role: discord.PermissionOverwrite(
- read_messages=False
- )
+ self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False)
}
category = category or self.bot.main_category
@@ -97,12 +95,12 @@ async def setup(self, *, creator=None, category=None):
try:
channel = await self.bot.modmail_guild.create_text_channel(
- name=self.manager.format_channel_name(recipient),
+ name=format_channel_name(recipient, self.bot.modmail_guild),
category=category,
overwrites=overwrites,
reason="Creating a thread channel.",
)
- except discord.HTTPException as e: # Failed to create due to 50 channel limit.
+ except discord.HTTPException as e: # Failed to create due to missing perms.
logger.critical("An error occurred while creating a thread.", exc_info=True)
self.manager.cache.pop(self.id)
@@ -125,12 +123,13 @@ async def setup(self, *, creator=None, category=None):
log_count = sum(1 for log in log_data if not log["open"])
except Exception:
- logger.error(
- "An error occurred while posting logs to the database.", exc_info=True
- )
+ logger.error("An error occurred while posting logs to the database.", exc_info=True)
log_url = log_count = None
# ensure core functionality still works
+ await channel.edit(topic=f"User ID: {recipient.id}")
+ self.ready = True
+
if creator:
mention = None
else:
@@ -146,38 +145,36 @@ async def send_genesis_message():
self.genesis_message = msg
except Exception:
logger.error("Failed unexpectedly:", exc_info=True)
- finally:
- self.ready = True
- await channel.edit(topic=f"User ID: {recipient.id}")
- self.bot.loop.create_task(send_genesis_message())
+ async def send_recipient_genesis_message():
+ # Once thread is ready, tell the recipient.
+ thread_creation_response = self.bot.config["thread_creation_response"]
- # Once thread is ready, tell the recipient.
- thread_creation_response = self.bot.config["thread_creation_response"]
+ embed = discord.Embed(
+ color=self.bot.mod_color,
+ description=thread_creation_response,
+ timestamp=channel.created_at,
+ )
- embed = discord.Embed(
- color=self.bot.mod_color,
- description=thread_creation_response,
- timestamp=channel.created_at,
- )
+ recipient_thread_close = self.bot.config.get("recipient_thread_close")
- recipient_thread_close = self.bot.config.get("recipient_thread_close")
+ if recipient_thread_close:
+ footer = self.bot.config["thread_self_closable_creation_footer"]
+ else:
+ footer = self.bot.config["thread_creation_footer"]
- if recipient_thread_close:
- footer = self.bot.config["thread_self_closable_creation_footer"]
- else:
- footer = self.bot.config["thread_creation_footer"]
+ embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url)
+ embed.title = self.bot.config["thread_creation_title"]
- embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url)
- embed.title = self.bot.config["thread_creation_title"]
+ if creator is None:
+ msg = await recipient.send(embed=embed)
- if creator is None:
- msg = await recipient.send(embed=embed)
+ if recipient_thread_close:
+ close_emoji = self.bot.config["close_emoji"]
+ close_emoji = await self.bot.convert_emoji(close_emoji)
+ await self.bot.add_reaction(msg, close_emoji)
- if recipient_thread_close:
- close_emoji = self.bot.config["close_emoji"]
- close_emoji = await self.bot.convert_emoji(close_emoji)
- await msg.add_reaction(close_emoji)
+ await asyncio.gather(send_genesis_message(), send_recipient_genesis_message())
def _format_info_embed(self, user, log_url, log_count, color):
"""Get information about a member of a server
@@ -195,7 +192,8 @@ def _format_info_embed(self, user, log_url, log_count, color):
roles = []
for role in sorted(member.roles, key=lambda r: r.position):
- if role.name == "@everyone":
+ if role.is_default():
+ # @everyone
continue
fmt = role.name if sep_server else role.mention
@@ -211,9 +209,7 @@ def _format_info_embed(self, user, log_url, log_count, color):
created = str((time - user.created_at).days)
embed = discord.Embed(
- color=color,
- description=f"{user.mention} was created {days(created)}",
- timestamp=time,
+ color=color, description=f"{user.mention} was created {days(created)}", timestamp=time
)
# if not role_names:
@@ -238,7 +234,7 @@ def _format_info_embed(self, user, log_url, log_count, color):
embed.set_footer(text=f"{footer} • (not in main server)")
if log_count is not None:
- # embed.add_field(name='Past logs', value=f'{log_count}')
+ # embed.add_field(name="Past logs", value=f"{log_count}")
thread = "thread" if log_count == 1 else "threads"
embed.description += f" with **{log_count or 'no'}** past {thread}."
else:
@@ -304,8 +300,8 @@ async def _close(
):
try:
self.manager.cache.pop(self.id)
- except KeyError:
- logger.warning("Thread already closed.", exc_info=True)
+ except KeyError as e:
+ logger.error("Thread already closed: %s.", e)
return
await self.cancel_closure(all=True)
@@ -365,7 +361,7 @@ async def _close(
embed.title = user
event = "Thread Closed as Scheduled" if scheduled else "Thread Closed"
- # embed.set_author(name=f'Event: {event}', url=log_url)
+ # embed.set_author(name=f"Event: {event}", url=log_url)
embed.set_footer(text=f"{event} by {_closer}")
embed.timestamp = datetime.utcnow()
@@ -389,10 +385,7 @@ async def _close(
message = self.bot.config["thread_close_response"]
message = self.bot.formatter.format(
- message,
- closer=closer,
- loglink=log_url,
- logkey=log_data["key"] if log_data else None,
+ message, closer=closer, loglink=log_url, logkey=log_data["key"] if log_data else None
)
embed.description = message
@@ -407,11 +400,7 @@ async def _close(
await asyncio.gather(*tasks)
- async def cancel_closure(
- self,
- auto_close: bool = False,
- all: bool = False, # pylint: disable=redefined-builtin
- ) -> None:
+ async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None:
if self.close_task is not None and (not auto_close or all):
self.close_task.cancel()
self.close_task = None
@@ -423,36 +412,15 @@ async def cancel_closure(
if to_update is not None:
await self.bot.config.update()
- @staticmethod
- async def _find_thread_message(channel, message_id):
- async for msg in channel.history():
- if not msg.embeds:
- continue
- embed = msg.embeds[0]
- if embed and embed.author and embed.author.url:
- if str(message_id) == str(embed.author.url).split("/")[-1]:
- return msg
-
- async def _fetch_timeout(
- self
- ) -> typing.Union[None, isodate.duration.Duration, timedelta]:
- """
- This grabs the timeout value for closing threads automatically
- from the ConfigManager and parses it for use internally.
- :returns: None if no timeout is set.
- """
- timeout = self.bot.config.get("thread_auto_close")
- return timeout
-
async def _restart_close_timer(self):
"""
This will create or restart a timer to automatically close this
thread.
"""
- timeout = await self._fetch_timeout()
+ timeout = self.bot.config.get("thread_auto_close")
# Exit if timeout was not set
- if not timeout:
+ if timeout == isodate.Duration():
return
# Set timeout seconds
@@ -481,44 +449,158 @@ async def _restart_close_timer(self):
)
await self.close(
- closer=self.bot.user,
- after=int(seconds),
- message=close_message,
- auto_close=True,
+ closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True
)
- async def edit_message(self, message_id: int, message: str) -> None:
- recipient_msg, channel_msg = await asyncio.gather(
- self._find_thread_message(self.recipient, message_id),
- self._find_thread_message(self.channel, message_id),
- )
+ async def find_linked_messages(
+ self,
+ message_id: typing.Optional[int] = None,
+ either_direction: bool = False,
+ message1: discord.Message = None,
+ ) -> typing.Tuple[discord.Message, typing.Optional[discord.Message]]:
+ if message1 is not None:
+ if not message1.embeds or not message1.embeds[0].author.url:
+ raise ValueError("Malformed thread message.")
+
+ elif message_id is not None:
+ try:
+ message1 = await self.channel.fetch_message(message_id)
+ except discord.NotFound:
+ raise ValueError("Thread message not found.")
- channel_embed = channel_msg.embeds[0]
- channel_embed.description = message
+ if not (
+ message1.embeds and message1.embeds[0].author.url and message1.embeds[0].color
+ ):
+ raise ValueError("Thread message not found.")
- tasks = [channel_msg.edit(embed=channel_embed)]
+ if message1.embeds[0].color.value == self.bot.main_color and message1.embeds[
+ 0
+ ].author.name.startswith("Note"):
+ return message1, None
- if recipient_msg:
- recipient_embed = recipient_msg.embeds[0]
- recipient_embed.description = message
- tasks.append(recipient_msg.edit(embed=recipient_embed))
+ if message1.embeds[0].color.value != self.bot.mod_color and not (
+ either_direction and message1.embeds[0].color.value == self.bot.recipient_color
+ ):
+ raise ValueError("Thread message not found.")
+ else:
+ async for message1 in self.channel.history():
+ if (
+ message1.embeds
+ and message1.embeds[0].author.url
+ and message1.embeds[0].color
+ and (
+ message1.embeds[0].color.value == self.bot.mod_color
+ or (
+ either_direction
+ and message1.embeds[0].color.value == self.bot.recipient_color
+ )
+ )
+ ):
+ break
+ else:
+ raise ValueError("Thread message not found.")
+
+ try:
+ joint_id = int(message1.embeds[0].author.url.split("#")[-1])
+ except ValueError:
+ raise ValueError("Malformed thread message.")
+
+ async for msg in self.recipient.history():
+ if either_direction:
+ if msg.id == joint_id:
+ return message1, msg
+
+ if not (msg.embeds and msg.embeds[0].author.url):
+ continue
+ try:
+ if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id:
+ return message1, msg
+ except ValueError:
+ raise ValueError("DM message not found.")
+ raise ValueError("DM message not found.")
+
+ async def edit_message(self, message_id: typing.Optional[int], message: str) -> None:
+ try:
+ message1, message2 = await self.find_linked_messages(message_id)
+ except ValueError:
+ logger.warning("Failed to edit message.", exc_info=True)
+ raise
+
+ embed1 = message1.embeds[0]
+ embed1.description = message
+
+ tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)]
+ if message2 is not None:
+ embed2 = message2.embeds[0]
+ embed2.description = message
+ tasks += [message2.edit(embed=embed2)]
await asyncio.gather(*tasks)
- async def delete_message(self, message_id):
- msg_recipient, msg_channel = await asyncio.gather(
- self._find_thread_message(self.recipient, message_id),
- self._find_thread_message(self.channel, message_id),
+ async def delete_message(self, message: typing.Union[int, discord.Message] = None) -> None:
+ try:
+ if isinstance(message, discord.Message):
+ message1, message2 = await self.find_linked_messages(message1=message)
+ else:
+ message1, message2 = await self.find_linked_messages(message)
+ except ValueError as e:
+ logger.warning("Failed to delete message: %s.", e)
+ raise
+
+ tasks = []
+ if not isinstance(message, discord.Message):
+ tasks += [message1.delete()]
+ if message2 is not None:
+ tasks += [message2.delete()]
+ if tasks:
+ await asyncio.gather(*tasks)
+
+ async def find_linked_message_from_dm(self, message, either_direction=False):
+ if either_direction and message.embeds:
+ compare_url = message.embeds[0].author.url
+ else:
+ compare_url = None
+
+ async for linked_message in self.channel.history():
+ if not linked_message.embeds:
+ continue
+ url = linked_message.embeds[0].author.url
+ if not url:
+ continue
+ if url == compare_url:
+ return linked_message
+
+ msg_id = url.split("#")[-1]
+ try:
+ if int(msg_id) == message.id:
+ return linked_message
+ except ValueError:
+ raise ValueError("Malformed dm channel message.")
+ raise ValueError("DM channel message not found.")
+
+ async def edit_dm_message(self, message: discord.Message, content: str) -> None:
+ try:
+ linked_message = await self.find_linked_message_from_dm(message)
+ except ValueError:
+ logger.warning("Failed to edit message.", exc_info=True)
+ raise
+ embed = linked_message.embeds[0]
+ embed.add_field(name="**Edited, former message:**", value=embed.description)
+ embed.description = content
+ await asyncio.gather(
+ self.bot.api.edit_message(message.id, content), linked_message.edit(embed=embed)
)
- await asyncio.gather(msg_recipient.delete(), msg_channel.delete())
async def note(self, message: discord.Message) -> None:
if not message.content and not message.attachments:
raise MissingRequiredArgument(SimpleNamespace(name="msg"))
- _, msg = await asyncio.gather(
- self.bot.api.append_log(message, self.channel.id, type_="system"),
- self.send(message, self.channel, note=True),
+ msg = await self.send(message, self.channel, note=True)
+
+ self.bot.loop.create_task(
+ self.bot.api.append_log(
+ message, message_id=msg.id, channel_id=self.channel.id, type_="system"
+ )
)
return msg
@@ -556,19 +638,15 @@ async def reply(self, message: discord.Message, anonymous: bool = False) -> None
)
else:
# Send the same thing in the thread channel.
- tasks.append(
- self.send(
- message,
- destination=self.channel,
- from_mod=True,
- anonymous=anonymous,
- )
+ msg = await self.send(
+ message, destination=self.channel, from_mod=True, anonymous=anonymous
)
tasks.append(
self.bot.api.append_log(
message,
- self.channel.id,
+ message_id=msg.id,
+ channel_id=self.channel.id,
type_="anonymous" if anonymous else "thread_message",
)
)
@@ -618,7 +696,7 @@ async def send(
await self.wait_until_ready()
if not from_mod and not note:
- self.bot.loop.create_task(self.bot.api.append_log(message, self.channel.id))
+ self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id))
destination = destination or self.channel
@@ -626,20 +704,14 @@ async def send(
embed = discord.Embed(description=message.content, timestamp=message.created_at)
- system_avatar_url = (
- "https://discordapp.com/assets/f78426a064bc9dd24847519259bc42af.png"
- )
+ system_avatar_url = "https://discordapp.com/assets/f78426a064bc9dd24847519259bc42af.png"
if not note:
- if (
- anonymous
- and from_mod
- and not isinstance(destination, discord.TextChannel)
- ):
+ if anonymous and from_mod and not isinstance(destination, discord.TextChannel):
# Anonymously sending to the user.
tag = self.bot.config["mod_tag"]
if tag is None:
- tag = str(message.author.top_role)
+ tag = str(author.top_role)
name = self.bot.config["anon_username"]
if name is None:
name = tag
@@ -649,7 +721,7 @@ async def send(
embed.set_author(
name=name,
icon_url=avatar_url,
- url=f"https://discordapp.com/channels/{self.bot.guild.id}",
+ url=f"https://discordapp.com/channels/{self.bot.guild.id}#{message.id}",
)
else:
# Normal message
@@ -658,14 +730,14 @@ async def send(
embed.set_author(
name=name,
icon_url=avatar_url,
- url=f"https://discordapp.com/users/{author.id}",
+ url=f"https://discordapp.com/users/{author.id}#{message.id}",
)
else:
# Special note messages
embed.set_author(
name=f"Note ({author.name})",
icon_url=system_avatar_url,
- url=f"https://discordapp.com/users/{author.id}",
+ url=f"https://discordapp.com/users/{author.id}#{message.id}",
)
ext = [(a.url, a.filename) for a in message.attachments]
@@ -694,9 +766,7 @@ async def send(
additional_count = 1
for url, filename in images:
- if not prioritize_uploads or (
- is_image_url(url) and not embedded_image and filename
- ):
+ if not prioritize_uploads or (is_image_url(url) and not embedded_image and filename):
embed.set_image(url=url)
if filename:
embed.add_field(name="Image", value=f"[{filename}]({url})")
@@ -713,9 +783,7 @@ async def send(
img_embed.set_image(url=url)
img_embed.title = filename
img_embed.url = url
- img_embed.set_footer(
- text=f"Additional Image Upload ({additional_count})"
- )
+ img_embed.set_footer(text=f"Additional Image Upload ({additional_count})")
img_embed.timestamp = message.created_at
additional_images.append(destination.send(embed=img_embed))
additional_count += 1
@@ -753,22 +821,16 @@ async def send(
try:
await message.delete()
except Exception as e:
- logger.warning("Cannot delete message: %s.", str(e))
-
- if (
- from_mod
- and self.bot.config["dm_disabled"] == 2
- and destination != self.channel
- ):
- logger.info(
- "Sending a message to %s when DM disabled is set.", self.recipient
- )
+ logger.warning("Cannot delete message: %s.", e)
+
+ if from_mod and self.bot.config["dm_disabled"] == 2 and destination != self.channel:
+ logger.info("Sending a message to %s when DM disabled is set.", self.recipient)
try:
await destination.trigger_typing()
except discord.NotFound:
- logger.warning("Channel not found.", exc_info=True)
- return
+ logger.warning("Channel not found.")
+ raise
if not from_mod and not note:
mentions = self.get_notifications()
@@ -807,11 +869,6 @@ def __init__(self, bot):
async def populate_cache(self) -> None:
for channel in self.bot.modmail_guild.text_channels:
- if (
- channel.category != self.bot.main_category
- and not self.bot.using_multiple_server_setup
- ):
- continue
await self.find(channel=channel)
def __len__(self):
@@ -835,29 +892,28 @@ async def find(
thread = self._find_from_channel(channel)
if thread is None:
user_id, thread = next(
- ((k, v) for k, v in self.cache.items() if v.channel == channel),
- (-1, None),
+ ((k, v) for k, v in self.cache.items() if v.channel == channel), (-1, None)
)
if thread is not None:
logger.debug("Found thread with tempered ID.")
await channel.edit(topic=f"User ID: {user_id}")
return thread
- thread = None
-
if recipient:
recipient_id = recipient.id
- try:
- thread = self.cache[recipient_id]
+ thread = self.cache.get(recipient_id)
+ if thread is not None:
+ await thread.wait_until_ready()
if not thread.channel or not self.bot.get_channel(thread.channel.id):
+ logger.warning(
+ "Found existing thread for %s but the channel is invalid.", recipient_id
+ )
self.bot.loop.create_task(
- thread.close(
- closer=self.bot.user, silent=True, delete_channel=False
- )
+ thread.close(closer=self.bot.user, silent=True, delete_channel=False)
)
thread = None
- except KeyError:
+ else:
channel = discord.utils.get(
self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}"
)
@@ -879,21 +935,22 @@ def _find_from_channel(self, channel):
if channel.topic:
user_id = match_user_id(channel.topic)
- if user_id != -1:
- if user_id in self.cache:
- return self.cache[user_id]
+ if user_id == -1:
+ return None
- recipient = self.bot.get_user(user_id)
- if recipient is None:
- self.cache[user_id] = thread = Thread(self, user_id, channel)
- else:
- self.cache[user_id] = thread = Thread(self, recipient, channel)
- thread.ready = True
+ if user_id in self.cache:
+ return self.cache[user_id]
- return thread
- return None
+ recipient = self.bot.get_user(user_id)
+ if recipient is None:
+ self.cache[user_id] = thread = Thread(self, user_id, channel)
+ else:
+ self.cache[user_id] = thread = Thread(self, recipient, channel)
+ thread.ready = True
- def create(
+ return thread
+
+ async def create(
self,
recipient: typing.Union[discord.Member, discord.User],
*,
@@ -901,28 +958,39 @@ def create(
category: discord.CategoryChannel = None,
) -> Thread:
"""Creates a Modmail thread"""
- # create thread immediately so messages can be processed
+
+ # checks for existing thread in cache
+ thread = self.cache.get(recipient.id)
+ if thread:
+ await thread.wait_until_ready()
+ if thread.channel and self.bot.get_channel(thread.channel.id):
+ logger.warning("Found an existing thread for %s, abort creating.", recipient)
+ return thread
+ logger.warning("Found an existing thread for %s, closing previous thread.", recipient)
+ self.bot.loop.create_task(
+ thread.close(closer=self.bot.user, silent=True, delete_channel=False)
+ )
+
thread = Thread(self, recipient)
+
self.cache[recipient.id] = thread
# Schedule thread setup for later
+ cat = self.bot.main_category
+ if category is None and len(cat.channels) == 50:
+ fallback_id = self.bot.config["fallback_category_id"]
+ if fallback_id:
+ fallback = discord.utils.get(cat.guild.categories, id=int(fallback_id))
+ if fallback and len(fallback.channels) != 50:
+ category = fallback
+
+ if not category:
+ category = await cat.clone(name="Fallback Modmail")
+ self.bot.config.set("fallback_category_id", category.id)
+ await self.bot.config.update()
+
self.bot.loop.create_task(thread.setup(creator=creator, category=category))
return thread
async def find_or_create(self, recipient) -> Thread:
- return await self.find(recipient=recipient) or self.create(recipient)
-
- def format_channel_name(self, author):
- """Sanitises a username for use with text channel names"""
- name = author.name.lower()
- new_name = (
- "".join(l for l in name if l not in string.punctuation and l.isprintable())
- or "null"
- )
- new_name += f"-{author.discriminator}"
-
- counter = 1
- while new_name in [c.name for c in self.bot.modmail_guild.text_channels]:
- new_name += f"-{counter}" # two channels with same name
-
- return new_name
+ return await self.find(recipient=recipient) or await self.create(recipient)
diff --git a/core/time.py b/core/time.py
index ba27fa021c..331e26349f 100644
--- a/core/time.py
+++ b/core/time.py
@@ -58,10 +58,7 @@ def __init__(self, argument):
if not status.hasTime:
# replace it with the current time
dt = dt.replace(
- hour=now.hour,
- minute=now.minute,
- second=now.second,
- microsecond=now.microsecond,
+ hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond
)
self.dt = dt
@@ -87,32 +84,23 @@ def __init__(self, argument):
raise BadArgument("The time is in the past.")
-class UserFriendlyTime(Converter):
+class UserFriendlyTimeSync(Converter):
"""That way quotes aren't absolutely necessary."""
- def __init__(self, converter: Converter = None):
- if isinstance(converter, type) and issubclass(converter, Converter):
- converter = converter()
-
- if converter is not None and not isinstance(converter, Converter):
- raise TypeError("commands.Converter subclass necessary.")
+ def __init__(self):
self.raw: str = None
self.dt: datetime = None
self.arg = None
self.now: datetime = None
- self.converter = converter
- async def check_constraints(self, ctx, now, remaining):
+ def check_constraints(self, now, remaining):
if self.dt < now:
raise BadArgument("This time is in the past.")
- if self.converter is not None:
- self.arg = await self.converter.convert(ctx, remaining)
- else:
- self.arg = remaining
+ self.arg = remaining
return self
- async def convert(self, ctx, argument):
+ def convert(self, ctx, argument):
self.raw = argument
remaining = ""
try:
@@ -125,25 +113,20 @@ async def convert(self, ctx, argument):
data = {k: int(v) for k, v in match.groupdict(default="0").items()}
remaining = argument[match.end() :].strip()
self.dt = self.now + relativedelta(**data)
- return await self.check_constraints(ctx, self.now, remaining)
+ return self.check_constraints(self.now, remaining)
# apparently nlp does not like "from now"
# it likes "from x" in other cases though
# so let me handle the 'now' case
if argument.endswith(" from now"):
argument = argument[:-9].strip()
- # handles "for xxx hours"
- if argument.startswith("for "):
- argument = argument[4:].strip()
-
- if argument[0:2] == "me":
- # starts with "me to", "me in", or "me at "
- if argument[0:6] in ("me to ", "me in ", "me at "):
- argument = argument[6:]
+ # handles "in xxx hours"
+ if argument.startswith("in "):
+ argument = argument[3:].strip()
elements = calendar.nlp(argument, sourceTime=self.now)
if elements is None or not elements:
- return await self.check_constraints(ctx, self.now, argument)
+ return self.check_constraints(self.now, argument)
# handle the following cases:
# "date time" foo
@@ -154,7 +137,7 @@ async def convert(self, ctx, argument):
dt, status, begin, end, _ = elements[0]
if not status.hasDateOrTime:
- return await self.check_constraints(ctx, self.now, argument)
+ return self.check_constraints(self.now, argument)
if begin not in (0, 1) and end != len(argument):
raise BadArgument(
@@ -193,12 +176,17 @@ async def convert(self, ctx, argument):
elif len(argument) == end:
remaining = argument[:begin].strip()
- return await self.check_constraints(ctx, self.now, remaining)
+ return self.check_constraints(self.now, remaining)
except Exception:
logger.exception("Something went wrong while parsing the time.")
raise
+class UserFriendlyTime(UserFriendlyTimeSync):
+ async def convert(self, ctx, argument):
+ return super().convert(ctx, argument)
+
+
def human_timedelta(dt, *, source=None):
now = source or datetime.utcnow()
if dt > now:
diff --git a/core/utils.py b/core/utils.py
index fd85f0d5b0..a4f14182a5 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -1,20 +1,49 @@
+import base64
import functools
import re
-import shlex
+import string
import typing
from difflib import get_close_matches
from distutils.util import strtobool as _stb # pylint: disable=import-error
-from itertools import takewhile
+from itertools import takewhile, zip_longest
from urllib import parse
import discord
from discord.ext import commands
+__all__ = [
+ "strtobool",
+ "User",
+ "truncate",
+ "format_preview",
+ "is_image_url",
+ "parse_image_url",
+ "human_join",
+ "days",
+ "cleanup_code",
+ "match_user_id",
+ "create_not_found_embed",
+ "parse_alias",
+ "normalize_alias",
+ "format_description",
+ "trigger_typing",
+ "escape_code_block",
+ "format_channel_name",
+]
+
def strtobool(val):
if isinstance(val, bool):
return val
- return _stb(str(val))
+ try:
+ return _stb(str(val))
+ except ValueError:
+ val = val.lower()
+ if val == "enable":
+ return 1
+ if val == "disable":
+ return 0
+ raise
class User(commands.IDConverter):
@@ -175,6 +204,9 @@ def cleanup_code(content: str) -> str:
return content.strip("` \n")
+TOPIC_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE)
+
+
def match_user_id(text: str) -> int:
"""
Matches a user ID in the format of "User ID: 12345".
@@ -189,7 +221,7 @@ def match_user_id(text: str) -> int:
int
The user ID if found. Otherwise, -1.
"""
- match = re.search(r"\bUser ID: (\d{17,21})\b", text)
+ match = TOPIC_REGEX.search(text)
if match is not None:
return int(match.group(1))
return -1
@@ -198,8 +230,7 @@ def match_user_id(text: str) -> int:
def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed:
# Single reference of Color.red()
embed = discord.Embed(
- color=discord.Color.red(),
- description=f"**{name.capitalize()} `{word}` cannot be found.**",
+ color=discord.Color.red(), description=f"**{name.capitalize()} `{word}` cannot be found.**"
)
val = get_close_matches(word, possibilities, n=n, cutoff=cutoff)
if val:
@@ -208,35 +239,47 @@ def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discor
def parse_alias(alias):
- if "&&" not in alias:
- if alias.startswith('"') and alias.endswith('"'):
- return [alias[1:-1]]
- return [alias]
+ def encode_alias(m):
+ return "\x1AU" + base64.b64encode(m.group(1).encode()).decode() + "\x1AU"
- buffer = ""
- cmd = []
- try:
- for token in shlex.shlex(alias, punctuation_chars="&"):
- if token != "&&":
- buffer += " " + token
- continue
-
- buffer = buffer.strip()
- if buffer.startswith('"') and buffer.endswith('"'):
- buffer = buffer[1:-1]
- cmd += [buffer]
- buffer = ""
- except ValueError:
- return []
+ def decode_alias(m):
+ return base64.b64decode(m.group(1).encode()).decode()
+
+ alias = re.sub(
+ r"(?:(?<=^)(?:\s*(?=3.5.3"
+version = "3.5.4"
+
+[package.dependencies]
+async-timeout = ">=3.0,<4.0"
+attrs = ">=17.3.0"
+chardet = ">=2.0,<4.0"
+multidict = ">=4.0,<5.0"
+yarl = ">=1.0,<2.0"
+
+[[package]]
+category = "dev"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+name = "appdirs"
+optional = false
+python-versions = "*"
+version = "1.4.3"
+
+[[package]]
+category = "dev"
+description = "An abstract syntax tree for Python with inference support."
+name = "astroid"
+optional = false
+python-versions = ">=3.5.*"
+version = "2.3.3"
+
+[package.dependencies]
+lazy-object-proxy = ">=1.4.0,<1.5.0"
+six = ">=1.12,<2.0"
+wrapt = ">=1.11.0,<1.12.0"
+
+[package.dependencies.typed-ast]
+python = "<3.8"
+version = ">=1.4.0,<1.5"
+
+[[package]]
+category = "main"
+description = "Timeout context manager for asyncio programs"
+name = "async-timeout"
+optional = false
+python-versions = ">=3.5.3"
+version = "3.0.1"
+
+[[package]]
+category = "main"
+description = "Classes Without Boilerplate"
+name = "attrs"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "19.3.0"
+
+[[package]]
+category = "dev"
+description = "Security oriented static analyser for python code."
+name = "bandit"
+optional = false
+python-versions = "*"
+version = "1.6.2"
+
+[package.dependencies]
+GitPython = ">=1.0.1"
+PyYAML = ">=3.13"
+colorama = ">=0.3.9"
+six = ">=1.10.0"
+stevedore = ">=1.20.0"
+
+[[package]]
+category = "dev"
+description = "The uncompromising code formatter."
+name = "black"
+optional = false
+python-versions = ">=3.6"
+version = "19.3b0"
+
+[package.dependencies]
+appdirs = "*"
+attrs = ">=18.1.0"
+click = ">=6.5"
+toml = ">=0.9.4"
+
+[[package]]
+category = "main"
+description = "Universal encoding detector for Python 2 and 3"
+name = "chardet"
+optional = false
+python-versions = "*"
+version = "3.0.4"
+
+[[package]]
+category = "dev"
+description = "Composable command line interface toolkit"
+name = "click"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "7.0"
+
+[[package]]
+category = "main"
+description = "Cross-platform colored terminal text."
+name = "colorama"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+version = "0.4.3"
+
+[[package]]
+category = "main"
+description = "A python wrapper for the Discord API"
+name = "discord.py"
+optional = false
+python-versions = ">=3.5.3"
+version = "1.2.5"
+
+[package.dependencies]
+aiohttp = ">=3.3.0,<3.6.0"
+websockets = ">=6.0,<7.0"
+
+[[package]]
+category = "main"
+description = "DNS toolkit"
+name = "dnspython"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "1.16.0"
+
+[[package]]
+category = "main"
+description = "Emoji for Python"
+name = "emoji"
+optional = false
+python-versions = "*"
+version = "0.5.4"
+
+[[package]]
+category = "main"
+description = "Backport of the concurrent.futures package from Python 3.2"
+name = "futures"
+optional = true
+python-versions = "*"
+version = "3.1.1"
+
+[[package]]
+category = "dev"
+description = "Git Object Database"
+name = "gitdb2"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "2.0.6"
+
+[package.dependencies]
+smmap2 = ">=2.0.0"
+
+[[package]]
+category = "dev"
+description = "Python Git Library"
+name = "gitpython"
+optional = false
+python-versions = ">=3.0, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "3.0.5"
+
+[package.dependencies]
+gitdb2 = ">=2.0.0"
+
+[[package]]
+category = "main"
+description = "Internationalized Domain Names in Applications (IDNA)"
+name = "idna"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "2.8"
+
+[[package]]
+category = "main"
+description = "An ISO 8601 date/time/duration parser and formatter"
+name = "isodate"
+optional = false
+python-versions = "*"
+version = "0.6.0"
+
+[package.dependencies]
+six = "*"
+
+[[package]]
+category = "dev"
+description = "A Python utility / library to sort Python imports."
+name = "isort"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "4.3.21"
+
+[[package]]
+category = "dev"
+description = "A fast and thorough lazy object proxy."
+name = "lazy-object-proxy"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "1.4.3"
+
+[[package]]
+category = "dev"
+description = "McCabe checker, plugin for flake8"
+name = "mccabe"
+optional = false
+python-versions = "*"
+version = "0.6.1"
+
+[[package]]
+category = "main"
+description = "Non-blocking MongoDB driver for Tornado or asyncio"
+name = "motor"
+optional = true
+python-versions = "*"
+version = "2.1.0"
+
+[package.dependencies]
+futures = "*"
+pymongo = ">=3.10,<4"
+
+[[package]]
+category = "main"
+description = "multidict implementation"
+name = "multidict"
+optional = false
+python-versions = ">=3.5"
+version = "4.7.1"
+
+[[package]]
+category = "main"
+description = "Convert data to their natural (human-readable) format"
+name = "natural"
+optional = false
+python-versions = "*"
+version = "0.2.0"
+
+[package.dependencies]
+six = "*"
+
+[[package]]
+category = "main"
+description = "Parse human-readable date/time text."
+name = "parsedatetime"
+optional = false
+python-versions = "*"
+version = "2.5"
+
+[[package]]
+category = "dev"
+description = "Python Build Reasonableness"
+name = "pbr"
+optional = false
+python-versions = "*"
+version = "5.4.4"
+
+[[package]]
+category = "dev"
+description = "python code static checker"
+name = "pylint"
+optional = false
+python-versions = ">=3.5.*"
+version = "2.4.4"
+
+[package.dependencies]
+astroid = ">=2.3.0,<2.4"
+colorama = "*"
+isort = ">=4.2.5,<5"
+mccabe = ">=0.6,<0.7"
+
+[[package]]
+category = "main"
+description = "Python driver for MongoDB "
+name = "pymongo"
+optional = true
+python-versions = "*"
+version = "3.10.0"
+
+[[package]]
+category = "main"
+description = "Extensions to the standard Python datetime module"
+name = "python-dateutil"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+version = "2.8.1"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+category = "main"
+description = "Add .env support to your django/flask apps in development and deployments"
+name = "python-dotenv"
+optional = false
+python-versions = "*"
+version = "0.10.3"
+
+[[package]]
+category = "dev"
+description = "YAML parser and emitter for Python"
+name = "pyyaml"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "5.2"
+
+[[package]]
+category = "main"
+description = "Python 2 and 3 compatibility utilities"
+name = "six"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*"
+version = "1.13.0"
+
+[[package]]
+category = "dev"
+description = "A pure Python implementation of a sliding window memory map manager"
+name = "smmap2"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+version = "2.0.5"
+
+[[package]]
+category = "dev"
+description = "Manage dynamic plugins for Python applications"
+name = "stevedore"
+optional = false
+python-versions = "*"
+version = "1.31.0"
+
+[package.dependencies]
+pbr = ">=2.0.0,<2.1.0 || >2.1.0"
+six = ">=1.10.0"
+
+[[package]]
+category = "dev"
+description = "Python Library for Tom's Obvious, Minimal Language"
+name = "toml"
+optional = false
+python-versions = "*"
+version = "0.10.0"
+
+[[package]]
+category = "dev"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+marker = "implementation_name == \"cpython\" and python_version < \"3.8\""
+name = "typed-ast"
+optional = false
+python-versions = "*"
+version = "1.4.0"
+
+[[package]]
+category = "main"
+description = "Fast implementation of asyncio event loop on top of libuv"
+name = "uvloop"
+optional = false
+python-versions = "*"
+version = "0.14.0"
+
+[[package]]
+category = "main"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+name = "websockets"
+optional = false
+python-versions = ">=3.4"
+version = "6.0"
+
+[[package]]
+category = "dev"
+description = "Module for decorators, wrappers and monkey patching."
+name = "wrapt"
+optional = false
+python-versions = "*"
+version = "1.11.2"
+
+[[package]]
+category = "main"
+description = "Yet another URL library"
+name = "yarl"
+optional = false
+python-versions = ">=3.5"
+version = "1.4.2"
+
+[package.dependencies]
+idna = ">=2.0"
+multidict = ">=4.0"
+
+[metadata]
+content-hash = "fbe9e329f33e482854cff5bf05b006de9830c2d46bf3874e2ee4f8a8da0b1797"
+python-versions = "^3.7"
+
+[metadata.hashes]
+aiohttp = ["00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", "0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", "09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", "199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", "296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", "368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", "40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", "629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", "6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", "87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", "9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", "9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", "9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", "a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", "a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", "a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", "acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", "b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", "c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", "cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", "d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", "e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889"]
+appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"]
+astroid = ["71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", "840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"]
+async-timeout = ["0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"]
+attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"]
+bandit = ["336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", "41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"]
+black = ["09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", "68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"]
+chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"]
+click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"]
+colorama = ["7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"]
+"discord.py" = ["7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d"]
+dnspython = ["36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", "f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"]
+emoji = ["60652d3a2dcee5b8af8acb097c31776fb6d808027aeb7221830f72cdafefc174"]
+futures = ["3a44f286998ae64f0cc083682fcfec16c406134a81a589a5de445d7bb7c2751b", "51ecb45f0add83c806c68e4b06106f90db260585b25ef2abfcda0bd95c0132fd", "c4884a65654a7c45435063e14ae85280eb1f111d94e542396717ba9828c4337f"]
+gitdb2 = ["1b6df1433567a51a4a9c1a5a0de977aa351a405cc56d7d35f3388bad1f630350", "96bbb507d765a7f51eb802554a9cfe194a174582f772e0d89f4e87288c288b7b"]
+gitpython = ["9c2398ffc3dcb3c40b27324b316f08a4f93ad646d5a6328cafbb871aa79f5e42", "c155c6a2653593ccb300462f6ef533583a913e17857cfef8fc617c246b6dc245"]
+idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"]
+isodate = ["2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8", "aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"]
+isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"]
+lazy-object-proxy = ["0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", "194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", "1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", "4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", "48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", "5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", "59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", "8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", "9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", "9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", "97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", "9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", "a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", "a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", "ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", "cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", "d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", "d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", "eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", "efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"]
+mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"]
+motor = ["599719bc6dcddc3b9ea4e09659fb0073d5fadcc24735999b2902f48cef33f909", "756c587985d166166e644ccd36fb8b586fb987eb42fc0fc60cce9a3d76d809b4", "97b4fc0a00a84df30f866d18693c503eef46c7642f75218a2c44d74d835be38a"]
+multidict = ["09c19f642e055550c9319d5123221b7e07fc79bda58122aa93910e52f2ab2f29", "0c1a5d5f7aa7189f7b83c4411c2af8f1d38d69c4360d5de3eea129c65d8d7ce2", "12f22980e7ed0972a969520fb1e55682c9fca89a68b21b49ec43132e680be812", "258660e9d6b52de1a75097944e12718d3aa59adc611b703361e3577d69167aaf", "3374a23e707848f27b3438500db0c69eca82929337656fce556bd70031fbda74", "503b7fce0054c73aa631cc910a470052df33d599f3401f3b77e54d31182525d5", "6ce55f2c45ffc90239aab625bb1b4864eef33f73ea88487ef968291fbf09fb3f", "725496dde5730f4ad0a627e1a58e2620c1bde0ad1c8080aae15d583eb23344ce", "a3721078beff247d0cd4fb19d915c2c25f90907cf8d6cd49d0413a24915577c6", "ba566518550f81daca649eded8b5c7dd09210a854637c82351410aa15c49324a", "c42362750a51a15dc905cb891658f822ee5021bfbea898c03aa1ed833e2248a5", "cf14aaf2ab067ca10bca0b14d5cbd751dd249e65d371734bc0e47ddd8fafc175", "cf24e15986762f0e75a622eb19cfe39a042e952b8afba3e7408835b9af2be4fb", "d7b6da08538302c5245cd3103f333655ba7f274915f1f5121c4f4b5fbdb3febe", "e27e13b9ff0a914a6b8fb7e4947d4ac6be8e4f61ede17edffabd088817df9e26", "e53b205f8afd76fc6c942ef39e8ee7c519c775d336291d32874082a87802c67c", "ec804fc5f68695d91c24d716020278fcffd50890492690a7e1fef2e741f7172c"]
+natural = ["18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2"]
+parsedatetime = ["3b835fc54e472c17ef447be37458b400e3fefdf14bb1ffdedb5d2c853acf4ba1", "d2e9ddb1e463de871d32088a3f3cea3dc8282b1b2800e081bd0ef86900451667"]
+pbr = ["139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b", "61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"]
+pylint = ["3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", "886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"]
+pymongo = ["0369136c6e79c5edc16aa5de2b48a1b1c1fe5e6f7fc5915a2deaa98bd6e9dad5", "08364e1bea1507c516b18b826ec790cb90433aec2f235033ec5eecfd1011633b", "0af1d2bc8cc9503bf92ec3669a77ec3a6d7938193b583fb867b7e9696eed52e8", "0cfd1aeeb8c0a634646ab3ebeb4ce6828b94b2e33553a69ff7e6c07c250bf201", "15bbd2b5397f7d22498e2f2769fd698a8a247b9cc1a630ee8dabf647fb333480", "1b4a13dff15641e58620524db15d7a323d60572b2b187261c5cb58c36d74778d", "22fbdb908257f9aaaa372a7684f3e094a05ca52eb84f8f381c8b1827c49556fd", "264272fd1c95fc48002ad85d5e41270831777b4180f2500943e45e12b2a3ab43", "3372e98eebbfd05ebf020388003f8a4438bed41e0fef1ef696d2c13633c416c8", "339d24ecdc42745d2dc09b26fda8151988e806ca81134a7bd10513c4031d91e1", "38281855fc3961ba5510fbb503b8d16cc1fcb326e9f7ba0dd096ed4eb72a7084", "4acdd2e16392472bfd49ca49038845c95e5254b5af862b55f7f2cc79aa258886", "4e0c006bc6e98e861b678432e05bf64ba3eb889b6ab7e7bf1ebaecf9f1ba0e58", "4e4284bcbe4b7be1b37f9641509085b715c478e7fbf8f820358362b5dd359379", "4e5e94a5f9823f0bd0c56012a57650bc6772636c29d83d253260c26b908fcfd9", "4e61f30800a40f1770b2ec56bbf5dc0f0e3f7e9250eb05fa4feb9ccb7bbe39ca", "53577cf57ba9d93b58ab41d45250277828ff83c5286dde14f855e4b17ec19976", "681cb31e8631882804a6cc3c8cc8f54a74ff3a82261a78e50f20c5eec05ac855", "6dfc2710f43dd1d66991a0f160d196356732ccc8aa9dbc6875aeba78388fa142", "72218201b13d8169be5736417987e9a0a3b10d4349e40e4db7a6a5ac670c7ef2", "7247fbcdbf7ab574eb70743461b3cfc14d9cfae3f27a9afb6ce14d87f67dd0b5", "72651f4b4adf50201891580506c8cca465d94d38f26ed92abfc56440662c723c", "87b3aaf12ad6a9b5570b12d2a4b8802757cb3588a903aafd3c25f07f9caf07e3", "87c28b7b37617c5a01eb396487f7d3b61a453e1fa0475a175ab87712d6f5d52f", "88efe627b628f36ef53f09abb218d4630f83d8ebde7028689439559475c43dae", "89bfbca22266f12df7fb80092b7c876734751d02b93789580b68957ad4a8bf56", "908a3caf348a672b28b8a06fe7b4a27c2fdcf7f873df671e4027d48bcd7f971f", "9128e7bea85f3a3041306fa14a7aa82a24b47881918500e1b8396dd1c933b5a6", "9737d6d688a15b8d5c0bfa909638b79261e195be817b9f1be79c722bbb23cd76", "98a8305da158f46e99e7e51db49a2f8b5fcdd7683ea7083988ccb9c4450507a6", "99285cd44c756f0900cbdb5fe75f567c0a76a273b7e0467f23cb76f47e60aac0", "9ed568f8026ffeb00ce31e5351e0d09d704cc19a29549ba4da0ac145d2a26fdf", "a006162035032021dfd00a879643dc06863dac275f9210d843278566c719eebc", "a03cb336bc8d25a11ff33b94967478a9775b0d2b23b39e952d9cc6cb93b75d69", "a863ceb67be163060d1099b7e89b6dd83d6dd50077c7ceae31ac844c4c2baff9", "b82628eaf0a16c1f50e1c205fd1dd406d7874037dd84643da89e91b5043b5e82", "bc6446a41fb7eeaf2c808bab961b9bac81db0f5de69eab74eebe1b8b072399f7", "c42d290ed54096355838421cf9d2a56e150cb533304d2439ef1adf612a986eaf", "c43879fe427ea6aa6e84dae9fbdc5aa14428a4cfe613fe0fee2cc004bf3f307c", "c566cbdd1863ba3ccf838656a1403c3c81fdb57cbe3fdd3515be7c9616763d33", "c5b7a0d7e6ca986de32b269b6dbbd5162c1a776ece72936f55decb4d1b197ee9", "ca109fe9f74da4930590bb589eb8fdf80e5d19f5cd9f337815cac9309bbd0a76", "d0260ba68f9bafd8775b2988b5aeace6e69a37593ec256e23e150c808160c05c", "d12d86e771fc3072a0e6bdbf4e417c63fec85ee47cb052ba7ad239403bf5e154", "d2ce33501149b373118fcfec88a292a87ef0b333fb30c7c6aac72fe64700bdf6", "d582ea8496e2a0e124e927a67dca55c8833f0dbfbc2c84aaf0e5949a2dd30c51", "d68b9ab0a900582a345fb279675b0ad4fac07d6a8c2678f12910d55083b7240d", "dbf1fa571db6006907aeaf6473580aaa76041f4f3cd1ff8a0039fd0f40b83f6d", "e032437a7d2b89dab880c79379d88059cee8019da0ff475d924c4ccab52db88f", "e0f5798f3ad60695465a093e3d002f609c41fef3dcb97fcefae355d24d3274cf", "e756355704a2cf91a7f4a649aa0bbf3bbd263018b9ed08f60198c262f4ee24b6", "e824b4b87bd88cbeb25c8babeadbbaaaf06f02bbb95a93462b7c6193a064974e", "ea1171470b52487152ed8bf27713cc2480dc8b0cd58e282a1bff742541efbfb8", "fa19aef44d5ed8f798a8136ff981aedfa508edac3b1bed481eca5dde5f14fd3d", "faf83d20c041637cb277e5fdb59abc217c40ab3202dd87cc95d6fbd9ce5ffd9b", "fceb6ae5a149a42766efb8344b0df6cfb21b55c55f360170abaddb11d43af0f1"]
+python-dateutil = ["73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"]
+python-dotenv = ["debd928b49dbc2bf68040566f55cdb3252458036464806f4094487244e2a4093", "f157d71d5fec9d4bd5f51c82746b6344dffa680ee85217c123f4a0c8117c4544"]
+pyyaml = ["0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", "2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", "35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", "38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", "483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", "4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", "7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", "8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", "c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", "e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", "ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"]
+six = ["1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", "30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"]
+smmap2 = ["0555a7bf4df71d1ef4218e4807bbf9b201f910174e6e08af2e138d4e517b4dde", "29a9ffa0497e7f2be94ca0ed1ca1aa3cd4cf25a1f6b4f5f87f74b46ed91d609a"]
+stevedore = ["01d9f4beecf0fbd070ddb18e5efb10567801ba7ef3ddab0074f54e3cd4e91730", "e0739f9739a681c7a1fda76a102b65295e96a144ccdb552f2ae03c5f0abe8a14"]
+toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"]
+typed-ast = ["1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", "838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"]
+uvloop = ["08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", "123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", "4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", "4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", "afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", "b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", "bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", "e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", "f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"]
+websockets = ["0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", "2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", "5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", "5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", "669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", "695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", "6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", "79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", "7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", "82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", "8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", "91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", "952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", "99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", "9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", "a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", "cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", "e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", "e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", "ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", "f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"]
+wrapt = ["565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"]
+yarl = ["0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", "0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", "2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", "25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", "26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", "308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", "3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", "58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", "5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", "6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", "944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", "a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", "a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", "c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", "c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", "d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", "e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"]
diff --git a/pyproject.toml b/pyproject.toml
index 42d03a7617..e9c402661d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,15 +1,15 @@
[tool.black]
-line-length = 88
-target-version = ['py37']
+line-length = 99
+target-version = ['py36']
include = '\.pyi?$'
exclude = '''
-
(
/(
\.eggs
| \.git
| \.venv
| venv
+ | venv2
| _build
| build
| dist
@@ -17,4 +17,40 @@ exclude = '''
| temp
)/
)
-'''
\ No newline at end of file
+'''
+
+[tool.poetry]
+name = 'Modmail'
+version = '3.4.0'
+description = 'Modmail is similar to Reddits Modmail both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way.'
+license = 'AGPL-3.0-only'
+authors = [
+ 'kyb3r ',
+ '4jr ',
+ 'Taki '
+]
+readme = 'README.md'
+repository = 'https://github.com/kyb3r/modmail'
+homepage = 'https://github.com/kyb3r/modmail'
+keywords = ['discord', 'modmail']
+
+[tool.poetry.dependencies]
+python = "^3.7"
+"discord.py" = "=1.2.5"
+uvloop = "^0.14.0"
+python-dotenv = "^0.10.3"
+parsedatetime = "^2.5"
+dnspython = "^1.16"
+isodate = "^0.6.0"
+natural = "^0.2.0"
+motor = {version = "^2.1", optional = true}
+emoji = "^0.5.4"
+python-dateutil = "^2.8"
+colorama = "^0.4.3"
+aiohttp = "<3.6.0,>=3.3.0"
+
+[tool.poetry.dev-dependencies]
+black = {version = "=19.3b0", allows-prereleases = true}
+pylint = "^2.4"
+bandit = "^1.6"
+