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" +