diff --git a/.flake8 b/.flake8 index 48b31fad5..2c5c893c1 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -ignore = DCO010, DCO023, DOC602, DOC603, E203, E501, E712, F401, F403, F821, W503 +ignore = DCO010, DCO023, DOC503, DOC602, DOC603, MDA002, E203, E501, E712, F401, F403, F821, W503 style = google -skip-checking-short-docstrings = False \ No newline at end of file +skip-checking-short-docstrings = False diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 77f5d5e5c..0c0695c69 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,5 +8,5 @@ updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "daily" + interval: "weekly" open-pull-requests-limit: 10 diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index d0ac8e0fe..000000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Black - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - blackCheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) - BLACK_VERSION=$(sed -nE 's/black = "==(.*)"/\1/p' Pipfile) - pip install black==$BLACK_VERSION - - name: Analysing the code with black - run: | - black $(git rev-parse --show-toplevel) --check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..acd89f41c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: Test and build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + codeValidation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v3 + with: + python-version: 3.11 + - name: Install pip + run: | + python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) + - name: Install pipenv + run: | + PIPENV_VERSION=$(sed -nE 's/pipenv = "==(.*)"/\1/p' Pipfile) + python -m pip install pipenv==$PIPENV_VERSION + - name: Install from pipfile + run: | + pipenv install --system + - name: Analysing the code with black + run: | + black $(git rev-parse --show-toplevel) --check + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') + - name: Check for CRLF line endings + run: | + for file in $(git ls-files); do + if grep -q $'\r$' "$file"; then + echo "$file has faulty file endings" + fi + done + if git grep -I --name-only $'\r'; then + echo "CRLF line endings detected" + exit 1 + fi + - name: Analysing the code with flake8 + run: | + flake8 $(git rev-parse --show-toplevel) + - name: Analysing the code with isort + run: | + isort --check-only $(git rev-parse --show-toplevel)/ --profile black + - name: Running pytest + run: | + cd techsupport_bot + python3.11 -m pytest tests/ -p no:warnings + + containerBuild: + runs-on: ubuntu-latest + needs: + - codeValidation + steps: + - uses: actions/checkout@v3 + - name: Build the Docker image + run: make establish_config && docker build -f Dockerfile . -t techsupportbot:$(date +%s) + + close_pyTest: + if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + needs: + - codeValidation + permissions: + contents: write + pull-requests: write + steps: + - name: Dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Merge PR + if: steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch' && steps.dependabot-metadata.outputs.dependency-names == 'pytest' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + + close_pyLint: + if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + needs: + - codeValidation + permissions: + contents: write + pull-requests: write + steps: + - name: Dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Merge PR + if: steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch' && steps.dependabot-metadata.outputs.dependency-names == 'pylint' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + + close_flake8: + if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + needs: + - codeValidation + permissions: + contents: write + pull-requests: write + steps: + - name: Dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Merge PR + if: steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch' && steps.dependabot-metadata.outputs.dependency-names == 'flake8' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + + close_isort: + if: github.event_name == 'pull_request' && github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + needs: + - codeValidation + permissions: + contents: write + pull-requests: write + steps: + - name: Dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + - name: Merge PR + if: steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch' && steps.dependabot-metadata.outputs.dependency-names == 'isort' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 83873b36b..11951599c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,7 +15,6 @@ on: push: branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above branches: [ "main" ] schedule: - cron: '31 18 * * 1' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index a9fdc5b7a..000000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Docker Image CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Build the Docker image - run: make establish_config && docker build -f Dockerfile . -t techsupportbot:$(date +%s) diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml deleted file mode 100644 index b2570d061..000000000 --- a/.github/workflows/flake8.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: flake8 - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - Flake8: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) - pip install pipenv==$(sed -nE 's/pipenv = "==(.*)"/\1/p' Pipfile) - pipenv install --system - - name: Analysing the code with flake8 - run: | - flake8 $(git rev-parse --show-toplevel) diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml deleted file mode 100644 index 122e981ef..000000000 --- a/.github/workflows/isort.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: isort - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - isortCheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) - ISORT_VERSION=$(sed -nE 's/isort = "==(.*)"/\1/p' Pipfile) - pip install isort==$ISORT_VERSION - - name: Analysing the code with isort - run: | - isort --check-only $(git rev-parse --show-toplevel)/ --profile black diff --git a/.github/workflows/lfendings.yml b/.github/workflows/lfendings.yml deleted file mode 100644 index 01b8e873b..000000000 --- a/.github/workflows/lfendings.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Check Line Endings - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - check-line-endings: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Check for CRLF line endings - run: | - for file in $(git ls-files); do - if grep -q $'\r$' "$file"; then - echo "$file has faulty file endings" - fi - done - if git grep -I --name-only $'\r'; then - echo "CRLF line endings detected" - exit 1 - fi diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml deleted file mode 100644 index 04904f4d8..000000000 --- a/.github/workflows/pylint.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Pylint - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - pylintTest: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) - PYLINT_VERSION=$(sed -nE 's/pylint = "==(.*)"/\1/p' Pipfile) - pip install pylint==$PYLINT_VERSION - - name: Analysing the code with pylint - run: | - pylint $(git ls-files '*.py') diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index d4ad0a40d..000000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: PyTest - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - PyTests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: 3.11 - - name: Install pip - run: | - python -m pip install pip==$(sed -nE 's/pip = "==(.*)"/\1/p' Pipfile) - - name: Install pipenv - run: | - PIPENV_VERSION=$(sed -nE 's/pipenv = "==(.*)"/\1/p' Pipfile) - python -m pip install pipenv==$PIPENV_VERSION - - name: Install from pipfile - run: | - pipenv install --system - - name: Running pytest - run: | - cd techsupport_bot - python3.11 -m pytest tests/ -p no:warnings \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 21ac7b770..1f097f331 100644 --- a/.pylintrc +++ b/.pylintrc @@ -75,6 +75,7 @@ disable=C0103, R0914, R0915, R0916, + R0917, W0201, W0231, W0406, diff --git a/Pipfile b/Pipfile index 16cd3d516..429c6fcd7 100644 --- a/Pipfile +++ b/Pipfile @@ -4,38 +4,37 @@ url = "https://pypi.org/simple" verify_ssl = true [packages] -aiocron = "==1.8" +aiocron = "==2.1" bidict = "==0.23.1" black = "==25.1.0" -dateparser = "1.2.0" -"discord.py" = "==2.4.0" -emoji = "==2.12.1" +dateparser = "1.2.1" +"discord.py" = "==2.5.2" +emoji = "==2.14.1" expiringdict = "==1.2.2" -flake8 = "==7.1.1" +flake8 = "==7.2.0" flake8-annotations = "==3.1.1" -flake8-bugbear = "==24.8.19" +flake8-bugbear = "==24.12.12" flake8-docstrings-complete = "==1.4.1" flake8-modern-annotations = "==1.6.0" flake8-variables-names = "==0.0.6" gino = "==1.0.1" -gitpython = "==3.1.43" -hypothesis = "==6.122.4" -ib3 = "==0.2.0" -inflect = "==7.3.1" -irc = "==20.1.0" -isort = "==5.13.2" +gitpython = "==3.1.44" +hypothesis = "==6.135.13" +inflect = "==7.5.0" +irc = "==20.5.0" +isort = "==6.0.1" munch = "==4.0.0" typing_extensions = "==4.8.0" -pip = "==24.3.1" -pipenv = "==2024.4.1" -pydantic = "==2.8.2" -pydoclint = "==0.5.6" -pylint = "==3.2.6" +pip = "==25.1.1" +pipenv = "==2025.0.3" +pydantic = "==2.9.2" +pydoclint = "==0.6.6" +pylint = "==3.3.7" pynacl = "==1.5.0" -pytest = "==8.3.2" -pytest-asyncio = "==0.25.0" +pytest = "==8.4.0" +pytest-asyncio = "==1.0.0" pyyaml = "==6.0.1" -unidecode = "==1.3.8" +unidecode = "==1.4.0" [requires] python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock index 6e7efce95..c252ddf66 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2992e5af6ef26383f2c8f623b27fb83a33f70480a341221629cc14abd52f4a9b" + "sha256": "af49e9d0d73f24253830648384e35359a9ff5336611340dc52a3a95db50c67a8" }, "pipfile-spec": 6, "requires": { @@ -18,117 +18,107 @@ "default": { "aiocron": { "hashes": [ - "sha256:48546513faf2eb7901e65a64eba7b653c80106ed00ed9ca3419c3d10b6555a01", - "sha256:b6313214c311b62aa2220e872b94139b648631b3103d062ef29e5d3230ddce6d" + "sha256:1bb65a36aee137e8833592783956e0c7dc478bc3e9273fc2841d5d0c6045e4d2", + "sha256:b2612b67c552ebc4d24f524fe0316dec30b44f3c5a1d9a3697493d840aa7a5de" ], "index": "pypi", - "version": "==1.8" + "markers": "python_version >= '3.9'", + "version": "==2.1" }, "aiohappyeyeballs": { "hashes": [ - "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", - "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0" + "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", + "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" ], "markers": "python_version >= '3.9'", - "version": "==2.4.6" + "version": "==2.6.1" }, "aiohttp": { "hashes": [ - "sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d", - "sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc", - "sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261", - "sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b", - "sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625", - "sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39", - "sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f", - "sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636", - "sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac", - "sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217", - "sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9", - "sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f", - "sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006", - "sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e", - "sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9", - "sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a", - "sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695", - "sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730", - "sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e", - "sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382", - "sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc", - "sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00", - "sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8", - "sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658", - "sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339", - "sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a", - "sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a", - "sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e", - "sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d", - "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e", - "sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca", - "sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038", - "sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461", - "sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829", - "sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31", - "sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4", - "sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127", - "sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3", - "sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97", - "sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710", - "sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2", - "sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674", - "sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb", - "sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03", - "sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07", - "sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9", - "sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6", - "sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f", - "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298", - "sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b", - "sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01", - "sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9", - "sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7", - "sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413", - "sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec", - "sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7", - "sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4", - "sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb", - "sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519", - "sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d", - "sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6", - "sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27", - "sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1", - "sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087", - "sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067", - "sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f", - "sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95", - "sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa", - "sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa", - "sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16", - "sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106", - "sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92", - "sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777", - "sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa", - "sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e", - "sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7", - "sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115", - "sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120", - "sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d", - "sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b", - "sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a", - "sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725", - "sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a", - "sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d", - "sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288", - "sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8", - "sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385", - "sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24", - "sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71", - "sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138", - "sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177" + "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717", + "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d", + "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8", + "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda", + "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421", + "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9", + "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d", + "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8", + "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3", + "sha256:1596ebf17e42e293cbacc7a24c3e0dc0f8f755b40aff0402cb74c1ff6baec1d3", + "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361", + "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137", + "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b", + "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd", + "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8", + "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f", + "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d", + "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec", + "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb", + "sha256:3cec21dd68924179258ae14af9f5418c1ebdbba60b98c667815891293902e5e0", + "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756", + "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6", + "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9", + "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009", + "sha256:46533e6792e1410f9801d09fd40cbbff3f3518d1b501d6c3c5b218f427f6ff08", + "sha256:469ac32375d9a716da49817cd26f1916ec787fc82b151c1c832f58420e6d3533", + "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811", + "sha256:5199be2a2f01ffdfa8c3a6f5981205242986b9e63eb8ae03fd18f736e4840721", + "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118", + "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55", + "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609", + "sha256:5bc0ae0a5e9939e423e065a3e5b00b24b8379f1db46046d7ab71753dfc7dd0e1", + "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66", + "sha256:5d61df4a05476ff891cff0030329fee4088d40e4dc9b013fac01bc3c745542c2", + "sha256:5e7007b8d1d09bce37b54111f593d173691c530b80f27c6493b928dabed9e6ef", + "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f", + "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2", + "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804", + "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f", + "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94", + "sha256:7ccec9e72660b10f8e283e91aa0295975c7bd85c204011d9f5eb69310555cf30", + "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e", + "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1", + "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4", + "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f", + "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4", + "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f", + "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6", + "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4", + "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f", + "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7", + "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421", + "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e", + "sha256:a2fd04ae4971b914e54fe459dd7edbbd3f2ba875d69e057d5e3c8e8cac094935", + "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c", + "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea", + "sha256:ad2f41203e2808616292db5d7170cccf0c9f9c982d02544443c7eb0296e8b0c7", + "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868", + "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a", + "sha256:b2f317d1678002eee6fe85670039fb34a757972284614638f82b903a03feacdc", + "sha256:b426495fb9140e75719b3ae70a5e8dd3a79def0ae3c6c27e012fc59f16544a4a", + "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643", + "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01", + "sha256:c1b90407ced992331dd6d4f1355819ea1c274cc1ee4d5b7046c6761f9ec11829", + "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93", + "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9", + "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78", + "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508", + "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd", + "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1", + "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2", + "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6", + "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261", + "sha256:eab7b040a8a873020113ba814b7db7fa935235e4cbaf8f3da17671baa1024863", + "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1", + "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb", + "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415", + "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000", + "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1", + "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7", + "sha256:fe7cdd3f7d1df43200e1c80f1aed86bb36033bf65e3c7cf46a2b97a253ef8798" ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==3.10.11" + "markers": "python_version >= '3.9'", + "version": "==3.11.18" }, "aiosignal": { "hashes": [ @@ -148,74 +138,74 @@ }, "astroid": { "hashes": [ - "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", - "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.2.4" - }, - "async-timeout": { - "hashes": [ - "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", - "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + "sha256:104fb9cb9b27ea95e847a94c003be03a9e039334a8ebca5ee27dafaf5c5711eb", + "sha256:c332157953060c6deb9caa57303ae0d20b0fbdb2e59b4a4f2a6ba49d0a7961ce" ], - "markers": "python_full_version < '3.12.0'", - "version": "==4.0.3" + "markers": "python_full_version >= '3.9.0'", + "version": "==3.3.10" }, "asyncpg": { "hashes": [ - "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9", - "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7", - "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548", - "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23", - "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3", - "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675", - "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe", - "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175", - "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83", - "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385", - "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da", - "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106", - "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870", - "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449", - "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc", - "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178", - "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9", - "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b", - "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169", - "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610", - "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772", - "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2", - "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c", - "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb", - "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac", - "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408", - "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22", - "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb", - "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02", - "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59", - "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8", - "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3", - "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e", - "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4", - "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364", - "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f", - "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775", - "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3", - "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090", - "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810", - "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397" + "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", + "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", + "sha256:0b448f0150e1c3b96cb0438a0d0aa4871f1472e58de14a3ec320dbb2798fb0d4", + "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", + "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", + "sha256:1b11a555a198b08f5c4baa8f8231c74a366d190755aa4f99aacec5970afe929a", + "sha256:1b982daf2441a0ed314bd10817f1606f1c28b1136abd9e4f11335358c2c631cb", + "sha256:1c06a3a50d014b303e5f6fc1e5f95eb28d2cee89cf58384b700da621e5d5e547", + "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", + "sha256:26683d3b9a62836fad771a18ecf4659a30f348a561279d6227dab96182f46144", + "sha256:29ff1fc8b5bf724273782ff8b4f57b0f8220a1b2324184846b39d1ab4122031d", + "sha256:3152fef2e265c9c24eec4ee3d22b4f4d2703d30614b0b6753e9ed4115c8a146f", + "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", + "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", + "sha256:393af4e3214c8fa4c7b86da6364384c0d1b3298d45803375572f415b6f673f38", + "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", + "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", + "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", + "sha256:578445f09f45d1ad7abddbff2a3c7f7c291738fdae0abffbeb737d3fc3ab8b75", + "sha256:5b290f4726a887f75dcd1b3006f484252db37602313f806e9ffc4e5996cfe5cb", + "sha256:5df69d55add4efcd25ea2a3b02025b669a285b767bfbf06e356d68dbce4234ff", + "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", + "sha256:64e899bce0600871b55368b8483e5e3e7f1860c9482e7f12e0a771e747988168", + "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", + "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", + "sha256:6f4e83f067b35ab5e6371f8a4c93296e0439857b4569850b178a01385e82e9ad", + "sha256:8b684a3c858a83cd876f05958823b68e8d14ec01bb0c0d14a6704c5bf9711773", + "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", + "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", + "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", + "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", + "sha256:a3479a0d9a852c7c84e822c073622baca862d1217b10a02dd57ee4a7a081f708", + "sha256:aa403147d3e07a267ada2ae34dfc9324e67ccc4cdca35261c8c22792ba2b10cf", + "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", + "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", + "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", + "sha256:bfb4dd5ae0699bad2b233672c8fc5ccbd9ad24b89afded02341786887e37927e", + "sha256:c42f6bb65a277ce4d93f3fba46b91a265631c8df7250592dd4f11f8b0152150f", + "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", + "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", + "sha256:c7255812ac85099a0e1ffb81b10dc477b9973345793776b128a23e60148dd1af", + "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", + "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", + "sha256:dc1f62c792752a49f88b7e6f774c26077091b44caceb1983509edc18a2222ec0", + "sha256:f23b836dd90bea21104f69547923a02b167d999ce053f3d502081acea2fba15b", + "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", + "sha256:f86b0e2cd3f1249d6fe6fd6cfe0cd4538ba994e2d8249c0491925629b9104d0f", + "sha256:fb622c94db4e13137c4c7f98834185049cc50ee01d8f657ef898b6407c7b9c50", + "sha256:fd4406d09208d5b4a14db9a9dbb311b6d7aeeab57bded7ed2f8ea41aeef39b34" ], "markers": "python_full_version >= '3.8.0'", - "version": "==0.29.0" + "version": "==0.30.0" }, "attrs": { "hashes": [ - "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", - "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a" + "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", + "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" ], "markers": "python_version >= '3.8'", - "version": "==25.1.0" + "version": "==25.3.0" }, "autocommand": { "hashes": [ @@ -230,7 +220,7 @@ "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991" ], - "markers": "python_version < '3.12'", + "markers": "python_version >= '3.8'", "version": "==1.2.0" }, "bidict": { @@ -273,69 +263,84 @@ }, "certifi": { "hashes": [ - "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", - "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" + "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", + "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" ], "markers": "python_version >= '3.6'", - "version": "==2025.1.31" + "version": "==2025.4.26" }, "cffi": { "hashes": [ - "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", - "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", - "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", - "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", - "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", - "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", - "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", - "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", - "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", - "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", - "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", - "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", - "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", - "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", - "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", - "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", - "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", - "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", - "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", - "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", - "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", - "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", - "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", - "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", - "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", - "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", - "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", - "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", - "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", - "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", - "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", - "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", - "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", - "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", - "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", - "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", - "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", - "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", - "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", - "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", - "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", - "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", - "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", - "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", - "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", - "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", - "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", - "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", - "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", - "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", - "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", - "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" ], "markers": "python_version >= '3.8'", - "version": "==1.16.0" + "version": "==1.17.1" }, "click": { "hashes": [ @@ -347,36 +352,44 @@ }, "croniter": { "hashes": [ - "sha256:f1f8ca0af64212fbe99b1bee125ee5a1b53a9c1b433968d8bca8817b79d237f3", - "sha256:fdbb44920944045cc323db54599b321325141d82d14fa7453bc0699826bbe9ed" + "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", + "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577" ], "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.0.5" + "version": "==6.0.0" + }, + "cronsim": { + "hashes": [ + "sha256:5aab98716ef90ab5ac6be294b2c3965dbf76dc869f048846a0af74ebb506c10d", + "sha256:5e153ff8ed64da7ee8d5caac470dbeda8024ab052c3010b1be149772b4801835" + ], + "markers": "python_version >= '3.8'", + "version": "==2.6" }, "dateparser": { "hashes": [ - "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830", - "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30" + "sha256:7e4919aeb48481dbfc01ac9683c8e20bfe95bb715a38c1e9f6af889f4f30ccc3", + "sha256:bdcac262a467e6260030040748ad7c10d6bacd4f3b9cdb4cfd2251939174508c" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.2.0" + "markers": "python_version >= '3.8'", + "version": "==1.2.1" }, "dill": { "hashes": [ - "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca", - "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7" + "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", + "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" ], - "markers": "python_version >= '3.11'", - "version": "==0.3.8" + "markers": "python_version >= '3.8'", + "version": "==0.4.0" }, "discord.py": { "hashes": [ - "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d", - "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5" + "sha256:01cd362023bfea1a4a1d43f5280b5ef00cad2c7eba80098909f98bf28e578524", + "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a" ], "markers": "python_version >= '3.8'", - "version": "==2.4.0" + "version": "==2.5.2" }, "distlib": { "hashes": [ @@ -387,20 +400,20 @@ }, "docstring-parser-fork": { "hashes": [ - "sha256:0be85ad00cb25bf5beeb673e46e777facf0f47552fa3a7570d120ef7e3374401", - "sha256:95b23cc5092af85080c716a6da68360f5ae4fcffa75f4a3aca5e539783cbcc3d" + "sha256:55d7cbbc8b367655efd64372b9a0b33a49bae930a8ddd5cdc4c6112312e28a87", + "sha256:b44c5e0be64ae80f395385f01497d381bd094a57221fd9ff020987d06857b2a0" ], "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==0.0.9" + "version": "==0.0.12" }, "emoji": { "hashes": [ - "sha256:4aa0488817691aa58d83764b6c209f8a27c0b3ab3f89d1b8dceca1a62e4973eb", - "sha256:a00d62173bdadc2510967a381810101624a2f0986145b8da0cffa42e29430235" + "sha256:35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b", + "sha256:f8c50043d79a2c1410ebfae833ae1868d5941a67a6cd4d18377e2eb0bd79346b" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==2.12.1" + "version": "==2.14.1" }, "expiringdict": { "hashes": [ @@ -412,20 +425,20 @@ }, "filelock": { "hashes": [ - "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", - "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e" + "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", + "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de" ], "markers": "python_version >= '3.9'", - "version": "==3.17.0" + "version": "==3.18.0" }, "flake8": { "hashes": [ - "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", - "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" + "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343", + "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426" ], "index": "pypi", - "markers": "python_full_version >= '3.8.1'", - "version": "==7.1.1" + "markers": "python_version >= '3.9'", + "version": "==7.2.0" }, "flake8-annotations": { "hashes": [ @@ -438,12 +451,12 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:25bc3867f7338ee3b3e0916bf8b8a0b743f53a9a5175782ddc4325ed4f386b89", - "sha256:9b77627eceda28c51c27af94560a72b5b2c97c016651bdce45d8f56c180d2d32" + "sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e", + "sha256:46273cef0a6b6ff48ca2d69e472f41420a42a46e24b2a8972e4f0d6733d12a64" ], "index": "pypi", "markers": "python_full_version >= '3.8.1'", - "version": "==24.8.19" + "version": "==24.12.12" }, "flake8-docstrings-complete": { "hashes": [ @@ -474,101 +487,113 @@ }, "frozenlist": { "hashes": [ - "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", - "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", - "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", - "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", - "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", - "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", - "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", - "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", - "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", - "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", - "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", - "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", - "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c", - "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", - "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", - "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", - "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", - "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", - "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10", - "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", - "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", - "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", - "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", - "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10", - "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", - "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", - "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", - "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", - "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", - "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923", - "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", - "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", - "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", - "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", - "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", - "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", - "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", - "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", - "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", - "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", - "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", - "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", - "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", - "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", - "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", - "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604", - "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", - "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", - "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", - "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", - "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", - "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", - "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", - "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", - "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3", - "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", - "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", - "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", - "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf", - "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", - "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", - "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171", - "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", - "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", - "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", - "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", - "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", - "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", - "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9", - "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", - "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723", - "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", - "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", - "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99", - "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e", - "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", - "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", - "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", - "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", - "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", - "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca", - "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", - "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", - "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", - "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", - "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307", - "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e", - "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", - "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", - "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", - "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", - "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a" + "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", + "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", + "sha256:0dbae96c225d584f834b8d3cc688825911960f003a85cb0fd20b6e5512468c42", + "sha256:0e6f8653acb82e15e5443dba415fb62a8732b68fe09936bb6d388c725b57f812", + "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3", + "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", + "sha256:1255d5d64328c5a0d066ecb0f02034d086537925f1f04b50b1ae60d37afbf572", + "sha256:1330f0a4376587face7637dfd245380a57fe21ae8f9d360c1c2ef8746c4195fa", + "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", + "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", + "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", + "sha256:1db8b2fc7ee8a940b547a14c10e56560ad3ea6499dc6875c354e2335812f739d", + "sha256:2187248203b59625566cac53572ec8c2647a140ee2738b4e36772930377a533c", + "sha256:2b8cf4cfea847d6c12af06091561a89740f1f67f331c3fa8623391905e878530", + "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", + "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", + "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869", + "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", + "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603", + "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", + "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", + "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", + "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", + "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", + "sha256:4da6fc43048b648275a220e3a61c33b7fff65d11bdd6dcb9d9c145ff708b804c", + "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", + "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", + "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02", + "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", + "sha256:536a1236065c29980c15c7229fbb830dedf809708c10e159b8136534233545f0", + "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", + "sha256:56a0b8dd6d0d3d971c91f1df75e824986667ccce91e20dca2023683814344791", + "sha256:5c9e89bf19ca148efcc9e3c44fd4c09d5af85c8a7dd3dbd0da1cb83425ef4983", + "sha256:625170a91dd7261a1d1c2a0c1a353c9e55d21cd67d0852185a5fef86587e6f5f", + "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", + "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", + "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860", + "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8", + "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", + "sha256:6ef8e7e8f2f3820c5f175d70fdd199b79e417acf6c72c5d0aa8f63c9f721646f", + "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", + "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24", + "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", + "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", + "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", + "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", + "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", + "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c", + "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", + "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", + "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75", + "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", + "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", + "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", + "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249", + "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", + "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", + "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b", + "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", + "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", + "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", + "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", + "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", + "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", + "sha256:aa733d123cc78245e9bb15f29b44ed9e5780dc6867cfc4e544717b91f980af3b", + "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", + "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", + "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", + "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", + "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", + "sha256:ba7f8d97152b61f22d7f59491a781ba9b177dd9f318486c5fbc52cde2db12189", + "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", + "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", + "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", + "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", + "sha256:c7c608f833897501dac548585312d73a7dca028bf3b8688f0d712b7acfaf7fb3", + "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", + "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", + "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", + "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", + "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", + "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", + "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", + "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769", + "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", + "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", + "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", + "sha256:e19c0fc9f4f030fcae43b4cdec9e8ab83ffe30ec10c79a4a43a04d1af6c5e1ad", + "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", + "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", + "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", + "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", + "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", + "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e", + "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", + "sha256:ed5e3a4462ff25ca84fb09e0fada8ea267df98a450340ead4c91b44857267d70", + "sha256:f1a39819a5a3e84304cd286e3dc62a549fe60985415851b3337b6f5cc91907f1", + "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106", + "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd", + "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc", + "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352", + "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", + "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", + "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f" ], - "markers": "python_version >= '3.8'", - "version": "==1.5.0" + "markers": "python_version >= '3.9'", + "version": "==1.6.0" }, "gino": { "hashes": [ @@ -581,37 +606,29 @@ }, "gitdb": { "hashes": [ - "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", - "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" + "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", + "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf" ], "markers": "python_version >= '3.7'", - "version": "==4.0.11" + "version": "==4.0.12" }, "gitpython": { "hashes": [ - "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c", - "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff" + "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", + "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==3.1.43" + "version": "==3.1.44" }, "hypothesis": { "hashes": [ - "sha256:77f6799115d68a4b95c06d3b28ba2cfe40de6d5e656ad9b751232b25beba790d", - "sha256:edbcc1b36eea8159e4df9afa669ae8570416f96df5591bec7ad561f2dd0d4931" + "sha256:02f757c0ca75ea7999521e02c11b318ead902d287e37c906817861f218d9422a", + "sha256:6009c0a11753554efd5413c2145a5badfdb391cdf19f60619613ce5430762ad3" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==6.122.4" - }, - "ib3": { - "hashes": [ - "sha256:23023c8998b3eec660bc222ecf6e9ef75ebbf59f4b63e1b18c17021cc836a3ad", - "sha256:bc4ea0eba083ad1ed9bc9a7a57f306e3784f9dc2cd8f926dd56016bc5659a5ff" - ], - "index": "pypi", - "version": "==0.2.0" + "version": "==6.135.13" }, "idna": { "hashes": [ @@ -621,64 +638,72 @@ "markers": "python_version >= '3.6'", "version": "==3.10" }, + "importlib-resources": { + "hashes": [ + "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", + "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec" + ], + "markers": "python_version >= '3.9'", + "version": "==6.5.2" + }, "inflect": { "hashes": [ - "sha256:bedbae76877b054ecf0597153725677ab618fdd69abf189cc82e0f7a6720669d", - "sha256:edd785148a673b0c6dfef1a7d80cc1bcb2dd6d041cdb313b60032e464fd4e808" + "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", + "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==7.3.1" + "markers": "python_version >= '3.9'", + "version": "==7.5.0" }, "iniconfig": { "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "irc": { "hashes": [ - "sha256:5114aee1247ff634abed80f3349d75ccab35616a4b76be2a25add7ef189db0e5", - "sha256:b6f737932dd4791f3b18e319de7b7daf02d2285a6bea263d101f4d8e553807ec" + "sha256:712c6065c41ceaf75c874d623b02edaa5e6c7d605532da81c52d39629a8e9fd0", + "sha256:8ddbfd19f71204ceceba7b7c72724b15b3fa87bab5e81e45a75bef736a1a3c76" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==20.1.0" + "markers": "python_version >= '3.8'", + "version": "==20.5.0" }, "isort": { "hashes": [ - "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", - "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", + "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615" ], "index": "pypi", - "markers": "python_full_version >= '3.8.0'", - "version": "==5.13.2" + "markers": "python_full_version >= '3.9.0'", + "version": "==6.0.1" }, "jaraco.collections": { "hashes": [ - "sha256:808631b174b84a4e2a592490d62f62dfc15d8047a0f715726098dc43b81a6cfa", - "sha256:b117bacf6b69013741e34b0d75ca9c14e2ab983ad1ab4a2e6188627beffea8ee" + "sha256:0e4829409d39ad18a40aa6754fee2767f4d9730c4ba66dc9df89f1d2756994c2", + "sha256:a9480be7fe741d34639b3c32049066d7634b520746552d1a5d0fcda07ada1020" ], "markers": "python_version >= '3.8'", - "version": "==5.0.1" + "version": "==5.1.0" }, "jaraco.context": { "hashes": [ - "sha256:3e16388f7da43d384a1a7cd3452e72e14732ac9fe459678773a3608a812bf266", - "sha256:c2f67165ce1f9be20f32f650f25d8edfc1646a8aeee48ae06fb35f90763576d2" + "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4" ], "markers": "python_version >= '3.8'", - "version": "==5.3.0" + "version": "==6.0.1" }, "jaraco.functools": { "hashes": [ - "sha256:3b24ccb921d6b593bdceb56ce14799204f473976e2a9d4b15b04d0f2c2326664", - "sha256:d33fa765374c0611b52f8b3a795f8900869aa88c84769d4d1746cd68fb28c3e8" + "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", + "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649" ], "markers": "python_version >= '3.8'", - "version": "==4.0.1" + "version": "==4.1.0" }, "jaraco.logging": { "hashes": [ @@ -690,19 +715,19 @@ }, "jaraco.stream": { "hashes": [ - "sha256:3af4b0441090ee65bd6dde930d29f93f50c4a2fe6048e2a9d288285f5e4dc441", - "sha256:7fe38f25767b717afcf3ca79ec8fff06e85ae166fb5e42da58cc2a581bc9db39" + "sha256:41cec12393ab733870b54e3683564b95e68b82017428fcec56324f70e64c47c6", + "sha256:e2bc5028e721ed2cc852b96ec9a33f2b7664e9b2dbf87eefbe617d126641934b" ], - "markers": "python_version >= '3.6'", - "version": "==3.0.3" + "markers": "python_version >= '3.8'", + "version": "==3.0.4" }, "jaraco.text": { "hashes": [ - "sha256:b699491f9d074b4feafdfda0407fa5bbc5d83f485607a012e93472ba161f6843", - "sha256:bf12c58e9245eb6380d476f2ec623bb2e0a451e56bb9ed5bfd8493d6dc18ea2b" + "sha256:08de508939b5e681b14cdac2f1f73036cd97f6f8d7b25e96b8911a9a428ca0d1", + "sha256:5b71fecea69ab6f939d4c906c04fee1eda76500d1641117df6ec45b865f10db0" ], "markers": "python_version >= '3.8'", - "version": "==3.12.1" + "version": "==4.0.0" }, "mccabe": { "hashes": [ @@ -714,109 +739,121 @@ }, "more-itertools": { "hashes": [ - "sha256:e5d93ef411224fbcef366a6e8ddc4c5781bc6359d43412a65dd5964e46111463", - "sha256:ea6a02e24a9161e51faad17a8782b92a0df82c12c1c8886fec7f0c3fa1a1b320" + "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", + "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e" ], - "markers": "python_version >= '3.8'", - "version": "==10.3.0" + "markers": "python_version >= '3.9'", + "version": "==10.7.0" }, "multidict": { "hashes": [ - "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", - "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", - "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", - "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", - "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", - "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", - "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", - "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", - "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f", - "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", - "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6", - "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada", - "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", - "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2", - "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", - "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", - "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef", - "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", - "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", - "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", - "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6", - "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", - "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478", - "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", - "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", - "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", - "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", - "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", - "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30", - "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", - "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", - "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", - "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", - "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c", - "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", - "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", - "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", - "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", - "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", - "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2", - "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", - "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", - "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", - "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", - "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657", - "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", - "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492", - "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43", - "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", - "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", - "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", - "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057", - "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc", - "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", - "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255", - "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", - "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972", - "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", - "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1", - "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", - "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a", - "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", - "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c", - "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd", - "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", - "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", - "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", - "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", - "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", - "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", - "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4", - "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", - "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", - "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28", - "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d", - "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a", - "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", - "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", - "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", - "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", - "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", - "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", - "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392", - "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167", - "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c", - "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", - "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", - "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", - "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875", - "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd", - "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", - "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db" + "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756", + "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8", + "sha256:0bb8f8302fbc7122033df959e25777b0b7659b1fd6bcb9cb6bed76b5de67afef", + "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c", + "sha256:0ecdc12ea44bab2807d6b4a7e5eef25109ab1c82a8240d86d3c1fc9f3b72efd5", + "sha256:0ee1bf613c448997f73fc4efb4ecebebb1c02268028dd4f11f011f02300cf1e8", + "sha256:11990b5c757d956cd1db7cb140be50a63216af32cd6506329c2c59d732d802db", + "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713", + "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44", + "sha256:1b2019317726f41e81154df636a897de1bfe9228c3724a433894e44cd2512378", + "sha256:1c152c49e42277bc9a2f7b78bd5fa10b13e88d1b0328221e7aef89d5c60a99a5", + "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676", + "sha256:1f4e0334d7a555c63f5c8952c57ab6f1c7b4f8c7f3442df689fc9f03df315c08", + "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea", + "sha256:224b79471b4f21169ea25ebc37ed6f058040c578e50ade532e2066562597b8a9", + "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9", + "sha256:2427370f4a255262928cd14533a70d9738dfacadb7563bc3b7f704cc2360fc4e", + "sha256:24a8caa26521b9ad09732972927d7b45b66453e6ebd91a3c6a46d811eeb7349b", + "sha256:255dac25134d2b141c944b59a0d2f7211ca12a6d4779f7586a98b4b03ea80508", + "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1", + "sha256:2e329114f82ad4b9dd291bef614ea8971ec119ecd0f54795109976de75c9a852", + "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac", + "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde", + "sha256:30c433a33be000dd968f5750722eaa0991037be0be4a9d453eba121774985bc8", + "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504", + "sha256:32a998bd8a64ca48616eac5a8c1cc4fa38fb244a3facf2eeb14abe186e0f6cc5", + "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02", + "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4", + "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", + "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a", + "sha256:3ccdde001578347e877ca4f629450973c510e88e8865d5aefbcb89b852ccc666", + "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc", + "sha256:3cf62f8e447ea2c1395afa289b332e49e13d07435369b6f4e41f887db65b40bf", + "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790", + "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8", + "sha256:43173924fa93c7486402217fab99b60baf78d33806af299c56133a3755f69589", + "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d", + "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07", + "sha256:4eb33b0bdc50acd538f45041f5f19945a1f32b909b76d7b117c0c25d8063df56", + "sha256:5427a2679e95a642b7f8b0f761e660c845c8e6fe3141cddd6b62005bd133fc21", + "sha256:578568c4ba5f2b8abd956baf8b23790dbfdc953e87d5b110bce343b4a54fc9e7", + "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", + "sha256:5e3929269e9d7eff905d6971d8b8c85e7dbc72c18fb99c8eae6fe0a152f2e343", + "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9", + "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4", + "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a", + "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427", + "sha256:6b5a272bc7c36a2cd1b56ddc6bff02e9ce499f9f14ee4a45c45434ef083f2459", + "sha256:6d79cf5c0c6284e90f72123f4a3e4add52d6c6ebb4a9054e88df15b8d08444c6", + "sha256:7146a8742ea71b5d7d955bffcef58a9e6e04efba704b52a460134fefd10a8208", + "sha256:740915eb776617b57142ce0bb13b7596933496e2f798d3d15a20614adf30d229", + "sha256:75482f43465edefd8a5d72724887ccdcd0c83778ded8f0cb1e0594bf71736cc0", + "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474", + "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817", + "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd", + "sha256:8aac2eeff69b71f229a405c0a4b61b54bade8e10163bc7b44fcd257949620618", + "sha256:8b6fcf6054fc4114a27aa865f8840ef3d675f9316e81868e0ad5866184a6cba5", + "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3", + "sha256:8eac0c49df91b88bf91f818e0a24c1c46f3622978e2c27035bfdca98e0e18124", + "sha256:909f7d43ff8f13d1adccb6a397094adc369d4da794407f8dd592c51cf0eae4b1", + "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb", + "sha256:99592bd3162e9c664671fd14e578a33bfdba487ea64bcb41d281286d3c870ad7", + "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3", + "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375", + "sha256:9f35de41aec4b323c71f54b0ca461ebf694fb48bec62f65221f52e0017955b39", + "sha256:a059ad6b80de5b84b9fa02a39400319e62edd39d210b4e4f8c4f1243bdac4752", + "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0", + "sha256:a54ec568f1fc7f3c313c2f3b16e5db346bf3660e1309746e7fccbbfded856188", + "sha256:a62d78a1c9072949018cdb05d3c533924ef8ac9bcb06cbf96f6d14772c5cd451", + "sha256:a7bd27f7ab3204f16967a6f899b3e8e9eb3362c0ab91f2ee659e0345445e0078", + "sha256:a7be07e5df178430621c716a63151165684d3e9958f2bbfcb644246162007ab7", + "sha256:ab583ac203af1d09034be41458feeab7863c0635c650a16f15771e1386abf2d7", + "sha256:abcfed2c4c139f25c2355e180bcc077a7cae91eefbb8b3927bb3f836c9586f1f", + "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b", + "sha256:ae93e0ff43b6f6892999af64097b18561691ffd835e21a8348a441e256592e1f", + "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c", + "sha256:b128dbf1c939674a50dd0b28f12c244d90e5015e751a4f339a96c54f7275e291", + "sha256:b1b389ae17296dd739015d5ddb222ee99fd66adeae910de21ac950e00979d897", + "sha256:b57e28dbc031d13916b946719f213c494a517b442d7b48b29443e79610acd887", + "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1", + "sha256:b9cb19dfd83d35b6ff24a4022376ea6e45a2beba8ef3f0836b8a4b288b6ad685", + "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf", + "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6", + "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731", + "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507", + "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b", + "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae", + "sha256:ce5b3082e86aee80b3925ab4928198450d8e5b6466e11501fe03ad2191c6d777", + "sha256:d4e8535bd4d741039b5aad4285ecd9b902ef9e224711f0b6afda6e38d7ac02c7", + "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be", + "sha256:dd53893675b729a965088aaadd6a1f326a72b83742b056c1065bdd2e2a42b4df", + "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054", + "sha256:e413152e3212c4d39f82cf83c6f91be44bec9ddea950ce17af87fbf4e32ca6b2", + "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124", + "sha256:eccb67b0e78aa2e38a04c5ecc13bab325a43e5159a181a9d1a6723db913cbb3c", + "sha256:edf74dc5e212b8c75165b435c43eb0d5e81b6b300a938a4eb82827119115e840", + "sha256:f2882bf27037eb687e49591690e5d491e677272964f9ec7bc2abbe09108bdfb8", + "sha256:f6f19170197cc29baccd33ccc5b5d6a331058796485857cf34f7635aa25fb0cd", + "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8", + "sha256:f901a5aace8e8c25d78960dcc24c870c8d356660d3b49b93a78bf38eb682aac3", + "sha256:f92c7f62d59373cd93bc9969d2da9b4b21f78283b1379ba012f7ee8127b3152e", + "sha256:fb6214fe1750adc2a1b801a199d64b5a67671bf76ebf24c730b157846d0e90d2", + "sha256:fbd8d737867912b6c5f99f56782b8cb81f978a97b4437a1c476de90a3e41c9a1", + "sha256:fbf226ac85f7d6b6b9ba77db4ec0704fde88463dc17717aec78ec3c8546c70ad" ], - "markers": "python_version >= '3.8'", - "version": "==6.1.0" + "markers": "python_version >= '3.9'", + "version": "==6.4.3" }, "munch": { "hashes": [ @@ -829,19 +866,19 @@ }, "mypy-extensions": { "hashes": [ - "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", - "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", + "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" ], - "markers": "python_version >= '3.5'", - "version": "==1.0.0" + "markers": "python_version >= '3.8'", + "version": "==1.1.0" }, "packaging": { "hashes": [ - "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", - "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" ], "markers": "python_version >= '3.8'", - "version": "==24.2" + "version": "==25.0" }, "pathspec": { "hashes": [ @@ -853,133 +890,149 @@ }, "pip": { "hashes": [ - "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", - "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99" + "sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af", + "sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==24.3.1" + "markers": "python_version >= '3.9'", + "version": "==25.1.1" }, "pipenv": { "hashes": [ - "sha256:ab26ee98a7d83d342c1f562ee0564094ab1de091e5d5cec4eeaa95fb600de998", - "sha256:e8ea6105c1cdda7d5c19df7bd6439a006751f3d4e017602c791e7b51314adf84" + "sha256:87370bedcf0ff66d226af07ca341ae94afcc08fed90d57ad9fea9ffd44ced4d3", + "sha256:f0a67aa928824e61003d52acea72a94b180800019f03d38a311966f6330bc8d1" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2024.4.1" + "markers": "python_version >= '3.9'", + "version": "==2025.0.3" }, "platformdirs": { "hashes": [ - "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", - "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" + "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", + "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4" ], - "markers": "python_version >= '3.8'", - "version": "==4.3.6" + "markers": "python_version >= '3.9'", + "version": "==4.3.8" }, "pluggy": { "hashes": [ - "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", - "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" ], - "markers": "python_version >= '3.8'", - "version": "==1.5.0" + "markers": "python_version >= '3.9'", + "version": "==1.6.0" }, "propcache": { "hashes": [ - "sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4", - "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", - "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", - "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", - "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", - "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", - "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", - "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", - "sha256:19a0f89a7bb9d8048d9c4370c9c543c396e894c76be5525f5e1ad287f1750ddf", - "sha256:1ac2f5fe02fa75f56e1ad473f1175e11f475606ec9bd0be2e78e4734ad575034", - "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", - "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", - "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", - "sha256:2d3af2e79991102678f53e0dbf4c35de99b6b8b58f29a27ca0325816364caaba", - "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", - "sha256:3156628250f46a0895f1f36e1d4fbe062a1af8718ec3ebeb746f1d23f0c5dc4d", - "sha256:31f5af773530fd3c658b32b6bdc2d0838543de70eb9a2156c03e410f7b0d3aae", - "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", - "sha256:39d51fbe4285d5db5d92a929e3e21536ea3dd43732c5b177c7ef03f918dff9f2", - "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", - "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", - "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", - "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", - "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", - "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", - "sha256:574faa3b79e8ebac7cb1d7930f51184ba1ccf69adfdec53a12f319a06030a68b", - "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", - "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", - "sha256:5d97151bc92d2b2578ff7ce779cdb9174337390a535953cbb9452fb65164c587", - "sha256:5eee736daafa7af6d0a2dc15cc75e05c64f37fc37bafef2e00d77c14171c2097", - "sha256:6445804cf4ec763dc70de65a3b0d9954e868609e83850a47ca4f0cb64bd79fea", - "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", - "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", - "sha256:6a9a8c34fb7bb609419a211e59da8887eeca40d300b5ea8e56af98f6fbbb1541", - "sha256:6b3f39a85d671436ee3d12c017f8fdea38509e4f25b28eb25877293c98c243f6", - "sha256:6b6fb63ae352e13748289f04f37868099e69dba4c2b3e271c46061e82c745634", - "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", - "sha256:781e65134efaf88feb447e8c97a51772aa75e48b794352f94cb7ea717dedda0d", - "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", - "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", - "sha256:887d9b0a65404929641a9fabb6452b07fe4572b269d901d622d8a34a4e9043b2", - "sha256:8b3489ff1ed1e8315674d0775dc7d2195fb13ca17b3808721b54dbe9fd020faf", - "sha256:92fc4500fcb33899b05ba73276dfb684a20d31caa567b7cb5252d48f896a91b1", - "sha256:9403db39be1393618dd80c746cb22ccda168efce239c73af13c3763ef56ffc04", - "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", - "sha256:999779addc413181912e984b942fbcc951be1f5b3663cd80b2687758f434c583", - "sha256:9caac6b54914bdf41bcc91e7eb9147d331d29235a7c967c150ef5df6464fd1bb", - "sha256:a7a078f5d37bee6690959c813977da5291b24286e7b962e62a94cec31aa5188b", - "sha256:a7e65eb5c003a303b94aa2c3852ef130230ec79e349632d030e9571b87c4698c", - "sha256:a96dc1fa45bd8c407a0af03b2d5218392729e1822b0c32e62c5bf7eeb5fb3958", - "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", - "sha256:accb6150ce61c9c4b7738d45550806aa2b71c7668c6942f17b0ac182b6142fd4", - "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", - "sha256:ae1aa1cd222c6d205853b3013c69cd04515f9d6ab6de4b0603e2e1c33221303e", - "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", - "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", - "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", - "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", - "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", - "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", - "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", - "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", - "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", - "sha256:cba4cfa1052819d16699e1d55d18c92b6e094d4517c41dd231a8b9f87b6fa681", - "sha256:cea7daf9fc7ae6687cf1e2c049752f19f146fdc37c2cc376e7d0032cf4f25347", - "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", - "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", - "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", - "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", - "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", - "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", - "sha256:d9631c5e8b5b3a0fda99cb0d29c18133bca1e18aea9effe55adb3da1adef80d3", - "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", - "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", - "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", - "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", - "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", - "sha256:f089118d584e859c62b3da0892b88a83d611c2033ac410e929cb6754eec0ed16", - "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", - "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", - "sha256:f7a31fc1e1bd362874863fdeed71aed92d348f5336fd84f2197ba40c59f061bd", - "sha256:f9479aa06a793c5aeba49ce5c5692ffb51fcd9a7016e017d555d5e2b0045d212" + "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", + "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", + "sha256:069e7212890b0bcf9b2be0a03afb0c2d5161d91e1bf51569a64f629acc7defbf", + "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", + "sha256:0c3c3a203c375b08fd06a20da3cf7aac293b834b6f4f4db71190e8422750cca5", + "sha256:0c86e7ceea56376216eba345aa1fc6a8a6b27ac236181f840d1d7e6a1ea9ba5c", + "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", + "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", + "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", + "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", + "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", + "sha256:1f6cc0ad7b4560e5637eb2c994e97b4fa41ba8226069c9277eb5ea7101845b42", + "sha256:27c6ac6aa9fc7bc662f594ef380707494cb42c22786a558d95fcdedb9aa5d035", + "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0", + "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e", + "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46", + "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", + "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", + "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", + "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", + "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", + "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", + "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", + "sha256:47ef24aa6511e388e9894ec16f0fbf3313a53ee68402bc428744a367ec55b833", + "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259", + "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136", + "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", + "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005", + "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", + "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", + "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f", + "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", + "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", + "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", + "sha256:603f1fe4144420374f1a69b907494c3acbc867a581c2d49d4175b0de7cc64566", + "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", + "sha256:64a956dff37080b352c1c40b2966b09defb014347043e740d420ca1eb7c9b908", + "sha256:668ddddc9f3075af019f784456267eb504cb77c2c4bd46cc8402d723b4d200bf", + "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", + "sha256:6f173bbfe976105aaa890b712d1759de339d8a7cef2fc0a1714cc1a1e1c47f64", + "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", + "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71", + "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", + "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", + "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", + "sha256:82de5da8c8893056603ac2d6a89eb8b4df49abf1a7c19d536984c8dd63f481d5", + "sha256:83be47aa4e35b87c106fc0c84c0fc069d3f9b9b06d3c494cd404ec6747544894", + "sha256:8638f99dca15b9dff328fb6273e09f03d1c50d9b6512f3b65a4154588a7595fe", + "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", + "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", + "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", + "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", + "sha256:916cd229b0150129d645ec51614d38129ee74c03293a9f3f17537be0029a9641", + "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7", + "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649", + "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", + "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd", + "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", + "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", + "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229", + "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", + "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", + "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", + "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", + "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", + "sha256:a461959ead5b38e2581998700b26346b78cd98540b5524796c175722f18b0294", + "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", + "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", + "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", + "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", + "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", + "sha256:b303b194c2e6f171cfddf8b8ba30baefccf03d36a4d9cab7fd0bb68ba476a3d7", + "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", + "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", + "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", + "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", + "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7", + "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519", + "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", + "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180", + "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", + "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", + "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", + "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", + "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", + "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", + "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", + "sha256:ed5f6d2edbf349bd8d630e81f474d33d6ae5d07760c44d33cd808e2f5c8f4ae6", + "sha256:ef2e4e91fb3945769e14ce82ed53007195e616a63aa43b40fb7ebaaf907c8d4c", + "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", + "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", + "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98", + "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", + "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", + "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", + "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", + "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", + "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5" ], "markers": "python_version >= '3.9'", - "version": "==0.2.1" + "version": "==0.3.1" }, "pycodestyle": { "hashes": [ - "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", - "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" + "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9", + "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae" ], - "markers": "python_version >= '3.8'", - "version": "==2.12.1" + "markers": "python_version >= '3.9'", + "version": "==2.13.0" }, "pycparser": { "hashes": [ @@ -991,133 +1044,141 @@ }, "pydantic": { "hashes": [ - "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a", - "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8" + "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", + "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.8.2" + "version": "==2.9.2" }, "pydantic-core": { "hashes": [ - "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d", - "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f", - "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686", - "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482", - "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006", - "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83", - "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6", - "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88", - "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86", - "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a", - "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6", - "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a", - "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6", - "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6", - "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43", - "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c", - "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4", - "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e", - "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203", - "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd", - "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1", - "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24", - "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc", - "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc", - "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3", - "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598", - "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98", - "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331", - "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2", - "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a", - "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6", - "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688", - "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91", - "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa", - "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b", - "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0", - "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840", - "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c", - "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd", - "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3", - "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231", - "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1", - "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953", - "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250", - "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a", - "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2", - "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20", - "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434", - "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab", - "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703", - "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a", - "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2", - "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac", - "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611", - "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121", - "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e", - "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b", - "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09", - "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906", - "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9", - "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7", - "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b", - "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987", - "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c", - "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b", - "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e", - "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237", - "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1", - "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19", - "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b", - "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad", - "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0", - "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94", - "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312", - "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f", - "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669", - "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1", - "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe", - "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99", - "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a", - "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a", - "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52", - "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c", - "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad", - "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1", - "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a", - "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f", - "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a", - "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27" + "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", + "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", + "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", + "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", + "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", + "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", + "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", + "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", + "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", + "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", + "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", + "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", + "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", + "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", + "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", + "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", + "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368", + "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", + "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", + "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2", + "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", + "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", + "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", + "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", + "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", + "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", + "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271", + "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", + "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb", + "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13", + "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", + "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556", + "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665", + "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", + "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", + "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", + "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", + "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", + "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", + "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", + "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", + "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", + "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", + "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", + "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", + "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", + "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658", + "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", + "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", + "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", + "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", + "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", + "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", + "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", + "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", + "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", + "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", + "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad", + "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", + "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", + "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", + "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", + "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", + "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", + "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", + "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", + "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", + "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", + "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555", + "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", + "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6", + "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", + "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", + "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", + "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", + "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", + "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", + "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", + "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", + "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12", + "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", + "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", + "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", + "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", + "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", + "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", + "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", + "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", + "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607" ], "markers": "python_version >= '3.8'", - "version": "==2.20.1" + "version": "==2.23.4" }, "pydoclint": { "hashes": [ - "sha256:903a33a25504df85b200d3a3f1207688de208890b79730ceb6045978864ba7c9", - "sha256:991d336a8058d482e581f55e0b140c5757ce11d2baa2d2fca94b8f678c03831b" + "sha256:22862a8494d05cdf22574d6533f4c47933c0ae1674b0f8b961d6ef42536eaa69", + "sha256:7ce8ed36f60f9201bf1c1edacb32c55eb051af80fdd7304480c6419ee0ced43c" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==0.5.6" + "markers": "python_version >= '3.9'", + "version": "==0.6.6" }, "pyflakes": { "hashes": [ - "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", - "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a", + "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b" + ], + "markers": "python_version >= '3.9'", + "version": "==3.3.2" + }, + "pygments": { + "hashes": [ + "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", + "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" ], "markers": "python_version >= '3.8'", - "version": "==3.2.0" + "version": "==2.19.1" }, "pylint": { "hashes": [ - "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f", - "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3" + "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559", + "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d" ], "index": "pypi", - "markers": "python_full_version >= '3.8.0'", - "version": "==3.2.6" + "markers": "python_full_version >= '3.9.0'", + "version": "==3.3.7" }, "pynacl": { "hashes": [ @@ -1138,36 +1199,36 @@ }, "pytest": { "hashes": [ - "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", - "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce" + "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", + "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==8.3.2" + "markers": "python_version >= '3.9'", + "version": "==8.4.0" }, "pytest-asyncio": { "hashes": [ - "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", - "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3" + "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", + "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==0.25.0" + "version": "==1.0.0" }, "python-dateutil": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, "pytz": { "hashes": [ - "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", - "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" + "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", + "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" ], - "version": "==2024.1" + "version": "==2025.2" }, "pyyaml": { "hashes": [ @@ -1229,112 +1290,127 @@ }, "regex": { "hashes": [ - "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649", - "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35", - "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb", - "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68", - "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5", - "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133", - "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0", - "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d", - "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da", - "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f", - "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d", - "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53", - "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa", - "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a", - "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890", - "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67", - "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c", - "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2", - "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced", - "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741", - "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f", - "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa", - "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf", - "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4", - "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5", - "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2", - "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384", - "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7", - "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014", - "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704", - "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5", - "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2", - "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49", - "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1", - "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694", - "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629", - "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6", - "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435", - "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c", - "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835", - "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e", - "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201", - "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62", - "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5", - "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16", - "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f", - "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1", - "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f", - "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f", - "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145", - "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3", - "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed", - "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143", - "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca", - "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9", - "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa", - "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850", - "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80", - "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe", - "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656", - "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388", - "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1", - "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294", - "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3", - "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d", - "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b", - "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40", - "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600", - "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c", - "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569", - "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456", - "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9", - "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb", - "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e", - "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f", - "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d", - "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a", - "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a", - "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796" + "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c", + "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", + "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d", + "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", + "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67", + "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773", + "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", + "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef", + "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", + "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", + "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", + "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", + "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", + "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", + "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e", + "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", + "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", + "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", + "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e", + "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", + "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", + "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", + "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0", + "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", + "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b", + "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", + "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd", + "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57", + "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", + "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", + "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f", + "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b", + "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519", + "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", + "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", + "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", + "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b", + "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839", + "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", + "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf", + "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", + "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0", + "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f", + "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95", + "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4", + "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", + "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13", + "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", + "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", + "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008", + "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9", + "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc", + "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48", + "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", + "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", + "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", + "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf", + "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b", + "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd", + "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", + "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", + "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", + "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", + "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", + "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3", + "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983", + "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e", + "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", + "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", + "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", + "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467", + "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", + "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001", + "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", + "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", + "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", + "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf", + "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6", + "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e", + "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde", + "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62", + "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df", + "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", + "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5", + "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86", + "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2", + "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2", + "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", + "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c", + "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f", + "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", + "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2", + "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", + "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91" ], "markers": "python_version >= '3.8'", - "version": "==2024.5.15" + "version": "==2024.11.6" }, "setuptools": { "hashes": [ - "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", - "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" + "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", + "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c" ], "markers": "python_version >= '3.9'", - "version": "==75.8.0" + "version": "==80.9.0" }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" }, "smmap": { "hashes": [ - "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", - "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" + "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", + "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e" ], "markers": "python_version >= '3.7'", - "version": "==5.0.1" + "version": "==5.0.2" }, "sortedcontainers": { "hashes": [ @@ -1385,19 +1461,19 @@ }, "tempora": { "hashes": [ - "sha256:3bfcc12cbdbbbafecaaccb9097fc3754435b9d063dce43338e4fa87d39104aed", - "sha256:af9e5c7fff8fd7b877a1cb4213e5dd93c50d09cdc85517ec31914790ef9468c5" + "sha256:1e9606e65a3f2063460961d68515dee07bdaca0859305a8d3e6604168175fef1", + "sha256:c6fa4bc090e0b3b7ce879a81fdda6e4e0e7ab20d1cd580b5c92fecdcf9e5fb65" ], - "markers": "python_version >= '3.8'", - "version": "==5.6.0" + "markers": "python_version >= '3.9'", + "version": "==5.8.0" }, "tomlkit": { "hashes": [ - "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72", - "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264" + "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", + "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79" ], "markers": "python_version >= '3.8'", - "version": "==0.13.0" + "version": "==0.13.2" }, "typeguard": { "hashes": [ @@ -1418,116 +1494,138 @@ }, "tzlocal": { "hashes": [ - "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", - "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e" + "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", + "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" ], - "markers": "python_version >= '3.8'", - "version": "==5.2" + "markers": "python_version >= '3.9'", + "version": "==5.3.1" }, "unidecode": { "hashes": [ - "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4", - "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39" + "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", + "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23" ], "index": "pypi", - "markers": "python_version >= '3.5'", - "version": "==1.3.8" + "markers": "python_version >= '3.7'", + "version": "==1.4.0" }, "virtualenv": { "hashes": [ - "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728", - "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a" + "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", + "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af" ], "markers": "python_version >= '3.8'", - "version": "==20.29.2" + "version": "==20.31.2" }, "yarl": { "hashes": [ - "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", - "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", - "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318", - "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee", - "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", - "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1", - "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", - "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", - "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1", - "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", - "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", - "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", - "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", - "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc", - "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5", - "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", - "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", - "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", - "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24", - "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b", - "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910", - "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", - "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", - "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed", - "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", - "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04", - "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", - "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5", - "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", - "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", - "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", - "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b", - "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c", - "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", - "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34", - "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", - "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990", - "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", - "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", - "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", - "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", - "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6", - "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", - "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", - "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", - "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", - "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8", - "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", - "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e", - "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985", - "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8", - "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", - "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5", - "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690", - "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", - "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789", - "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", - "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", - "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", - "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", - "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", - "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9", - "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", - "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db", - "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde", - "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7", - "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", - "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", - "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", - "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", - "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", - "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", - "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", - "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd", - "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", - "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760", - "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", - "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", - "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", - "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", - "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719", - "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62" + "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", + "sha256:04d9c7a1dc0a26efb33e1acb56c8849bd57a693b85f44774356c92d610369efa", + "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61", + "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2", + "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2", + "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", + "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902", + "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2", + "sha256:119bca25e63a7725b0c9d20ac67ca6d98fa40e5a894bd5d4686010ff73397914", + "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", + "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", + "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569", + "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", + "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", + "sha256:27359776bc359ee6eaefe40cb19060238f31228799e43ebd3884e9c589e63b20", + "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", + "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", + "sha256:35d20fb919546995f1d8c9e41f485febd266f60e55383090010f272aca93edcc", + "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", + "sha256:3b4e88d6c3c8672f45a30867817e4537df1bbc6f882a91581faf1f6d9f0f1b5a", + "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", + "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", + "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", + "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5", + "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", + "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", + "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", + "sha256:44869ee8538208fe5d9342ed62c11cc6a7a1af1b3d0bb79bb795101b6e77f6e0", + "sha256:484e7a08f72683c0f160270566b4395ea5412b4359772b98659921411d32ad26", + "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", + "sha256:4ba5e59f14bfe8d261a654278a0f6364feef64a794bd456a8c9e823071e5061c", + "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", + "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", + "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", + "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a", + "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", + "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", + "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", + "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", + "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", + "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2", + "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", + "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", + "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", + "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", + "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e", + "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", + "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", + "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe", + "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", + "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", + "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", + "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", + "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62", + "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", + "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", + "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791", + "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", + "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb", + "sha256:8d8a3d54a090e0fff5837cd3cc305dd8a07d3435a088ddb1f65e33b322f66a94", + "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", + "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10", + "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", + "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", + "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", + "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da", + "sha256:a884b8974729e3899d9287df46f015ce53f7282d8d3340fa0ed57536b440621c", + "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", + "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", + "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195", + "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", + "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8", + "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634", + "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", + "sha256:b7fa0cb9fd27ffb1211cde944b41f5c67ab1c13a13ebafe470b1e206b8459da8", + "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", + "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", + "sha256:bc906b636239631d42eb8a07df8359905da02704a868983265603887ed68c076", + "sha256:bdb77efde644d6f1ad27be8a5d67c10b7f769804fff7a966ccb1da5a4de4b656", + "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", + "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19", + "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a", + "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4", + "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2", + "sha256:d0bf955b96ea44ad914bc792c26a0edcd71b4668b93cbcd60f5b0aeaaed06c64", + "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", + "sha256:d4fad6e5189c847820288286732075f213eabf81be4d08d6cc309912e62be5b7", + "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995", + "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6", + "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f", + "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", + "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487", + "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9", + "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a", + "sha256:f0cf05ae2d3d87a8c9022f3885ac6dea2b751aefd66a4f200e408a61ae9b7f0d", + "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", + "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", + "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22", + "sha256:f4d3fa9b9f013f7050326e165c3279e22850d02ae544ace285674cb6174b5d6d", + "sha256:f8d8aa8dd89ffb9a831fedbcb27d00ffd9f4842107d52dc9d57e64cb34073d5c", + "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", + "sha256:faa709b66ae0e24c8e5134033187a972d849d87ed0a12a0366bedcc6b5dc14a5", + "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867", + "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3" ], "markers": "python_version >= '3.9'", - "version": "==1.18.3" + "version": "==1.20.0" } }, "develop": {} diff --git a/README.md b/README.md index 5eab70ba3..9c158dbb7 100644 --- a/README.md +++ b/README.md @@ -68,22 +68,21 @@ On startup, the bot will load all extension files in the `techsupport_bot/extens A (very) simple example: ```python -from core import auxiliary, cogs -from discord.ext import commands -async def setup(bot): +import discord +from core import cogs +from discord import app_commands + +async def setup(bot: bot.TechSupportBot) -> None: await bot.add_cog(Greeter(bot=bot)) + class Greeter(cogs.BaseCog): - async def hello_command(self, ctx) -> None: - await auxiliary.add_list_of_reactions( - message=ctx.message, reactions=["🇭", "🇪", "🇾"] - ) - @commands.command( + @app_commands.command( name="hello", - brief="Says hello to the bot", description="Says hello to the bot (because they are doing such a great job!)", - usage="", + extras={"module": "hello"}, ) - async def hello(self, ctx): - await self.hello_command(ctx) + async def hello_app_command(self: Self, interaction: discord.Interaction) -> None: + await interaction.response.send_message("🇭 🇪 🇾") + ``` Extensions can be configured per-guild with settings saved on Postgres. There are several extensions included in the main repo, so please reference them for more advanced examples. diff --git a/config.default.yml b/config.default.yml index 4aa13c42d..c40a481e6 100644 --- a/config.default.yml +++ b/config.default.yml @@ -6,6 +6,7 @@ bot_config: disabled_extensions: ["kanye"] default_prefix: "." global_alerts_channel: "" + override_owner: "" modmail_config: enable_modmail: False disable_thread_creation: False diff --git a/default.env b/default.env index f8dfa5dd0..3e76726c4 100644 --- a/default.env +++ b/default.env @@ -1,4 +1,5 @@ POSTGRES_DB_NAME= POSTGRES_DB_PASSWORD= POSTGRES_DB_USER= -DEBUG= +DEBUG=0 +CONFIG_YML=./config.yml \ No newline at end of file diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index b4413f4b5..311205b6d 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -47,7 +47,7 @@ class TechSupportBot(commands.Bot): FUNCTIONS_DIR (str):The list of all files in the FUNCTIONS_DIR_NAME folder """ - CONFIG_PATH: str = "./config.yml" + CONFIG_PATH: str = os.environ.get("CONFIG_YML", "./config.yml") EXTENSIONS_DIR_NAME: str = "commands" EXTENSIONS_DIR: str = ( f"{os.path.join(os.path.dirname(__file__))}/{EXTENSIONS_DIR_NAME}" @@ -78,6 +78,7 @@ def __init__( ) ) self.command_execute_history: dict[str, dict[int, bool]] = {} + self.notified_dm_log: list[int] = [] # Loads the file config, which includes things like the token self.load_file_config() @@ -243,7 +244,14 @@ async def log_DM(self: Self, sent_from: str, source: str, content: str) -> None: f"{source} recieved a PM", f"PM from: {sent_from}\n{content}" ) embed.timestamp = datetime.datetime.utcnow() - await owner.send(embed=embed) + try: + await owner.send(embed=embed) + except discord.Forbidden as exception: + await self.logger.send_log( + message="Could not DM discord bot owner", + level=LogLevel.ERROR, + exception=exception, + ) async def on_message(self: Self, message: discord.Message) -> None: """Logs DMs and ensure that commands are processed @@ -258,6 +266,13 @@ async def on_message(self: Self, message: discord.Message) -> None: and message.author.id != owner.id and not message.author.bot ): + if message.author.id not in self.notified_dm_log: + self.notified_dm_log.append(message.author.id) + await message.author.send( + "All DMs sent to this bot are permanently logged and not " + "regularly checked. No responses will be given to any messages." + ) + attachment_urls = ", ".join(a.url for a in message.attachments) content_string = f'"{message.content}"' if message.content else "" attachment_string = f"({attachment_urls})" if attachment_urls else "" @@ -322,6 +337,9 @@ async def create_new_context_config(self: Self, guild_id: str) -> munch.Munch: config_.rate_limit.enabled = False config_.rate_limit.commands = 4 config_.rate_limit.time = 10 + config_.moderation = munch.DefaultMunch(None) + config_.moderation.max_warnings = 3 + config_.moderation.alert_channel = None config_.extensions = extensions_config @@ -763,7 +781,6 @@ async def is_bot_admin(self: Self, member: discord.Member) -> bool: context=LogContext(guild=member.guild), console_only=True, ) - owner = await self.get_owner() if getattr(owner, "id", None) == member.id: return True @@ -787,6 +804,12 @@ async def get_owner(self: Self) -> discord.User | None: Returns: discord.User | None: The User object of the owner of the application on discords side """ + if self.file_config.bot_config.override_owner: + self.owner = await self.fetch_user( + int(self.file_config.bot_config.override_owner) + ) + return self.owner + if not self.owner: try: # If this isn't console only, it is a forever recursion @@ -915,7 +938,7 @@ async def interaction_check(self: Self, interaction: discord.Interaction) -> boo await self.slash_command_log(interaction) await self.logger.send_log( - message="Checking if prefix command can run", + message="Checking if slash command can run", level=LogLevel.DEBUG, context=LogContext(guild=interaction.guild, channel=interaction.channel), console_only=True, @@ -963,24 +986,28 @@ async def slash_command_log(self: Self, interaction: discord.Interaction) -> Non Args: interaction (discord.Interaction): The interaction the slash command generated """ + if interaction.type != discord.InteractionType.application_command: + return embed = discord.Embed() embed.add_field(name="User", value=interaction.user) embed.add_field( name="Channel", value=getattr(interaction.channel, "name", "DM") ) embed.add_field(name="Server", value=getattr(interaction.guild, "name", "None")) - embed.add_field(name="Namespace", value=f"{interaction.namespace}") - embed.set_footer(text=f"Requested by {interaction.user.id}") + parameters = [] + for parameter in interaction.namespace: + parameters.append(f"{parameter[0]}: {parameter[1]}") log_channel = await self.get_log_channel_from_guild( interaction.guild, key="logging_channel" ) sliced_content = interaction.command.qualified_name[:100] - message = f"Command detected: `/{sliced_content}`" + command = f"/{sliced_content} {', '.join(parameters)}".strip() + message = f"Command detected: `{command}`" await self.logger.send_log( - message=message, + message=message.strip()[:6000], level=LogLevel.INFO, context=LogContext(guild=interaction.guild, channel=interaction.channel), channel=log_channel, @@ -1042,11 +1069,12 @@ async def can_run( # IRC Stuff async def start_irc(self: Self) -> None: - """Starts the IRC connection in a seperate thread""" - irc_config = self.file_config.api.irc + """Starts the IRC bot in a separate thread.""" main_loop = asyncio.get_running_loop() + irc_config = self.file_config.api.irc - irc_bot = ircrelay.IRCBot( + # Create the bot instance + irc_bot = ircrelay.relay.IRCBot( loop=main_loop, server=irc_config.server, port=irc_config.port, @@ -1054,10 +1082,17 @@ async def start_irc(self: Self) -> None: username=irc_config.name, password=irc_config.password, ) + self.irc = irc_bot - irc_thread = threading.Thread(target=irc_bot.start) + def run_in_thread() -> None: + """Run the IRC bot in a separate thread.""" + irc_bot.start_bot() + + # Start the bot in a new thread await self.logger.send_log( - message="Logging in to IRC", level=LogLevel.INFO, console_only=True + message="Logging into IRC", level=LogLevel.INFO, console_only=True ) - irc_thread.start() + + bot_thread = threading.Thread(target=run_in_thread) + bot_thread.start() diff --git a/techsupport_bot/commands/__init__.py b/techsupport_bot/commands/__init__.py index ac6cbd267..720c19524 100644 --- a/techsupport_bot/commands/__init__.py +++ b/techsupport_bot/commands/__init__.py @@ -3,6 +3,7 @@ Both app and prefix commands are in this module """ +from .application import * from .burn import * from .conch import * from .config import * @@ -16,6 +17,9 @@ from .linter import * from .listen import * from .mock import * +from .moderator import * +from .modlog import * +from .notes import * from .relay import * from .roll import * from .wyr import * diff --git a/techsupport_bot/commands/application.py b/techsupport_bot/commands/application.py index 5cfca5887..70530850e 100644 --- a/techsupport_bot/commands/application.py +++ b/techsupport_bot/commands/application.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime from enum import Enum from typing import TYPE_CHECKING, Self @@ -105,7 +106,7 @@ async def setup(bot: bot.TechSupportBot) -> None: description=( "The role IDs required to manage the applications (not required to apply)" ), - default=[""], + default=[], ) config.add( key="ping_role", @@ -114,6 +115,13 @@ async def setup(bot: bot.TechSupportBot) -> None: description="The ID of the role to ping when a new application is created", default="", ) + config.add( + key="max_age", + datatype="int", + title="Max days an application can live", + description="After this many days, the system will auto reject the applications.", + default=30, + ) await bot.add_cog(ApplicationManager(bot=bot, extension_name="application")) await bot.add_cog(ApplicationNotifier(bot=bot, extension_name="application")) bot.add_extension_config("application", config) @@ -142,6 +150,8 @@ async def command_permission_check(interaction: discord.Interaction) -> bool: # Gets permitted roles allowed_roles = [] for role_id in config.extensions.application.manage_roles.value: + if not role_id: + continue role = interaction.guild.get_role(int(role_id)) if not role: continue @@ -927,6 +937,8 @@ async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None user = await guild.fetch_member(int(app.applicant_id)) except discord.NotFound: user = None + + # User who made application left if not user: audit_log.append( f"Application by user: `{app.applicant_name}` was rejected because" @@ -937,6 +949,21 @@ async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None ).apply() continue + # Application has been pending for max_age days + max_age_config = config.extensions.application.max_age.value + if app.application_time < datetime.datetime.now() - datetime.timedelta( + days=max_age_config + ): + audit_log.append( + f"Application by user: `{user.name}` was rejected since it's been" + f" inactive for {max_age_config} days" + ) + await app.update( + application_status=ApplicationStatus.REJECTED.value + ).apply() + continue + + # User changed their name if user.name != app.applicant_name: audit_log.append( f"Application by user: `{app.applicant_name}` had the stored name" @@ -948,6 +975,7 @@ async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None int(config.extensions.application.application_role.value) ) + # User has the helper role if role in getattr(user, "roles", []): audit_log.append( f"Application by user: `{user.name}` was approved since they have" @@ -956,6 +984,7 @@ async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None await app.update( application_status=ApplicationStatus.APPROVED.value ).apply() + if audit_log: embed = discord.Embed(title="Application manage events") for event in audit_log: diff --git a/techsupport_bot/commands/botinfo.py b/techsupport_bot/commands/botinfo.py index a42a7ea8a..be97c8749 100644 --- a/techsupport_bot/commands/botinfo.py +++ b/techsupport_bot/commands/botinfo.py @@ -49,7 +49,11 @@ async def get_bot_data(self: Self, ctx: commands.Context) -> None: embed.add_field( name="Started", - value=f"{self.bot.startup_time} UTC" if self.bot.startup_time else "None", + value=( + f"" + if self.bot.startup_time + else "Unknown" + ), inline=True, ) embed.add_field( diff --git a/techsupport_bot/commands/burn.py b/techsupport_bot/commands/burn.py index 8d0c3b46c..df61b9d75 100644 --- a/techsupport_bot/commands/burn.py +++ b/techsupport_bot/commands/burn.py @@ -83,10 +83,13 @@ async def burn_command( ctx (commands.Context): The context in which the command was run user_to_match (discord.Member): The user in which to burn """ - prefix = await self.bot.get_prefix(ctx.message) - message = await auxiliary.search_channel_for_message( - channel=ctx.channel, prefix=prefix, member_to_match=user_to_match - ) + if ctx.message.reference is None: + prefix = await self.bot.get_prefix(ctx.message) + message = await auxiliary.search_channel_for_message( + channel=ctx.channel, prefix=prefix, member_to_match=user_to_match + ) + else: + message = ctx.message.reference.resolved await self.handle_burn(ctx, user_to_match, message) @@ -94,11 +97,11 @@ async def burn_command( @commands.guild_only() @commands.command( brief="Declares a BURN!", - description="Declares the user's last message as a BURN!", + description="Declares mentioned user's message as a BURN!", usage="@user", ) async def burn( - self: Self, ctx: commands.Context, user_to_match: discord.Member + self: Self, ctx: commands.Context, user_to_match: discord.Member = None ) -> None: """The only purpose of this function is to accept input from discord @@ -106,4 +109,14 @@ async def burn( ctx (commands.Context): The context in which the command was run user_to_match (discord.Member): The user in which to burn """ + if user_to_match is None: + if ctx.message.reference is None: + await auxiliary.send_deny_embed( + message="You need to mention someone to declare a burn.", + channel=ctx.channel, + ) + return + + user_to_match = ctx.message.reference.resolved.author + await self.burn_command(ctx, user_to_match) diff --git a/techsupport_bot/commands/correct.py b/techsupport_bot/commands/correct.py index c3b733dc6..fd3852936 100644 --- a/techsupport_bot/commands/correct.py +++ b/techsupport_bot/commands/correct.py @@ -55,9 +55,25 @@ async def correct_command( updated_message = self.prepare_message( message_to_correct.content, to_replace, replacement ) + + updated_message += " :white_check_mark:" + + if len(updated_message) > 4096: + await auxiliary.send_deny_embed( + message="The corrected message is too long to send", channel=ctx.channel + ) + return + + if updated_message.count("\n") > 15: + await auxiliary.send_deny_embed( + message="The corrected message has too many lines to send", + channel=ctx.channel, + ) + return + embed = auxiliary.generate_basic_embed( title="Correction!", - description=f"{updated_message} :white_check_mark:", + description=updated_message, color=discord.Color.green(), ) await ctx.send( diff --git a/techsupport_bot/commands/debug.py b/techsupport_bot/commands/debug.py new file mode 100644 index 000000000..071bcc748 --- /dev/null +++ b/techsupport_bot/commands/debug.py @@ -0,0 +1,184 @@ +""" +This is a development and issue tracking command designed to dump all attributes +of a given object type. +Current supported is message, member and channel +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +import discord +import ui +from core import auxiliary, cogs +from discord import app_commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Registers the debugger cog + + Args: + bot (bot.TechSupportBot): The bot to register the cog to + """ + await bot.add_cog(Debugger(bot=bot)) + + +class Debugger(cogs.BaseCog): + """The cog that holds the slowmode commands and helper functions + + Attributes: + debug_group (app_commands.Group): The group for the /debug commands + """ + + debug_group: app_commands.Group = app_commands.Group( + name="debug", description="...", extras={"module": "debug"} + ) + + @app_commands.check(auxiliary.bot_admin_check_interaction) + @debug_group.command( + name="message", + description="Searches and displays all the message properties", + extras={ + "module": "debug", + }, + ) + async def debug_message( + self: Self, + interaction: discord.Interaction, + channel: discord.TextChannel, + id: str, + ) -> None: + """Searches for a message by ID in the given channel. + + Args: + interaction (discord.Interaction): The interaction that called this command + channel (discord.TextChannel): The channel to find the message in + id (str): The ID of the message to search for + """ + await interaction.response.defer(ephemeral=False) + message = await channel.fetch_message(str(id)) + embeds = build_debug_embed(message) + + view = ui.PaginateView() + await view.send(interaction.channel, interaction.user, embeds, interaction) + + @app_commands.check(auxiliary.bot_admin_check_interaction) + @debug_group.command( + name="member", + description="Searches and displays all the member properties", + extras={ + "module": "debug", + }, + ) + async def debug_member( + self: Self, interaction: discord.Interaction, member: discord.Member + ) -> None: + """Displays attributes for a member of the guild where the command was run. + + Args: + interaction (discord.Interaction): The interaction that called this command + member (discord.Member): The member to search for information on + """ + await interaction.response.defer(ephemeral=False) + embeds = build_debug_embed(member) + view = ui.PaginateView() + await view.send(interaction.channel, interaction.user, embeds, interaction) + + @app_commands.check(auxiliary.bot_admin_check_interaction) + @debug_group.command( + name="channel", + description="Searches and displays all the channel properties", + extras={ + "module": "debug", + }, + ) + async def debug_channel( + self: Self, interaction: discord.Interaction, channel: discord.abc.GuildChannel + ) -> None: + """Displays attributes for a channel of the guild where the command was run. + + Args: + interaction (discord.Interaction): The interaction that called this command + channel (discord.abc.GuildChannel): The channel to search for information on + """ + await interaction.response.defer(ephemeral=False) + embeds = build_debug_embed(channel) + view = ui.PaginateView() + await view.send(interaction.channel, interaction.user, embeds, interaction) + + +def build_debug_embed(discord_object: object) -> list[discord.Embed]: + """Builds a list of embeds, with each one at a max of 4000 characters + This will be every attribute of the given object. + + Args: + discord_object (object): A discord object that needs to be explored + + Returns: + list[discord.Embed]: A list of embeds to be displayed in a paginated display + """ + all_strings = [] + properties_string = "" + + for attribute in dir(discord_object): + if not attribute.startswith("_"): + try: + value = getattr(discord_object, attribute) + except AttributeError: + continue + + temp_string = f"**{attribute}:** {value}\n" + if temp_string.startswith(f"**{attribute}:** 1500: + all_strings.append(properties_string) + properties_string = "" + + properties_string += print_string + + all_strings.append(properties_string) + + embeds = [] + for string in all_strings: + embeds.append(discord.Embed(description=string[:4000])) + + return embeds + + +def format_attribute_chunks(attribute: str, value: str) -> list[str]: + """This makes a simple paginated split fields, to break up long attributes + + Args: + attribute (str): The name of the attribute to be formatted. + value (str): The string representation of the attribute + + Returns: + list[str]: A list of attributes, split if needed + """ + + def make_chunk_label(index: int, total: int) -> str: + return f"**{attribute} ({index}):** " if total > 1 else f"**{attribute}:** " + + max_length = 750 + + temp_prefix = f"**{attribute} (999):** " + chunk_size = max_length - len(temp_prefix) - 1 # Reserve space for prefix and \n + + # Create raw chunks of value + raw_chunks = [value[i : i + chunk_size] for i in range(0, len(value), chunk_size)] + total_chunks = len(raw_chunks) + + # Format each chunk with appropriate prefix + return [ + f"{make_chunk_label(i + 1, total_chunks)}{chunk}\n" + for i, chunk in enumerate(raw_chunks) + ] diff --git a/techsupport_bot/commands/duck.py b/techsupport_bot/commands/duck.py index ca141834e..8820e2ea5 100644 --- a/techsupport_bot/commands/duck.py +++ b/techsupport_bot/commands/duck.py @@ -13,7 +13,7 @@ import munch import ui from botlogging import LogContext, LogLevel -from core import auxiliary, cogs, extensionconfig +from core import auxiliary, cogs, extensionconfig, moderation from discord import Color as embed_colors from discord.ext import commands @@ -64,6 +64,13 @@ async def setup(bot: bot.TechSupportBot) -> None: description="The amount of time to wait between bef/bang messages", default=5, ) + config.add( + key="mute_for_cooldown", + datatype="bool", + title="Uses the timeout feature for cooldown", + description="If enabled, users who miss will be timed out for the cooldown seconds", + default=True, + ) config.add( key="success_rate", datatype="int", @@ -362,14 +369,9 @@ def message_check( ) return False - weights = ( - config.extensions.duck.success_rate.value, - 100 - config.extensions.duck.success_rate.value, - ) - # Check to see if random failure - choice_ = random.choice(random.choices([True, False], weights=weights, k=1000)) - if not choice_: + choice = self.random_choice(config) + if not choice: time = message.created_at - duck_message.created_at duration_exact = float(str(time.seconds) + "." + str(time.microseconds)) cooldowns[message.author.id] = datetime.datetime.now() @@ -381,17 +383,26 @@ def message_check( f"seconds. Time would have been {duration_exact} seconds" ) ) - # Only attempt timeout if we know we can do it + if ( - channel.guild.me.top_role > message.author.top_role - and channel.guild.me.guild_permissions.moderate_members + config.extensions.duck.mute_for_cooldown.value + and config.extensions.duck.cooldown.value > 0 ): - asyncio.create_task( - message.author.timeout( - timedelta(seconds=config.extensions.duck.cooldown.value), - reason="Missed a duck", + # Only attempt timeout if we know we can do it + if ( + channel.guild.me.top_role > message.author.top_role + and channel.guild.me.guild_permissions.moderate_members + ): + asyncio.create_task( + moderation.mute_user( + user=message.author, + reason="Missed a duck", + duration=timedelta( + seconds=config.extensions.duck.cooldown.value + ), + ) ) - ) + asyncio.create_task( message.channel.send( content=message.author.mention, @@ -399,7 +410,7 @@ def message_check( ) ) - return choice_ + return choice async def get_duck_user( self: Self, user_id: int, guild_id: int @@ -761,12 +772,7 @@ async def kill(self: Self, ctx: commands.Context) -> None: await duck_user.update(befriend_count=duck_user.befriend_count - 1).apply() - weights = ( - config.extensions.duck.success_rate.value, - 100 - config.extensions.duck.success_rate.value, - ) - - passed = random.choice(random.choices([True, False], weights=weights, k=1000)) + passed = self.random_choice(config) if not passed: await auxiliary.send_deny_embed( message="The duck got away before you could kill it.", @@ -839,12 +845,7 @@ async def donate(self: Self, ctx: commands.Context, user: discord.Member) -> Non await duck_user.update(befriend_count=duck_user.befriend_count - 1).apply() - weights = ( - config.extensions.duck.success_rate.value, - 100 - config.extensions.duck.success_rate.value, - ) - - passed = random.choice(random.choices([True, False], weights=weights, k=1000)) + passed = self.random_choice(config) if not passed: await auxiliary.send_deny_embed( message="The duck got away before you could donate it.", @@ -933,3 +934,25 @@ async def spawn(self: Self, ctx: commands.Context) -> None: message="It looks like you don't have permissions to spawn a duck", channel=ctx.channel, ) + + def random_choice(self: Self, config: munch.Munch) -> bool: + """A function to pick true or false randomly based on the success_rate in the config + + Args: + config (munch.Munch): The config for the guild + + Returns: + bool: Whether the random choice should succeed or not + """ + + weights = ( + config.extensions.duck.success_rate.value, + 100 - config.extensions.duck.success_rate.value, + ) + + # Check to see if random failure + choice_ = random.choice( + random.choices([True, False], weights=weights, k=100000) + ) + + return choice_ diff --git a/techsupport_bot/commands/extension.py b/techsupport_bot/commands/extension.py index 7891a58de..80fdf9daa 100644 --- a/techsupport_bot/commands/extension.py +++ b/techsupport_bot/commands/extension.py @@ -12,6 +12,7 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Self import discord @@ -73,6 +74,47 @@ async def list_disabled(self: Self, interaction: discord.Interaction) -> None: ) await interaction.response.send_message(embed=embed) + @app_commands.checks.has_permissions(administrator=True) + @extension_app_command_group.command( + name="enable_all", + description="Enables all loaded but disabled extensions in the guild", + extras={"module": "extension"}, + ) + async def enable_everything(self: Self, interaction: discord.Interaction) -> None: + """This will get all the disabled extensions and enable them for the current + guild. + + Args: + interaction (discord.Interaction): The interaction that triggered the slash command + """ + config = self.bot.guild_configs[str(interaction.guild.id)] + missing_extensions = [ + item + for item in self.bot.extension_name_list + if item not in config.enabled_extensions + ] + if len(missing_extensions) == 0: + embed = auxiliary.prepare_confirm_embed( + message="No currently loaded extensions are disabled" + ) + else: + for extension in missing_extensions: + config.enabled_extensions.append(extension) + + config.enabled_extensions.sort() + # Modify the database + await self.bot.write_new_config( + str(interaction.guild.id), json.dumps(config) + ) + + # Modify the local cache + self.bot.guild_configs[str(interaction.guild.id)] = config + + embed = auxiliary.prepare_confirm_embed( + f"I have enabled {len(missing_extensions)} for this guild." + ) + await interaction.response.send_message(embed=embed) + @commands.check(auxiliary.bot_admin_check_context) @commands.group( name="extension", diff --git a/techsupport_bot/commands/factoids.py b/techsupport_bot/commands/factoids.py index 9a1570757..5c0fd44c2 100644 --- a/techsupport_bot/commands/factoids.py +++ b/techsupport_bot/commands/factoids.py @@ -713,7 +713,6 @@ async def add_factoid( # Removes the factoid from the cache await self.handle_cache(guild, name) - await auxiliary.send_confirm_embed( message=f"Successfully {fmt} the factoid `{factoid_name}`", channel=ctx.channel, @@ -734,13 +733,23 @@ async def delete_factoid( factoid = await self.get_raw_factoid_entry( called_factoid.factoid_db_entry.name, str(ctx.guild.id) ) + aliases_list = await self.get_list_of_aliases( + called_factoid.factoid_db_entry.name, str(ctx.guild.id) + ) + aliases_list.remove(called_factoid.original_call_str) + print_aliases_list = ", ".join(aliases_list) + + send_message = ( + f"This will remove the factoid `{called_factoid.original_call_str}`" + ) + if print_aliases_list: + send_message += f" and all of it's aliases `({print_aliases_list})` forever" + + send_message += ". Are you sure?" view = ui.Confirm() await view.send( - message=( - f"This will remove the factoid `{called_factoid.original_call_str}` " - "and all of it's aliases forever. Are you sure?" - ), + message=send_message, channel=ctx.channel, author=ctx.author, ) @@ -759,13 +768,13 @@ async def delete_factoid( await self.delete_factoid_call(factoid, str(ctx.guild.id)) # Don't send the confirmation message if this is an alias either - await auxiliary.send_confirm_embed( - ( - f"Successfully deleted the factoid `{called_factoid.original_call_str}`" - "and all of it's aliases" - ), - channel=ctx.channel, + confirm_message = ( + f"Successfully deleted the factoid `{called_factoid.original_call_str}`" ) + if print_aliases_list: + confirm_message += f" and all of it's aliases `({print_aliases_list})`" + + await auxiliary.send_confirm_embed(message=confirm_message, channel=ctx.channel) return True # -- Getting and responding with a factoid -- @@ -908,6 +917,118 @@ async def send_to_irc( factoid_message=factoid_message, ) + @factoid_app_group.command( + name="call", + description="Calls a factoid from the database and sends it publicy in the channel.", + extras={ + "usage": "[factoid_name]", + "module": "factoids", + }, + ) + async def factoid_call_command( + self: Self, interaction: discord.Interaction, factoid_name: str + ) -> None: + """This is an app command version of typing {prefix}call + + Args: + interaction (discord.Interaction): The interaction that triggered this command + factoid_name (str): The factoid name to search for and print + + Raises: + TooLongFactoidMessageError: If the plaintext exceed 2000 characters + """ + query = factoid_name.replace("\n", " ").split(" ")[0].lower() + config = self.bot.guild_configs[str(interaction.guild.id)] + try: + factoid = await self.get_factoid(query, str(interaction.guild.id)) + + except custom_errors.FactoidNotFoundError: + embed = auxiliary.prepare_deny_embed( + message=f"The factoid {factoid_name} couldn't be found" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + await self.bot.logger.send_log( + message=f"Invalid factoid call {query} from {interaction.guild.id}", + level=LogLevel.DEBUG, + context=LogContext( + guild=interaction.guild, channel=interaction.channel + ), + ) + return + + # Checking for disabled or restricted + if factoid.disabled: + embed = auxiliary.prepare_deny_embed( + message=f"The factoid {factoid_name} is disabled." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + if ( + factoid.restricted + and str(interaction.channel.id) + not in config.extensions.factoids.restricted_list.value + ): + embed = auxiliary.prepare_deny_embed( + message=f"The factoid {factoid_name} is restricted and not allowed in this channel." + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + if not config.extensions.factoids.disable_embeds.value: + embed = self.get_embed_from_factoid(factoid) + else: + embed = None + # if the json doesn't include non embed argument, then don't send anything + # otherwise send message text with embed + try: + content = factoid.message if not embed else None + except ValueError: + # The not embed causes a ValueError in certain cases. This ensures fallback works + content = factoid.message + + if content and len(content) > 2000: + embed = auxiliary.prepare_deny_embed( + message="I ran into an error sending that factoid: " + + "The factoid message is longer than the discord size limit (2000)", + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + raise custom_errors.TooLongFactoidMessageError + + try: + # define the message and send it + await interaction.response.send_message(content=content, embed=embed) + # log it in the logging channel with type info and generic content + log_channel = config.get("logging_channel") + await self.bot.logger.send_log( + message=( + f"Sending factoid: {query} (triggered by {interaction.user} in" + f" #{interaction.channel.name})" + ), + level=LogLevel.INFO, + context=LogContext( + guild=interaction.guild, channel=interaction.channel + ), + channel=log_channel, + ) + # If something breaks, also log it + except discord.errors.HTTPException as exception: + log_channel = config.get("logging_channel") + await self.bot.logger.send_log( + message="Could not send factoid", + level=LogLevel.ERROR, + context=LogContext( + guild=interaction.guild, channel=interaction.channel + ), + channel=log_channel, + exception=exception, + ) + # Sends the raw factoid instead of the embed as fallback + await interaction.response.send_message(content=factoid.message) + await self.send_to_irc( + interaction.channel, interaction.message, factoid.message + ) + # -- Factoid job related functions -- async def kickoff_jobs(self: Self) -> None: """Gets a list of cron jobs and starts them""" @@ -1135,7 +1256,6 @@ async def remember( alias=None, ) - @auxiliary.with_typing @commands.check(has_manage_factoids_role) @commands.guild_only() @factoid.command( @@ -1456,7 +1576,7 @@ async def _json(self: Self, ctx: commands.Context, factoid_name: str) -> None: json_file = discord.File( io.StringIO(formatted), filename=( - f"{factoid.name}-factoid-embed-config-{datetime.datetime.utcnow()}.json" + f"{factoid_name.lower()}-factoid-embed-config-{datetime.datetime.utcnow()}.json" ), ) @@ -1587,6 +1707,13 @@ async def app_command_all( guild, exclusive_property=property, include_hidden=show_hidden ) + if not factoids: + embed = auxiliary.prepare_deny_embed( + "No factoids could be found matching your filter" + ) + await interaction.response.send_message(embed=embed) + return + aliases = self.build_alias_dict_for_given_factoids(factoids) # If the linx server isn't configured, we must make it a file @@ -1793,23 +1920,6 @@ def build_formatted_factoid_data( output_data = sorted(output_data, key=lambda x: list(x.keys())[0]) return output_data - @auxiliary.with_typing - @commands.guild_only() - @factoid.command( - name="all", - aliases=["lsf"], - brief="List all factoids", - description="Sends a list of all factoids as a url.", - ) - async def all_(self: Self, ctx: commands.Context) -> None: - """Command to list all factoids - DEPREACTED, /factoid all is the main one now - - Args: - ctx (commands.Context): Context of the invocation - """ - await ctx.send("Progress has to be made. `.factoid all` has been sunset.") - async def generate_html( self: Self, guild: discord.Guild, @@ -1917,34 +2027,64 @@ async def send_factoids_as_file( def search_content_and_bold( self: Self, original: str, search_string: str - ) -> list[str]: - """Searches a string for a substring and bolds it + ) -> str | None: + """Finds all starting indices of the search_string in the original string. Args: - original (str): The original content to search through - search_string (str): The string we are searching for + original (str): The original content to search through. + search_string (str): The string we are searching for. Returns: - list[str]: Snippets that have been modified with the search string + str | None: A single string with bolded matches and surrounding context, + or None if no matches exist. """ - # Compile the regular expression for the substring - pattern = re.compile(re.escape(search_string)) - matches = list(pattern.finditer(original)) - matches_list = [] + original = original.replace(search_string, f"**{search_string}**") + + show_range = 20 + + indices = [] + search_len = len(search_string) + for i in range(len(original) - search_len + 1): + if original[i : i + search_len] == search_string: + indices.append(i) - # Print all instances with 20 characters before and after each occurrence - for match in matches: - start = max(match.start() - 20, 0) - end = min(match.end() + 20, len(original)) - context = original[start:end] - # Replace the substring in the context with the formatted version - context_with_formatting = context.replace( - search_string, f"**{search_string}**" + if len(indices) == 0: + return None + + # Generate ranges to include + ranges_to_include = [] + for start in indices: + ranges_to_include.append( + ( + max(0, start - show_range - 2), + min(len(original), start + search_len + show_range + 2), + ) ) - matches_list.append(context_with_formatting.replace("****", "")) - return matches_list + # Minimize ranges by merging overlapping or adjacent ranges + minimized_ranges = [] + for start, end in sorted(ranges_to_include): + if minimized_ranges and start <= minimized_ranges[-1][1]: + minimized_ranges[-1] = ( + min(minimized_ranges[-1][0], start), + max(minimized_ranges[-1][1], end), + ) + else: + minimized_ranges.append((start, end)) + + ranges_to_strs = [] + + if minimized_ranges[0][0] != 0: + ranges_to_strs.append("") + + for include_range in minimized_ranges: + ranges_to_strs.append(original[include_range[0] : include_range[1]]) + + if minimized_ranges[len(minimized_ranges) - 1][1] != len(original): + ranges_to_strs.append("") + + return "...".join(ranges_to_strs) @auxiliary.with_typing @commands.guild_only() @@ -1973,43 +2113,45 @@ async def search(self: Self, ctx: commands.Context, *, query: str) -> None: factoids = await self.get_all_factoids(guild, list_hidden=False) matches = {} - for factoid in factoids: + if factoid.alias: + continue + factoid_key = ", ".join(await self.get_list_of_aliases(factoid.name, guild)) - if query in factoid.name.lower(): + + # Name string + name_highlight = self.search_content_and_bold(factoid_key.lower(), query) + if name_highlight: if factoid_key in matches: - matches[factoid_key].append( - f"Name: {factoid.name.lower().replace(query, f'**{query}**')}" - ) + matches[factoid_key].append(f"Name: {name_highlight}") else: - matches[factoid_key] = [ - f"Name: {factoid.name.lower().replace(query, f'**{query}**')}" - ] + matches[factoid_key] = [f"Name: {name_highlight}"] - if query in factoid.message.lower(): - matches_list = self.search_content_and_bold( - factoid.message.lower(), query - ) - for match in matches_list: - if factoid_key in matches: - matches[factoid_key].append(f"Content: {match}") - else: - matches[factoid_key] = [f"Content: {match}"] + # Content + content_highlight = self.search_content_and_bold( + factoid.message.lower(), query + ) + if content_highlight: + if factoid_key in matches: + matches[factoid_key].append(f"Content: {content_highlight}") + else: + matches[factoid_key] = [f"Content: {content_highlight}"] - if ( - factoid.embed_config is not None - and query in factoid.embed_config.lower() - ): - matches_list = self.search_content_and_bold( + # Embed + if factoid.embed_config is not None: + embed_highlight = self.search_content_and_bold( factoid.embed_config.lower(), query ) - for match in matches_list: + if embed_highlight: if factoid_key in matches: matches[factoid_key].append( - f"Embed: {match.replace('_', '`_`')}" + f"Embed: {embed_highlight.replace('_', '`_`')}" ) else: - matches[factoid_key] = [f"Embed: {match.replace('_', '`_`')}"] + matches[factoid_key] = [ + f"Embed: {embed_highlight.replace('_', '`_`')}" + ] + if len(matches) == 0: embed = auxiliary.prepare_deny_embed( f"No factoids could be found matching `{query}`" diff --git a/techsupport_bot/commands/hangman.py b/techsupport_bot/commands/hangman.py index 12300e9c1..fc599db2d 100644 --- a/techsupport_bot/commands/hangman.py +++ b/techsupport_bot/commands/hangman.py @@ -9,6 +9,7 @@ import discord import ui from core import auxiliary, cogs, extensionconfig +from discord import app_commands from discord.ext import commands if TYPE_CHECKING: @@ -35,19 +36,31 @@ async def setup(bot: bot.TechSupportBot) -> None: class HangmanGame: - """Class for the game hangman. + """ + A class that represents a game of Hangman. + + The game includes the logic for tracking the word to be guessed, the guesses made, + and the state of the hangman figure based on incorrect guesses. It also supports + additional functionality such as adding more guesses and determining whether the game + is finished or failed. Attributes: - HANG_PICS (list[str]): The list of hangman pictures - FINAL_STEP (int): The last step of the hangman game + HANG_PICS (list[str]): The list of hangman pictures. + word (str): The word that players need to guess. + guesses (set): A set of guessed letters. + step (int): The current number of incorrect guesses made. + max_guesses (int): The maximum number of incorrect guesses allowed before the game ends. + started (datetime): The UTC timestamp of when the game was started. + id (UUID): A unique identifier for the game. finished (bool): Determines if the game has been finished or not failed (bool): Determines if the players failed to guess the word Args: - word (str): The word to start the game with + word (str): The word for the game. It must be an alphabetic string without underscores. + max_guesses (int, optional): The maximum number of incorrect guesses allowed. Raises: - ValueError: A valid alphabetic word wasn't provided + ValueError: A valid alphabetic word wasn't provided. """ HANG_PICS: list[str] = [ @@ -108,14 +121,14 @@ class HangmanGame: | =========""", ] - FINAL_STEP: int = len(HANG_PICS) - 1 - def __init__(self: Self, word: str) -> None: + def __init__(self: Self, word: str, max_guesses: int = 6) -> None: if not word or "_" in word or not word.isalpha(): raise ValueError("valid word must be provided") self.word = word self.guesses = set() self.step = 0 + self.max_guesses = max_guesses self.started = datetime.datetime.utcnow() self.id = uuid.uuid4() @@ -139,7 +152,11 @@ def draw_hang_state(self: Self) -> str: Returns: str: The str representation of the correct picture """ - return self.HANG_PICS[self.step] + picture_index = min( + len(self.HANG_PICS) - 1, # Maximum valid index + int(self.step / self.max_guesses * (len(self.HANG_PICS) - 1)), + ) + return self.HANG_PICS[picture_index] def guess(self: Self, letter: str) -> bool: """Registers a guess to the given game @@ -168,8 +185,18 @@ def guess(self: Self, letter: str) -> bool: @property def finished(self: Self) -> bool: - """Method to finish the game of hangman.""" - if self.step < 0 or self.step >= self.FINAL_STEP: + """ + Determines if the game of Hangman is finished. + + The game is considered finished if: + - The number of incorrect guesses (`step`) is greater than or + equal to the maximum allowed (`max_guesses`). + - All letters in the word have been correctly guessed, meaning the game has been won. + + Returns: + bool: True if the game is finished (either won or lost), False otherwise. + """ + if self.step < 0 or self.step >= self.max_guesses: return True if all(letter in self.guesses for letter in self.word): return True @@ -177,13 +204,24 @@ def finished(self: Self) -> bool: @property def failed(self: Self) -> bool: - """Method in case the game wasn't successful.""" - if self.step >= self.FINAL_STEP: + """ + Determines if the game was unsuccessful. + + The game is considered a failure when the number of incorrect guesses (`step`) + equals or exceeds the maximum allowed guesses (`max_guesses`), meaning the players + failed to guess the word within the allowed attempts. + + Returns: + bool: True if the game was unsuccessful (i.e., the number of incorrect guesses + is greater than or equal to the maximum allowed), False otherwise. + """ + if self.step >= self.max_guesses: return True return False def guessed(self: Self, letter: str) -> bool: - """Method to know if a letter has already been guessed + """ + Method to know if a letter has already been guessed Args: letter (str): The letter to check if it has been guessed @@ -201,9 +239,32 @@ def guessed(self: Self, letter: str) -> bool: return True return False + def remaining_guesses(self: Self) -> int: + """ + Calculates the number of guesses remaining in the game. + + The remaining guesses are determined by subtracting the number of incorrect + guesses (`step`) from the maximum allowed guesses (`max_guesses`). + + Returns: + int: The number of guesses the players have left. + """ + return self.max_guesses - self.step + + def add_guesses(self: Self, num_guesses: int) -> None: + """ + Increases the total number of allowed guesses in the game. + + Args: + num_guesses (int): The number of additional guesses to add to the + current maximum allowed guesses. + """ + self.max_guesses += num_guesses + async def can_stop_game(ctx: commands.Context) -> bool: - """Checks if a user has the ability to stop the running game + """ + Checks if a user has the ability to stop the running game Args: ctx (commands.Context): The context in which the stop command was run @@ -221,6 +282,9 @@ async def can_stop_game(ctx: commands.Context) -> bool: raise AttributeError("could not find hangman cog when checking game states") game_data = cog.games.get(ctx.channel.id) + if not game_data: + return True + user = game_data.get("user") if getattr(user, "id", 0) == ctx.author.id: return True @@ -243,10 +307,19 @@ async def can_stop_game(ctx: commands.Context) -> bool: class HangmanCog(cogs.BaseCog): - """Class to define the hangman game.""" + """Class to define the Hangman game. + + Args: + bot (commands.Bot): The bot instance that this cog is a part of. - async def preconfig(self: Self) -> None: - """Method to preconfig the game.""" + Attributes: + games (dict): A dictionary to store ongoing games, where the keys are + player identifiers and the values are the current game state. + hangman_app_group (app_commands.Group): The command group for the Hangman extension. + """ + + def __init__(self: Self, bot: commands.Bot) -> None: + super().__init__(bot) self.games = {} @commands.guild_only() @@ -263,64 +336,85 @@ async def hangman(self: Self, ctx: commands.Context) -> None: # Executed if there are no/invalid args supplied await auxiliary.extension_help(self, ctx, self.__module__[9:]) - @hangman.command( + hangman_app_group: app_commands.Group = app_commands.Group( + name="hangman", description="Command Group for the Hangman Extension" + ) + + @hangman_app_group.command( name="start", - description="Starts a hangman game in the current channel", - usage="[word]", + description="Start a Hangman game in the current channel.", + extras={"module": "hangman"}, ) - async def start_game(self: Self, ctx: commands.Context, word: str) -> None: - """Method to start the hangman game and delete the original message. - This is a command and should be access via discord + async def start_game( + self: Self, interaction: discord.Interaction, word: str + ) -> None: + """Slash command to start a hangman game. Args: - ctx (commands.Context): The context in which the command occured - word (str): The word to state the hangman game with + interaction (discord.Interaction): The interaction object from Discord. + word (str): The word to start the Hangman game with. """ - # delete the message so the word is not seen - await ctx.message.delete() + # Ensure only the command's author can see this interaction + await interaction.response.defer(ephemeral=True) - game_data = self.games.get(ctx.channel.id) + # Check if the provided word is too long + if len(word) >= 85: + await interaction.followup.send( + "The word must be less than 256 characters.", ephemeral=True + ) + return + + # Check if a game is already active in the channel + game_data = self.games.get(interaction.channel_id) if game_data: - # if there is a game currently, - # get user who started it + # Check if the game owner wants to overwrite the current game user = game_data.get("user") - if getattr(user, "id", 0) == ctx.author.id: + if user.id == interaction.user.id: view = ui.Confirm() await view.send( - message=( - "There is a current game in progress. Would you like to end it?" - ), - channel=ctx.channel, - author=ctx.author, + message="There is a current game in progress. Do you want to end it?", + channel=interaction.channel, + author=interaction.user, ) - await view.wait() - if view.value is ui.ConfirmResponse.TIMEOUT: - return - if view.value is ui.ConfirmResponse.DENIED: - await auxiliary.send_deny_embed( - message="The current game was not ended", channel=ctx.channel + if view.value in [ + ui.ConfirmResponse.TIMEOUT, + ui.ConfirmResponse.DENIED, + ]: + await interaction.followup.send( + "The current game was not ended.", ephemeral=True ) return - del self.games[ctx.channel.id] + # Remove the existing game + del self.games[interaction.channel_id] else: - await auxiliary.send_deny_embed( - message="There is a game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + "A game is already in progress for this channel.", ephemeral=True ) return - game = HangmanGame(word=word) - embed = await self.generate_game_embed(ctx, game) - message = await ctx.channel.send(embed=embed) - self.games[ctx.channel.id] = { - "user": ctx.author, + # Validate the provided word + try: + game = HangmanGame(word=word.lower()) + except ValueError as e: + await interaction.followup.send(f"Invalid word: {e}", ephemeral=True) + return + + # Create and send the initial game embed + embed = await self.generate_game_embed(interaction, game) + message = await interaction.channel.send(embed=embed) + self.games[interaction.channel_id] = { + "user": interaction.user, "game": game, "message": message, "last_guesser": None, } + await interaction.followup.send( + "The Hangman game has started with a hidden word!", ephemeral=True + ) + @hangman.command( name="guess", description="Guesses a letter for the current hangman game", @@ -333,22 +427,28 @@ async def guess(self: Self, ctx: commands.Context, letter: str) -> None: ctx (commands.Context): The context in which the command was run in letter (str): The letter the user is trying to guess """ - if len(letter) > 1 or not letter.isalpha(): + game_data = self.games.get(ctx.channel.id) + if not game_data: await auxiliary.send_deny_embed( - message="You can only guess a letter", channel=ctx.channel + message="There is no game in progress for this channel", + channel=ctx.channel, ) return - game_data = self.games.get(ctx.channel.id) - if not game_data: + if ctx.author == game_data.get("user"): await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", + message="You cannot guess letters because you started this game!", channel=ctx.channel, ) return - game = game_data.get("game") + if len(letter) > 1 or not letter.isalpha(): + await auxiliary.send_deny_embed( + message="You can only guess a letter", channel=ctx.channel + ) + return + game = game_data.get("game") if game.guessed(letter): await auxiliary.send_deny_embed( message="That letter has already been guessed", channel=ctx.channel @@ -367,28 +467,46 @@ async def guess(self: Self, ctx: commands.Context, letter: str) -> None: await ctx.send(content=content) async def generate_game_embed( - self: Self, ctx: commands.Context, game: HangmanGame + self: Self, + ctx_or_interaction: discord.Interaction | commands.Context, + game: HangmanGame, ) -> discord.Embed: - """Takes a game state and makes it into a pretty embed - Does not send the embed + """ + Generates an embed representing the current state of the Hangman game. Args: - ctx (commands.Context): The context in which the game command needing - a drawing was called in - game (HangmanGame): The hangman game to draw into an embed + ctx_or_interaction (discord.Interaction | commands.Context): + The context or interaction used to generate the embed, which provides + information about the user and the message. + game (HangmanGame): The current instance of the Hangman game, used to + retrieve game state, including word state, remaining guesses, and the + hangman drawing. Returns: - discord.Embed: The ready and styled embed containing the current state of the game + discord.Embed: An embed displaying the current game state, including + the hangman drawing, word state, remaining guesses, guessed letters, + and the footer indicating the game status and creator. """ hangman_drawing = game.draw_hang_state() hangman_word = game.draw_word_state() + # Determine the guild ID + guild_id = None + if isinstance(ctx_or_interaction, commands.Context): + guild_id = ctx_or_interaction.guild.id if ctx_or_interaction.guild else None + elif isinstance(ctx_or_interaction, discord.Interaction): + guild_id = ctx_or_interaction.guild_id + + # Fetch the prefix manually since get_prefix expects a Message + if guild_id and str(guild_id) in self.bot.guild_configs: + prefix = self.bot.guild_configs[str(guild_id)].command_prefix + else: + prefix = self.file_config.bot_config.default_prefix - prefix = await self.bot.get_prefix(ctx.message) embed = discord.Embed( title=f"`{hangman_word}`", description=( - f"Type `{prefix}help extension hangman` for more info\n\n" - f" ```{hangman_drawing}```" + f"Type `{prefix}help hangman` for more info\n\n" + f"```{hangman_drawing}```" ), ) @@ -400,10 +518,26 @@ async def generate_game_embed( footer_text = "Word guessed! Nice job!" else: embed.color = discord.Color.gold() - footer_text = f"{game.FINAL_STEP - game.step} wrong guesses left!" + embed.add_field( + name=f"Remaining Guesses {str(game.remaining_guesses())}", + value="\u200b", + inline=False, + ) + embed.add_field( + name="Guessed Letters", + value=", ".join(game.guesses) or "None", + inline=False, + ) - embed.set_footer(text=footer_text) + # Determine the game creator based on interaction type + if isinstance(ctx_or_interaction, discord.Interaction): + footer_text = f"Game started by {ctx_or_interaction.user}" + elif isinstance(ctx_or_interaction, commands.Context): + footer_text = f"Game started by {ctx_or_interaction.author}" + else: + footer_text = " " + embed.set_footer(text=footer_text) return embed @hangman.command(name="redraw", description="Redraws the current hangman game") @@ -444,7 +578,8 @@ async def stop(self: Self, ctx: commands.Context) -> None: game_data = self.games.get(ctx.channel.id) if not game_data: await auxiliary.send_deny_embed( - "There is no game in progress for this channel", channel=ctx.channel + message="There is no game in progress for this channel", + channel=ctx.channel, ) return @@ -471,3 +606,59 @@ async def stop(self: Self, ctx: commands.Context) -> None: message=f"That game is now finished. The word was: `{word}`", channel=ctx.channel, ) + + @hangman.command( + name="add_guesses", + description="Allows the creator of the game to give more guesses", + usage="[number_of_guesses]", + ) + async def add_guesses( + self: Self, ctx: commands.Context, number_of_guesses: int + ) -> None: + """Discord command to allow the game creator to add more guesses. + + Args: + ctx (commands.Context): The context in which the command was run. + number_of_guesses (int): The number of guesses to add. + """ + if number_of_guesses <= 0: + await auxiliary.send_deny_embed( + message="The number of guesses must be a positive integer.", + channel=ctx.channel, + ) + return + + game_data = self.games.get(ctx.channel.id) + if not game_data: + await auxiliary.send_deny_embed( + message="There is no game in progress for this channel", + channel=ctx.channel, + ) + return + + # Ensure only the creator of the game can add guesses + game_author = game_data.get("user") + if ctx.author.id != game_author.id: + await auxiliary.send_deny_embed( + message="Only the creator of the game can add more guesses.", + channel=ctx.channel, + ) + return + + game = game_data.get("game") + + # Add the new guesses + game.add_guesses(number_of_guesses) + + # Notify the channel + await ctx.send( + content=( + f"{number_of_guesses} guesses have been added! " + f"Total guesses remaining: {game.remaining_guesses()}" + ) + ) + + # Update the game embed + embed = await self.generate_game_embed(ctx, game) + message = game_data.get("message") + await message.edit(embed=embed) diff --git a/techsupport_bot/commands/hello.py b/techsupport_bot/commands/hello.py index 78fb87753..6c0a1b646 100644 --- a/techsupport_bot/commands/hello.py +++ b/techsupport_bot/commands/hello.py @@ -1,15 +1,17 @@ """ -Module for the hello command on the discord bot. -This module has unit tests -This modules requires no config, no databases, and no APIs +Commands: /hello +Config: None +Databases: None +Unit tests: No need """ from __future__ import annotations from typing import TYPE_CHECKING, Self -from core import auxiliary, cogs -from discord.ext import commands +import discord +from core import cogs +from discord import app_commands if TYPE_CHECKING: import bot @@ -27,26 +29,15 @@ async def setup(bot: bot.TechSupportBot) -> None: class Greeter(cogs.BaseCog): """Class for the greeter command.""" - async def hello_command(self: Self, ctx: commands.Context) -> None: - """A simple function to add HEY reactions to the command invocation - - Args: - ctx (commands.Context): The context in which the command was run in - """ - await auxiliary.add_list_of_reactions( - message=ctx.message, reactions=["🇭", "🇪", "🇾"] - ) - - @commands.command( + @app_commands.command( name="hello", - brief="Says hello to the bot", description="Says hello to the bot (because they are doing such a great job!)", - usage="", + extras={"module": "hello"}, ) - async def hello(self: Self, ctx: commands.Context) -> None: - """Entry point for the .hello command on discord + async def hello_app_command(self: Self, interaction: discord.Interaction) -> None: + """A simple command to have the bot say HEY to the invoker Args: - ctx (commands.Context): The context in which the command was run in + interaction (discord.Interaction): The interaction that called this command """ - await self.hello_command(ctx) + await interaction.response.send_message("🇭 🇪 🇾") diff --git a/techsupport_bot/commands/htd.py b/techsupport_bot/commands/htd.py index 453dba3c3..0d78673a7 100644 --- a/techsupport_bot/commands/htd.py +++ b/techsupport_bot/commands/htd.py @@ -235,7 +235,6 @@ def custom_embed_generation(raw_input: str, val_to_convert: int) -> discord.Embe inline=False, ) - print(embed.fields[0].name) return embed diff --git a/techsupport_bot/commands/joke.py b/techsupport_bot/commands/joke.py index 4e8f0d5ca..ce2892e91 100644 --- a/techsupport_bot/commands/joke.py +++ b/techsupport_bot/commands/joke.py @@ -19,16 +19,24 @@ async def setup(bot: bot.TechSupportBot) -> None: Args: bot (bot.TechSupportBot): The bot object to register the cogs to """ + config = extensionconfig.ExtensionConfig() config.add( - key="pc_jokes", - datatype="bool", - title="Politically correct jokes only", + key="blacklisted_filters", + datatype="list[str]", + title="Enable filter", description=( - "True only politically correct jokes should be shown" - " (non-racist/non-sexist)" + "Filters all categories listed" + "(nsfw,religious,political,racist,sexist,explicit)" ), - default=True, + default=["nsfw", "explicit"], + ) + config.add( + key="apply_in_nsfw_channels", + datatype="bool", + title="Apply in NSFW Channels", + description=("Toggles whether or not filters are applies in NSFW channels"), + default=False, ) await bot.add_cog(Joker(bot=bot)) bot.add_extension_config("joke", config) @@ -75,10 +83,11 @@ def build_url(self: Self, ctx: commands.Context, config: munch.Munch) -> str: str: The URL, properly formatted and ready to be called """ blacklist_flags = [] - if not ctx.channel.is_nsfw(): - blacklist_flags.extend(["explicit", "nsfw"]) - if config.extensions.joke.pc_jokes.value: - blacklist_flags.extend(["sexist", "racist", "religious"]) + if ( + config.extensions.joke.apply_in_nsfw_channels.value + or not ctx.channel.is_nsfw() + ): + blacklist_flags = config.extensions.joke.blacklisted_filters.value blacklists = ",".join(blacklist_flags) url = f"{self.API_URL}?blacklistFlags={blacklists}&format=txt" diff --git a/techsupport_bot/commands/moderator.py b/techsupport_bot/commands/moderator.py new file mode 100644 index 000000000..79322df6d --- /dev/null +++ b/techsupport_bot/commands/moderator.py @@ -0,0 +1,882 @@ +"""Manual moderation commands and helper functions""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Self + +import dateparser +import discord +import ui +from botlogging import LogContext, LogLevel +from commands import modlog +from core import auxiliary, cogs, extensionconfig, moderation +from discord import app_commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="immune_roles", + datatype="list", + title="Immune role names", + description="The list of role names that are immune to protect commands", + default=[], + ) + config.add( + key="ban_delete_duration", + datatype="int", + title="Ban delete duration (days)", + description=( + "The default amount of days to delete messages for a user after they are banned" + ), + default=7, + ) + await bot.add_cog(ProtectCommands(bot=bot, extension_name="moderator")) + bot.add_extension_config("moderator", config) + + +class ProtectCommands(cogs.BaseCog): + """The cog for all manual moderation activities + These are all slash commands + + Attributes: + warnings_group (app_commands.Group): The group for the /warning commands + """ + + warnings_group: app_commands.Group = app_commands.Group( + name="warning", description="...", extras={"module": "moderator"} + ) + + # Commands + + @app_commands.checks.has_permissions(ban_members=True) + @app_commands.checks.bot_has_permissions(ban_members=True) + @app_commands.command( + name="ban", + description="Bans a user from the guild", + extras={"module": "moderator"}, + ) + async def handle_ban_user( + self: Self, + interaction: discord.Interaction, + target: discord.User, + reason: str, + delete_days: app_commands.Range[int, 0, 7] = None, + ) -> None: + """The ban slash command. This checks that the permissions are correct + and that the user is not already banned + + Args: + interaction (discord.Interaction): The interaction that called this command + target (discord.User): The target to ban + reason (str): The reason the person is getting banned + delete_days (app_commands.Range[int, 0, 7], optional): How many days of + messages to delete. Defaults to None. + """ + # Ensure we can ban the person + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="ban" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Ban reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + is_banned = await moderation.check_if_user_banned(target, interaction.guild) + if is_banned: + embed = auxiliary.prepare_deny_embed(message="User is already banned.") + await interaction.response.send_message(embed=embed) + return + + if not delete_days: + config = self.bot.guild_configs[str(interaction.guild.id)] + delete_days = config.extensions.moderator.ban_delete_duration.value + + # Ban the user using the core moderation cog + result = await moderation.ban_user( + guild=interaction.guild, + user=target, + delete_seconds=delete_days * 86400, + reason=f"{reason} - banned by {interaction.user}", + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when banning {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await modlog.log_ban( + self.bot, target, interaction.user, interaction.guild, reason + ) + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=( + f"/ban target: {target.display_name}, reason: {reason}, delete_days:" + f" {delete_days}" + ), + guild=interaction.guild, + target=target, + ) + embed = generate_response_embed(user=target, action="ban", reason=reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(ban_members=True) + @app_commands.checks.bot_has_permissions(ban_members=True) + @app_commands.command( + name="unban", + description="Unbans a user from the guild", + extras={"module": "moderator"}, + ) + async def handle_unban_user( + self: Self, interaction: discord.Interaction, target: discord.User, reason: str + ) -> None: + """The logic for the /unban command + + Args: + interaction (discord.Interaction): The interaction that triggered this command + target (discord.User): The target to be unbanned + reason (str): The reason for the user being unbanned + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="unban" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Unban reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + is_banned = await moderation.check_if_user_banned(target, interaction.guild) + + if not is_banned: + embed = auxiliary.prepare_deny_embed(message=f"{target} is not banned") + await interaction.response.send_message(embed=embed) + return + + result = await moderation.unban_user( + guild=interaction.guild, + user=target, + reason=f"{reason} - unbanned by {interaction.user}", + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when unbanning {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await modlog.log_unban( + self.bot, target, interaction.user, interaction.guild, reason + ) + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/unban target: {target.display_name}, reason: {reason}", + guild=interaction.guild, + target=target, + ) + embed = generate_response_embed(user=target, action="unban", reason=reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + @app_commands.command( + name="kick", + description="Kicks a user from the guild", + extras={"module": "moderator"}, + ) + async def handle_kick_user( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + ) -> None: + """The core logic for the /kick command + + Args: + interaction (discord.Interaction): The interaction that triggered the command + target (discord.Member): The target for being kicked + reason (str): The reason for the user being kicked + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="kick" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Kick reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + result = await moderation.kick_user( + guild=interaction.guild, + user=target, + reason=f"{reason} - kicked by {interaction.user}", + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when kicking {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/kick target: {target.display_name}, reason: {reason}", + guild=interaction.guild, + target=target, + ) + embed = generate_response_embed(user=target, action="kick", reason=reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(moderate_members=True) + @app_commands.checks.bot_has_permissions(moderate_members=True) + @app_commands.command( + name="mute", description="Times out a user", extras={"module": "moderator"} + ) + async def handle_mute_user( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + duration: str = None, + ) -> None: + """The core logic for the /mute command + + Args: + interaction (discord.Interaction): The interaction that triggered this command + target (discord.Member): The target for being muted + reason (str): The reason for being muted + duration (str, optional): The human readable duration to be muted for. Defaults to None. + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="mute" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Mute reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + # The API prevents administrators from being timed out. Check it here + if target.guild_permissions.administrator: + embed = auxiliary.prepare_deny_embed( + message=( + "Someone with the `administrator` permissions cannot be timed out" + ) + ) + await interaction.response.send_message(embed=embed) + return + + delta_duration = None + + if duration: + # The date parser defaults to time in the past, so it is second + # This could be fixed by appending "in" to your query, but this is simpler + try: + delta_duration = datetime.now() - dateparser.parse(duration) + delta_duration = timedelta( + seconds=round(delta_duration.total_seconds()) + ) + except TypeError: + embed = auxiliary.prepare_deny_embed(message="Invalid duration") + await interaction.response.send_message(embed=embed) + return + if not delta_duration: + embed = auxiliary.prepare_deny_embed(message="Invalid duration") + await interaction.response.send_message(embed=embed) + return + else: + delta_duration = timedelta(hours=1) + + # Checks to ensure time is valid and within the scope of the API + if delta_duration > timedelta(days=28): + embed = auxiliary.prepare_deny_embed( + message="Timeout duration cannot be more than 28 days" + ) + await interaction.response.send_message(embed=embed) + return + if delta_duration < timedelta(seconds=1): + embed = auxiliary.prepare_deny_embed( + message="Timeout duration cannot be less than 1 second" + ) + await interaction.response.send_message(embed=embed) + return + + result = await moderation.mute_user( + user=target, + reason=f"{reason} - muted by {interaction.user}", + duration=delta_duration, + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when muting {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=( + f"/mute target: {target.display_name}, reason: {reason}, duration:" + f" {duration}" + ), + guild=interaction.guild, + target=target, + ) + + muted_until_timestamp = ( + f"" + ) + + full_reason = f"{reason} (muted until {muted_until_timestamp})" + + embed = generate_response_embed(user=target, action="mute", reason=full_reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(moderate_members=True) + @app_commands.checks.bot_has_permissions(moderate_members=True) + @app_commands.command( + name="unmute", + description="Removes timeout from a user", + extras={"module": "moderator"}, + ) + async def handle_unmute_user( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + ) -> None: + """The core logic for the /unmute command + + Args: + interaction (discord.Interaction): The interaction that triggered this command + target (discord.Member): The target for being unmuted + reason (str): The reason for the unmute + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="unmute" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Unmute reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + if not target.timed_out_until: + embed = auxiliary.prepare_deny_embed( + message=(f"{target} is not currently muted") + ) + await interaction.response.send_message(embed=embed) + return + + result = await moderation.unmute_user( + user=target, + reason=f"{reason} - unmuted by {interaction.user}", + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when unmuting {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/unmute target: {target.display_name}, reason: {reason}", + guild=interaction.guild, + target=target, + ) + embed = generate_response_embed(user=target, action="unmute", reason=reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + @app_commands.command( + name="warn", description="Warns a user", extras={"module": "moderator"} + ) + async def handle_warn_user( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + ) -> None: + """The core logic for the /warn command + + Args: + interaction (discord.Interaction): The interaction that triggered this command + target (discord.Member): The target for being warned + reason (str): The reason the user is being warned + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="warn" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Warn reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + if target not in interaction.channel.members: + embed = auxiliary.prepare_deny_embed( + message=f"{target} cannot see this warning. No warning was added." + ) + await interaction.response.send_message(embed=embed) + return + + config = self.bot.guild_configs[str(interaction.guild.id)] + + new_count_of_warnings = ( + len(await moderation.get_all_warnings(self.bot, target, interaction.guild)) + + 1 + ) + + should_ban = False + if new_count_of_warnings >= config.moderation.max_warnings: + await interaction.response.defer(ephemeral=False) + view = ui.Confirm() + await view.send( + message="This user has exceeded the max warnings of " + + f"{config.moderation.max_warnings}. Would " + + "you like to ban them instead?", + channel=interaction.channel, + author=interaction.user, + interaction=interaction, + ) + await view.wait() + if view.value is ui.ConfirmResponse.CONFIRMED: + should_ban = True + + warn_result = await moderation.warn_user( + bot_object=self.bot, user=target, invoker=interaction.user, reason=reason + ) + + if should_ban: + ban_result = await moderation.ban_user( + guild=interaction.guild, + user=target, + delete_seconds=( + config.extensions.moderator.ban_delete_duration.value * 86400 + ), + reason=( + f"Over max warning count {new_count_of_warnings} out of" + f" {config.moderation.max_warnings} (final warning:" + f" {reason}) - banned by {interaction.user}" + ), + ) + if not ban_result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when banning {target}" + ) + if interaction.response.is_done(): + await interaction.followup.send(embed=embed) + else: + await interaction.response.send_message(embed=embed) + return + await modlog.log_ban( + self.bot, target, interaction.user, interaction.guild, reason + ) + + if not warn_result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when warning {target}" + ) + if interaction.response.is_done(): + await interaction.followup.send(embed=embed) + else: + await interaction.response.send_message(embed=embed) + return + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/warn target: {target.display_name}, reason: {reason}", + guild=interaction.guild, + target=target, + ) + + embed = generate_response_embed( + user=target, + action="warn", + reason=f"{reason} ({new_count_of_warnings} total warnings)", + ) + if interaction.response.is_done(): + await interaction.followup.send(content=target.mention, embed=embed) + else: + await interaction.response.send_message(content=target.mention, embed=embed) + + try: + await target.send(embed=embed) + except (discord.HTTPException, discord.Forbidden): + channel = config.get("logging_channel") + await self.bot.logger.send_log( + message=f"Failed to DM warning to {target}", + level=LogLevel.WARNING, + channel=channel, + context=LogContext( + guild=interaction.guild, channel=interaction.channel + ), + ) + + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + @app_commands.command( + name="unwarn", description="Unwarns a user", extras={"module": "moderator"} + ) + async def handle_unwarn_user( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + warning: str, + ) -> None: + """The core logic of the /unwarn command + + Args: + interaction (discord.Interaction): The interaction that triggered the command + target (discord.Member): The user being unwarned + reason (str): The reason for the unwarn + warning (str): The exact string of the warning + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="unwarn" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Unwarn reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + database_warning = await self.get_warning(user=target, warning=warning) + + if not database_warning: + embed = auxiliary.prepare_deny_embed( + message=f"{warning} was not found on {target}" + ) + await interaction.response.send_message(embed=embed) + return + + result = await moderation.unwarn_user( + bot_object=self.bot, user=target, warning=warning + ) + if not result: + embed = auxiliary.prepare_deny_embed( + message=f"Something went wrong when unwarning {target}" + ) + await interaction.response.send_message(embed=embed) + return + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/unwarn target: {target.display_name}, reason: {reason}, warning: {warning}", + guild=interaction.guild, + target=target, + ) + embed = generate_response_embed(user=target, action="unwarn", reason=reason) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + @warnings_group.command( + name="clear", + description="clears all warnings from a user", + extras={"module": "moderator"}, + ) + async def handle_warning_clear( + self: Self, + interaction: discord.Interaction, + target: discord.Member, + reason: str, + ) -> None: + """The core logic of the /warnings clear command + + Args: + interaction (discord.Interaction): The interaction that triggered the command + target (discord.Member): The user having warnings cleared + reason (str): The reason for the warnings being cleared + """ + permission_check = await self.permission_check( + invoker=interaction.user, target=target, action_name="unwarn" + ) + if permission_check: + embed = auxiliary.prepare_deny_embed(message=permission_check) + await interaction.response.send_message(embed=embed) + return + + if len(reason) > 500: + embed = auxiliary.prepare_deny_embed( + message="Reason must be under 500 characters" + ) + await interaction.response.send_message(embed=embed) + return + + warnings = await moderation.get_all_warnings( + self.bot, target, interaction.guild + ) + + if not warnings: + embed = auxiliary.prepare_deny_embed(message=f"{target} has no warnings") + await interaction.response.send_message(embed=embed) + return + + for warning in warnings: + await moderation.unwarn_user(self.bot, target, warning.reason) + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/warnings clear target: {target.display_name}, reaason: {reason}", + guild=interaction.guild, + target=target, + ) + + embed = generate_response_embed( + user=target, action="warnings clear", reason=reason + ) + await interaction.response.send_message(content=target.mention, embed=embed) + + @app_commands.checks.has_permissions(kick_members=True) + @app_commands.checks.bot_has_permissions(kick_members=True) + @warnings_group.command( + name="all", + description="Shows all warnings to the invoker", + extras={"module": "moderator"}, + ) + async def handle_warning_all( + self: Self, + interaction: discord.Interaction, + target: discord.User, + ) -> None: + """The core logic of the /warnings all command + + Args: + interaction (discord.Interaction): The interaction that triggered the command + target (discord.User): The user to lookup warnings for + """ + warnings = await moderation.get_all_warnings( + self.bot, target, interaction.guild + ) + + embeds = build_warning_embeds(interaction.guild, target, warnings) + + await interaction.response.defer(ephemeral=True) + view = ui.PaginateView() + await view.send( + interaction.channel, interaction.user, embeds, interaction, True + ) + + # Helper functions + + async def permission_check( + self: Self, + invoker: discord.Member, + target: discord.User | discord.Member, + action_name: str, + ) -> str: + """Checks permissions to ensure the command should be executed. This checks: + If the target is the invoker + If the target is the bot + If the user is in the server + If the target has an immune role + If the target can be banned by the bot + If the invoker has a higher role than the target + + Args: + invoker (discord.Member): The invoker of the action. + Either will be the user who ran the command, or the bot itself + target (discord.User | discord.Member): The target of the command. + Can be a user or member. + action_name (str): The action name to be displayed in messages + + Returns: + str: The rejection string, if one exists. Otherwise, None is returned + """ + config = self.bot.guild_configs[str(invoker.guild.id)] + # Check to see if executed on author + if invoker == target: + return f"You cannot {action_name} yourself" + + # Check to see if executed on bot + if target == self.bot.user: + return f"It would be silly to {action_name} myself" + + # Check to see if User or Member + if isinstance(target, discord.User): + return None + + # Check to see if target has any immune roles + try: + for name in config.extensions.moderator.immune_roles.value: + role_check = discord.utils.get(target.guild.roles, name=name) + if role_check and role_check in getattr(target, "roles", []): + return ( + f"You cannot {action_name} {target} because they have" + f" `{role_check}` role" + ) + except AttributeError: + pass + + # Check to see if the Bot can execute on the target + if invoker.guild.get_member(int(self.bot.user.id)).top_role <= target.top_role: + return f"Bot does not have enough permissions to {action_name} `{target}`" + + # Check to see if author top role is higher than targets + if invoker.top_role <= target.top_role: + return f"You do not have enough permissions to {action_name} `{target}`" + + return None + + # Database functions + + async def get_warning( + self: Self, user: discord.Member, warning: str + ) -> bot.models.Warning: + """Gets a specific warning by string for a user + + Args: + user (discord.Member): The user to get the warning for + warning (str): The warning to look for + + Returns: + bot.models.Warning: If it exists, the warning object + """ + query = ( + self.bot.models.Warning.query.where( + self.bot.models.Warning.guild_id == str(user.guild.id) + ) + .where(self.bot.models.Warning.reason == warning) + .where(self.bot.models.Warning.user_id == str(user.id)) + ) + entry = await query.gino.first() + return entry + + +def generate_response_embed( + user: discord.Member, action: str, reason: str +) -> discord.Embed: + """This generates a simple embed to be displayed in the chat where the command was called. + + Args: + user (discord.Member): The user who was actioned against + action (str): The string representation of the action type + reason (str): The reason the action was taken + + Returns: + discord.Embed: The formatted embed ready to be sent + """ + embed = discord.Embed( + title="Chat Protection", description=f"{action.upper()} `{user}`" + ) + embed.add_field(name="Reason", value=reason) + embed.set_thumbnail(url=user.display_avatar.url) + embed.color = discord.Color.gold() + + return embed + + +def build_warning_embeds( + guild: discord.Guild, + member: discord.Member, + warnings: list[bot.models.UserNote], +) -> list[discord.Embed]: + """Makes a list of embeds with 6 warnings per page, for a given user + + Args: + guild (discord.Guild): The guild where the warnings occured + member (discord.Member): The member whose warnings are being looked for + warnings (list[bot.models.UserNote]): The list of warnings from the database + + Returns: + list[discord.Embed]: The list of well formatted embeds + """ + embed = auxiliary.generate_basic_embed( + f"Warnings for `{member.display_name}` (`{member.name}`)", + color=discord.Color.dark_blue(), + ) + embed.set_footer(text=f"{len(warnings)} total warns.") + + embeds = [] + + if not warnings: + embed.description = "No warnings" + return [embed] + + for index, warn in enumerate(warnings): + if index % 6 == 0 and index > 0: + embeds.append(embed) + embed = auxiliary.generate_basic_embed( + f"Warnings for `{member.display_name}` (`{member.name}`)", + color=discord.Color.dark_blue(), + ) + embed.set_footer(text=f"{len(warnings)} total warns.") + warn_author = "Unknown" + if warn.invoker_id: + warn_author = warn.invoker_id + author = guild.get_member(int(warn.invoker_id)) + if author: + warn_author = author.name + embed.add_field( + name=f"Warned by {warn_author}", + value=f"{warn.reason}\nWarned ", + ) + embeds.append(embed) + return embeds diff --git a/techsupport_bot/commands/modlog.py b/techsupport_bot/commands/modlog.py new file mode 100644 index 000000000..fa55633ef --- /dev/null +++ b/techsupport_bot/commands/modlog.py @@ -0,0 +1,371 @@ +"""Commands and functions to log and interact with logs of bans and unbans""" + +from __future__ import annotations + +import datetime +from collections import Counter +from typing import TYPE_CHECKING, Self + +import discord +import munch +import ui +from core import auxiliary, cogs, extensionconfig +from discord import app_commands +from discord.ext import commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="alert_channel", + datatype="int", + title="Alert channel ID", + description="The ID of the channel to send auto-protect alerts to", + default=None, + ) + await bot.add_cog(BanLogger(bot=bot, extension_name="modlog")) + bot.add_extension_config("modlog", config) + + +class BanLogger(cogs.BaseCog): + """The class that holds the /modlog commands + + Attributes: + modlog_group (app_commands.Group): The group for the /modlog commands + """ + + modlog_group: app_commands.Group = app_commands.Group( + name="modlog", description="...", extras={"module": "modlog"} + ) + + @modlog_group.command( + name="highscores", + description="Shows the top 10 moderators based on ban count", + extras={"module": "modlog"}, + ) + async def high_score_command(self: Self, interaction: discord.Interaction) -> None: + """Gets the top 10 moderators based on banned user count + + Args: + interaction (discord.Interaction): The interaction that started this command + """ + all_bans = await self.bot.models.BanLog.query.where( + self.bot.models.BanLog.guild_id == str(interaction.guild.id) + ).gino.all() + ban_frequency_counter = Counter(ban.banning_moderator for ban in all_bans) + + sorted_ban_frequency = sorted( + ban_frequency_counter.items(), key=lambda x: x[1], reverse=True + ) + embed = discord.Embed(title="Most active moderators") + + final_string = "" + for index, (moderator_id, count) in enumerate(sorted_ban_frequency): + moderator = await interaction.guild.fetch_member(int(moderator_id)) + if moderator: + final_string += ( + f"{index+1}. {moderator.display_name} " + f"{moderator.mention} ({moderator.id}) - ({count})\n" + ) + else: + final_string += {f"{index+1}. Moderator left: {moderator_id}"} + + embed.description = final_string + embed.color = discord.Color.blue() + await interaction.response.send_message(embed=embed) + + @modlog_group.command( + name="lookup-user", + description="Looks up the 10 most recent bans for a given user", + extras={"module": "modlog"}, + ) + async def lookup_user_command( + self: Self, interaction: discord.Interaction, user: discord.User + ) -> None: + """This is the core of the /modlog lookup-user command + + Args: + interaction (discord.Interaction): The interaction that called the command + user (discord.User): The user to search for bans for + """ + + await interaction.response.defer(ephemeral=False) + + all_bans_by_user = ( + await self.bot.models.BanLog.query.where( + self.bot.models.BanLog.guild_id == str(interaction.guild.id) + ) + .where(self.bot.models.BanLog.banned_member == str(user.id)) + .order_by(self.bot.models.BanLog.ban_time.desc()) + .gino.all() + ) + + embeds = [] + for ban in all_bans_by_user[:10]: + temp_embed = await self.convert_ban_to_pretty_string( + ban, f"{user.name} bans" + ) + temp_embed.description += f"\n**Total bans:** {len(all_bans_by_user)}" + embeds.append(temp_embed) + + if len(embeds) == 0: + embed = auxiliary.prepare_deny_embed( + f"No bans for the user {user.name} could be found" + ) + await interaction.followup.send(embed=embed) + return + + view = ui.PaginateView() + await view.send(interaction.channel, interaction.user, embeds, interaction) + + @modlog_group.command( + name="lookup-moderator", + description="Looks up the 10 most recent bans by a given moderator", + extras={"module": "modlog"}, + ) + async def lookup_moderator_command( + self: Self, interaction: discord.Interaction, moderator: discord.Member + ) -> None: + """This is the core of the /modlog lookup-moderator command + + Args: + interaction (discord.Interaction): The interaction that called the command + moderator (discord.Member): The moderator to search for bans for + """ + await interaction.response.defer(ephemeral=False) + + all_bans_by_user = ( + await self.bot.models.BanLog.query.where( + self.bot.models.BanLog.guild_id == str(interaction.guild.id) + ) + .where(self.bot.models.BanLog.banning_moderator == str(moderator.id)) + .order_by(self.bot.models.BanLog.ban_time.desc()) + .gino.all() + ) + + embeds = [] + for ban in all_bans_by_user[:10]: + temp_embed = await self.convert_ban_to_pretty_string( + ban, f"Bans by {moderator.name}" + ) + temp_embed.description += f"\n**Total bans:** {len(all_bans_by_user)}" + embeds.append(temp_embed) + + if len(embeds) == 0: + embed = auxiliary.prepare_deny_embed( + f"No bans by the user {moderator.name} could be found" + ) + await interaction.followup.send(embed=embed) + return + + view = ui.PaginateView() + await view.send(interaction.channel, interaction.user, embeds, interaction) + + async def convert_ban_to_pretty_string( + self: Self, ban: munch.Munch, title: str + ) -> discord.Embed: + """This converts a database ban entry into a shiny embed + + Args: + ban (munch.Munch): The ban database entry + title (str): The title to set the embeds to + + Returns: + discord.Embed: The fancy embed + """ + member = await self.bot.fetch_user(int(ban.banned_member)) + moderator = await self.bot.fetch_user(int(ban.banning_moderator)) + embed = discord.Embed(title=title) + embed.description = ( + f"**Case:** {ban.pk}\n" + f"**Offender:** {member.name} {member.mention}\n" + f"**Reason:** {ban.reason}\n" + f"**Responsible moderator:** {moderator.name} {moderator.mention}" + ) + embed.timestamp = ban.ban_time + embed.color = discord.Color.red() + return embed + + @commands.Cog.listener() + async def on_member_ban( + self: Self, guild: discord.Guild, user: discord.User | discord.Member + ) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_ban + + Args: + guild (discord.Guild): The guild the user got banned from + user (discord.User | discord.Member): The user that got banned. Can be either User + or Member depending if the user was in the guild or not at the time of removal. + """ + # Wait a short time to ensure the audit log has been updated + await discord.utils.sleep_until( + discord.utils.utcnow() + datetime.timedelta(seconds=2) + ) + + config = self.bot.guild_configs[str(guild.id)] + if not self.extension_enabled(config): + return + + entry = None + moderator = None + async for entry in guild.audit_logs( + limit=10, action=discord.AuditLogAction.ban + ): + if entry.target.id == user.id: + moderator = entry.user + break + + if not entry: + return + + if not moderator or moderator.bot: + return + + await log_ban(self.bot, user, moderator, guild, entry.reason) + + @commands.Cog.listener() + async def on_member_unban( + self: Self, guild: discord.Guild, user: discord.User + ) -> None: + """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_member_unban + + Args: + guild (discord.Guild): The guild the user got unbanned from + user (discord.User): The user that got unbanned + """ + # Wait a short time to ensure the audit log has been updated + await discord.utils.sleep_until( + discord.utils.utcnow() + datetime.timedelta(seconds=2) + ) + + config = self.bot.guild_configs[str(guild.id)] + if not self.extension_enabled(config): + return + + entry = None + moderator = None + async for entry in guild.audit_logs( + limit=10, action=discord.AuditLogAction.unban + ): + if entry.target.id == user.id: + moderator = entry.user + if not entry: + return + + if not moderator or moderator.bot: + return + + await log_unban(self.bot, user, moderator, guild, entry.reason) + + +# Any bans initiated by TS will come through this +async def log_ban( + bot: bot.TechSupportBot, + banned_member: discord.User | discord.Member, + banning_moderator: discord.Member, + guild: discord.Guild, + reason: str, +) -> None: + """Logs a ban into the alert channel + + Args: + bot (bot.TechSupportBot): The bot object to use for the logging + banned_member (discord.User | discord.Member): The member who was banned + banning_moderator (discord.Member): The moderator who banned the member + guild (discord.Guild): The guild the member was banned from + reason (str): The reason for the ban + """ + config = bot.guild_configs[str(guild.id)] + if "modlog" not in config.get("enabled_extensions", []): + return + + if not reason: + reason = "No reason specified" + + ban = bot.models.BanLog( + guild_id=str(guild.id), + reason=reason, + banning_moderator=str(banning_moderator.id), + banned_member=str(banned_member.id), + ) + ban = await ban.create() + + embed = discord.Embed(title=f"ban | case {ban.pk}") + embed.description = ( + f"**Offender:** {banned_member.name} {banned_member.mention}\n" + f"**Reason:** {reason}\n" + f"**Responsible moderator:** {banning_moderator.name} {banning_moderator.mention}" + ) + embed.set_footer(text=f"ID: {banned_member.id}") + embed.timestamp = datetime.datetime.utcnow() + embed.color = discord.Color.red() + + config = bot.guild_configs[str(guild.id)] + + try: + alert_channel = guild.get_channel( + int(config.extensions.modlog.alert_channel.value) + ) + except TypeError: + alert_channel = None + + if not alert_channel: + return + + await alert_channel.send(embed=embed) + + +async def log_unban( + bot: bot.TechSupportBot, + unbanned_member: discord.User | discord.Member, + unbanning_moderator: discord.Member, + guild: discord.Guild, + reason: str, +) -> None: + """Logs an unban into the alert channel + + Args: + bot (bot.TechSupportBot): The bot object to use for the logging + unbanned_member (discord.User | discord.Member): The member who was unbanned + unbanning_moderator (discord.Member): The moderator who unbanned the member + guild (discord.Guild): The guild the member was unbanned from + reason (str): The reason for the unban + """ + config = bot.guild_configs[str(guild.id)] + if "modlog" not in config.get("enabled_extensions", []): + return + + if not reason: + reason = "No reason specified" + + embed = discord.Embed(title="unban") + embed.description = ( + f"**Offender:** {unbanned_member.name} {unbanned_member.mention}\n" + f"**Reason:** {reason}\n" + f"**Responsible moderator:** {unbanning_moderator.name} {unbanning_moderator.mention}" + ) + embed.set_footer(text=f"ID: {unbanned_member.id}") + embed.timestamp = datetime.datetime.utcnow() + embed.color = discord.Color.green() + + config = bot.guild_configs[str(guild.id)] + + try: + alert_channel = guild.get_channel( + int(config.extensions.modlog.alert_channel.value) + ) + except TypeError: + alert_channel = None + + if not alert_channel: + return + + await alert_channel.send(embed=embed) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index fbb11cf70..86f6301ae 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -153,7 +153,6 @@ async def on_message_edit( isinstance(before.channel, discord.DMChannel) and before.author.id in active_threads ): - if await Ts_client.models.ModmailBan.query.where( Ts_client.models.ModmailBan.user_id == str(before.author.id) ).gino.first(): @@ -452,7 +451,6 @@ async def create_thread( if not thread.name.startswith("[OPEN]") and thread.name.split("|")[ -1 ].strip() == str(user.id): - past_thread_count += 1 if past_thread_count == 0: @@ -1068,23 +1066,23 @@ async def on_message(self: Self, message: discord.Message) -> None: # - Replies - case "reply": - await message.delete() await reply_to_thread( raw_contents=content[5:], message=message, thread=message.channel, anonymous=False, ) + await message.delete() return case "areply": - await message.delete() await reply_to_thread( raw_contents=content[6:], message=message, thread=message.channel, anonymous=True, ) + await message.delete() return # Sends a factoid @@ -1197,7 +1195,6 @@ async def contact(self: Self, ctx: commands.Context, user: discord.User) -> None ) case ui.ConfirmResponse.CONFIRMED: - # Makes sure the user can reply if they were timed out from creating threads if user.id in delayed_people: del delayed_people[user.id] @@ -1207,9 +1204,12 @@ async def contact(self: Self, ctx: commands.Context, user: discord.User) -> None user=user, source_channel=ctx.channel, ): - await auxiliary.send_confirm_embed( - message="Thread successfully created!", channel=ctx.channel + message=( + "Thread successfully created! " + f"{self.bot.get_channel(active_threads[user.id]).mention}" + ), + channel=ctx.channel, ) @auxiliary.with_typing @@ -1259,7 +1259,6 @@ async def selfcontact(self: Self, ctx: commands.Context) -> None: ) case ui.ConfirmResponse.CONFIRMED: - # Makes sure the user can reply if they were timed out from creating threads if ctx.author in delayed_people: del delayed_people[ctx.author.id] @@ -1269,9 +1268,12 @@ async def selfcontact(self: Self, ctx: commands.Context) -> None: user=ctx.author, source_channel=ctx.channel, ): - await auxiliary.send_confirm_embed( - message="Thread successfully created!", channel=ctx.channel + message=( + f"Thread successfully created! " + f"{self.bot.get_channel(active_threads[ctx.author.id]).mention}" + ), + channel=ctx.channel, ) @commands.group(name="modmail") @@ -1381,11 +1383,13 @@ async def list_aliases(self: Self, ctx: commands.context) -> None: # Checks if the command was an alias aliases = config.extensions.modmail.aliases.value if not aliases: - embed = auxiliary.generate_basic_embed( - color=discord.Color.green(), - description="There are no aliases registered for this guild", + embed = auxiliary.prepare_deny_embed( + message="There are no aliases registered for this guild", ) + await ctx.channel.send(embed=embed) + return + embed = discord.Embed( color=discord.Color.green(), title="Registered aliases for this guild:" ) @@ -1505,3 +1509,38 @@ async def modmail_unban( message=f"{user.mention} was successfully unbanned from creating modmail threads!", channel=ctx.channel, ) + + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @modmail.command( + name="bans", + description="Lists the users who are banned from using modmail", + ) + async def modmail_list_bans(self: Self, ctx: commands.Context) -> None: + """Lists the users who are banned from using modmail + + Args: + ctx (commands.Context): Context of the command execution + """ + bans = await self.bot.models.ModmailBan.query.gino.all() + if not bans: + embed = auxiliary.generate_basic_embed( + color=discord.Color.green(), + description="There are no modmail bans", + ) + await ctx.channel.send(embed=embed) + return + + embed_description = "" + + for ban in bans: + user: discord.User = await self.bot.fetch_user(ban.user_id) + embed_description += f"{user.mention} - `{user}`\n" + + embed: discord.Embed = discord.Embed( + color=discord.Color.green(), + title="Modmail Bans:", + description=embed_description, + ) + + await ctx.channel.send(embed=embed) diff --git a/techsupport_bot/commands/news.py b/techsupport_bot/commands/news.py index a5959526d..957d12629 100644 --- a/techsupport_bot/commands/news.py +++ b/techsupport_bot/commands/news.py @@ -10,8 +10,8 @@ import discord import munch from botlogging import LogContext, LogLevel -from core import auxiliary, cogs, extensionconfig -from discord.ext import commands +from core import cogs, extensionconfig +from discord import app_commands if TYPE_CHECKING: import bot @@ -108,13 +108,17 @@ async def preconfig(self: Self) -> None: self.valid_category.append(item.value) async def get_headlines( - self: Self, country_code: str, category: str = None + self: Self, + country_code: str, + category: str = None, + is_interaction: bool = False, ) -> list[munch.Munch]: """Calls the API to get the list of headlines based on the category and country Args: country_code (str): The country code to get headlines from category (str, optional): The category of headlines to get. Defaults to None. + is_interaction (bool): If the headline is being called from an interaction Returns: list[munch.Munch]: The list of article objects from the API @@ -126,7 +130,9 @@ async def get_headlines( if category: url = f"{url}&category={category}" - response = await self.bot.http_functions.http_call("get", url) + response = await self.bot.http_functions.http_call( + "get", url, use_app_error=is_interaction + ) articles = response.get("articles") if not articles: @@ -134,19 +140,37 @@ async def get_headlines( return articles async def get_random_headline( - self: Self, country_code: str, category: str = None + self: Self, + country_code: str, + category: str = None, + is_interaction: bool = False, ) -> munch.Munch: """Gets a single article object from the news API Args: country_code (str): The country code of the headliens to get category (str, optional): The category of headlines to get. Defaults to None. + is_interaction (bool): If the headline is being called from an interaction Returns: munch.Munch: The raw API object representing a news headline """ - articles = await self.get_headlines(country_code, category) - return random.choice(articles) + + articles = await self.get_headlines(country_code, category, is_interaction) + + # Filter out articles with URLs containing "removed.com" + filtered_articles = [] + for article in articles: + url = article.get("url", "") + if url != "https://removed.com": + filtered_articles.append(article) + + # Check if there are any articles left after filtering + if not filtered_articles: + return None + + # Choose a random article from the filtered list + return random.choice(filtered_articles) async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None: """Loop entry point for the news command @@ -168,6 +192,9 @@ async def execute(self: Self, config: munch.Munch, guild: discord.Guild) -> None ) url = article.get("url") + if article is None: + return + log_channel = config.get("logging_channel") await self.bot.logger.send_log( message=f"Sending news headline to #{channel.name}", @@ -187,48 +214,79 @@ async def wait(self: Self, config: munch.Munch, _: discord.Guild) -> None: """ await aiocron.crontab(config.extensions.news.cron_config.value).next() - @commands.group( - brief="Executes a news command", - description="Executes a news command", - ) - async def news(self: Self, ctx: commands.Context) -> None: - """The bare .news command. This does nothing but generate the help message - - Args: - ctx (commands.Context): The context in which the command was run in - """ - - # Executed if there are no/invalid args supplied - await auxiliary.extension_help(self, ctx, self.__module__[9:]) - - @news.command( - name="random", - brief="Gets a random news article", + @app_commands.command( + name="news", description="Gets a random news headline", - usage="[category] (optional)", + extras={"module": "news"}, ) - async def random(self: Self, ctx: commands.Context, category: str = None) -> None: + async def news_command( + self: Self, interaction: discord.Interaction, category: str = "" + ) -> None: """Discord command entry point for getting a news article Args: - ctx (commands.Context): The context in which the command was run + interaction (discord.Interaction): The interaction in which the command was run category (str, optional): The category to get news headlines from. Defaults to None. """ + + # Debug statement + print("Executing news command") if category is None or category.lower() not in self.valid_category: category = random.choice(list(Category)).value else: category.lower() - config = self.bot.guild_configs[str(ctx.guild.id)] + config = self.bot.guild_configs[str(interaction.guild.id)] url = None while not url: article = await self.get_random_headline( - config.extensions.news.country.value, category + config.extensions.news.country.value, category, True ) url = article.get("url") + if article is None: + return + if url.endswith("/"): url = url[:-1] - await ctx.send(content=url) + await interaction.response.send_message(content=url) + + # Log the command execution + log_channel = config.get("logging_channel") + if log_channel: + await self.bot.logger.send_log( + message=( + f"News command executed: " + f"Sent a news headline to {interaction.channel.name}" + ), + level=LogLevel.INFO, + context=LogContext( + guild=interaction.guild, channel=interaction.channel + ), + channel=log_channel, + ) + + @news_command.autocomplete("category") + async def news_autocompletion( + self: Self, interaction: discord.Interaction, current: str + ) -> list: + """This command creates a list of categories for autocomplete the news command. + + Args: + interaction (discord.Interaction): The interaction that started the command + current (str): The current input from the user. + + Returns: + list: The list of autocomplete for the news command. + """ + # Debug statement + print("Autocomplete interaction") + news_category = [] + for category in Category: + if current.lower() in category.value.lower(): + news_category.append( + app_commands.Choice(name=category.value, value=category.value) + ) + return news_category diff --git a/techsupport_bot/commands/notes.py b/techsupport_bot/commands/notes.py new file mode 100644 index 000000000..2afe3dd6a --- /dev/null +++ b/techsupport_bot/commands/notes.py @@ -0,0 +1,379 @@ +"""Module for the who extension for the discord bot.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self + +import discord +import ui +from botlogging import LogContext, LogLevel +from core import auxiliary, cogs, extensionconfig, moderation +from discord import app_commands +from discord.ext import commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Loading the Who plugin into the bot + + Args: + bot (bot.TechSupportBot): The bot object to register the cogs to + """ + + config = extensionconfig.ExtensionConfig() + config.add( + key="note_role", + datatype="str", + title="Note role", + description="The name of the role to be added when a note is added to a user", + default=None, + ) + config.add( + key="note_bypass", + datatype="list", + title="Note bypass list", + description=( + "A list of roles that shouldn't have notes set or the note role assigned" + ), + default=["Moderator"], + ) + config.add( + key="note_readers", + datatype="list", + title="Note Reader Roles", + description="Users with roles in this list will be able to use whois", + default=[], + ) + config.add( + key="note_writers", + datatype="list", + title="Note Writer Roles", + description="Users with roles in this list will be able to create or delete notes", + default=[], + ) + + await bot.add_cog(Notes(bot=bot, extension_name="notes")) + bot.add_extension_config("notes", config) + + +async def is_reader(interaction: discord.Interaction) -> bool: + """Checks whether invoker can read notes. If at least one reader + role is not set, NO members can read notes + + Args: + interaction (discord.Interaction): The interaction in which the whois command occured + + Raises: + MissingAnyRole: Raised if the user is lacking any reader role, + but there are roles defined + AppCommandError: Raised if there are no note_readers set in the config + + Returns: + bool: True if the user can run, False if they cannot + """ + + config = interaction.client.guild_configs[str(interaction.guild.id)] + if reader_roles := config.extensions.notes.note_readers.value: + roles = ( + discord.utils.get(interaction.guild.roles, name=role) + for role in reader_roles + ) + status = any((role in interaction.user.roles for role in roles)) + if not status: + raise app_commands.MissingAnyRole(reader_roles) + return True + + # Reader_roles are empty (not set) + message = "There aren't any `note_readers` roles set in the config!" + + raise app_commands.AppCommandError(message) + + +async def is_writer(interaction: discord.Interaction) -> bool: + """Checks whether invoker can write notes. If at least one writer + role is not set, NO members can write notes + + Args: + interaction (discord.Interaction): The interaction in which the whois command occured + + Raises: + MissingAnyRole: Raised if the user is lacking any writer role, + but there are roles defined + AppCommandError: Raised if there are no note_writers set in the config + + Returns: + bool: True if the user can run, False if they cannot + """ + config = interaction.client.guild_configs[str(interaction.guild.id)] + if writer_roles := config.extensions.notes.note_writers.value: + roles = ( + discord.utils.get(interaction.guild.roles, name=role) + for role in writer_roles + ) + status = any((role in interaction.user.roles for role in roles)) + if not status: + raise app_commands.MissingAnyRole(writer_roles) + return True + + # Reader_roles are empty (not set) + message = "There aren't any `note_writers` roles set in the config!" + + raise app_commands.AppCommandError(message) + + +class Notes(cogs.BaseCog): + """Class to set up who for the extension. + + Attributes: + notes (app_commands.Group): The group for the /note commands + + """ + + notes: app_commands.Group = app_commands.Group( + name="notes", description="Command Group for the Notes Extension" + ) + + @app_commands.check(is_reader) + @app_commands.check(is_writer) + @notes.command( + name="set", + description="Adds a note to a given user.", + extras={ + "brief": "Sets a note for a user", + "usage": "@user [note]", + "module": "notes", + }, + ) + async def set_note( + self: Self, interaction: discord.Interaction, user: discord.Member, body: str + ) -> None: + """Adds a new note to a user + This is the entrance for the /note set command + + Args: + interaction (discord.Interaction): The interaction that called this command + user (discord.Member): The member to add the note to + body (str): The contents of the note being created + """ + if interaction.user.id == user.id: + embed = auxiliary.prepare_deny_embed( + message="You cannot add a note for yourself" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + note = self.bot.models.UserNote( + user_id=str(user.id), + guild_id=str(interaction.guild.id), + author_id=str(interaction.user.id), + body=body, + ) + + config = self.bot.guild_configs[str(interaction.guild.id)] + + # Check to make sure notes are allowed to be assigned + for name in config.extensions.notes.note_bypass.value: + role_check = discord.utils.get(interaction.guild.roles, name=name) + if not role_check: + continue + if role_check in getattr(user, "roles", []): + embed = auxiliary.prepare_deny_embed( + message=f"You cannot assign notes to `{user}` because " + + f"they have `{role_check}` role", + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await note.create() + + role = discord.utils.get( + interaction.guild.roles, name=config.extensions.notes.note_role.value + ) + + if not role: + embed = auxiliary.prepare_confirm_embed( + message=f"Note created for `{user}`, but no note " + + "role is configured so no role was added", + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await user.add_roles(role, reason=f"First note was added by {interaction.user}") + + embed = auxiliary.prepare_confirm_embed(message=f"Note created for `{user}`") + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.check(is_reader) + @app_commands.check(is_writer) + @notes.command( + name="clear", + description="Clears all existing notes for a user", + extras={ + "brief": "Clears all notes for a user", + "usage": "@user", + "module": "notes", + }, + ) + async def clear_notes( + self: Self, interaction: discord.Interaction, user: discord.Member + ) -> None: + """Clears all notes on a given user + This is the entrace for the /note clear command + + Args: + interaction (discord.Interaction): The interaction that called this command + user (discord.Member): The member to remove all notes from + """ + notes = await moderation.get_all_notes(self.bot, user, interaction.guild) + + if not notes: + embed = auxiliary.prepare_deny_embed( + message="There are no notes for that user" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + view = ui.Confirm() + + await view.send( + message=f"Are you sure you want to clear {len(notes)} notes?", + channel=interaction.channel, + author=interaction.user, + interaction=interaction, + ephemeral=True, + ) + + await view.wait() + if view.value is ui.ConfirmResponse.TIMEOUT: + return + if view.value is ui.ConfirmResponse.DENIED: + embed = auxiliary.prepare_deny_embed( + message=f"Notes for `{user}` were not cleared" + ) + await view.followup.send(embed=embed, ephemeral=True) + return + + for note in notes: + await note.delete() + + config = self.bot.guild_configs[str(interaction.guild.id)] + role = discord.utils.get( + interaction.guild.roles, name=config.extensions.notes.note_role.value + ) + if role: + await user.remove_roles( + role, reason=f"Notes were cleared by {interaction.user}" + ) + + embed = auxiliary.prepare_confirm_embed(message=f"Notes cleared for `{user}`") + await view.followup.send(embed=embed, ephemeral=True) + + @app_commands.check(is_reader) + @notes.command( + name="all", + description="Gets all notes for a user instead of just new ones", + extras={ + "brief": "Gets all notes for a user", + "usage": "@user", + "module": "notes", + }, + ) + async def all_notes( + self: Self, interaction: discord.Interaction, member: discord.Member + ) -> None: + """Gets a file containing every note on a user + This is the entrance for the /note all command + + Args: + interaction (discord.Interaction): The interaction that called this command + member (discord.Member): The member to get all notes for + """ + notes = await moderation.get_all_notes(self.bot, member, interaction.guild) + + embeds = build_note_embeds(interaction.guild, member, notes) + + await interaction.response.defer(ephemeral=True) + view = ui.PaginateView() + await view.send( + interaction.channel, interaction.user, embeds, interaction, True + ) + + # re-adds note role back to joining users + @commands.Cog.listener() + async def on_member_join(self: Self, member: discord.Member) -> None: + """Automatic listener to look at users when they join the guild. + This is to apply the note role back to joining users + + Args: + member (discord.Member): The member who has just joined + """ + config = self.bot.guild_configs[str(member.guild.id)] + if not self.extension_enabled(config): + return + + role = discord.utils.get( + member.guild.roles, name=config.extensions.notes.note_role.value + ) + if not role: + return + + user_notes = await moderation.get_all_notes(self.bot, member, member.guild) + if not user_notes: + return + + await member.add_roles(role, reason="Noted user has joined the guild") + + log_channel = config.get("logging_channel") + await self.bot.logger.send_log( + message=f"Found noted user with ID {member.id} joining - re-adding role", + level=LogLevel.INFO, + context=LogContext(guild=member.guild), + channel=log_channel, + ) + + +def build_note_embeds( + guild: discord.Guild, + member: discord.Member, + notes: list[bot.models.UserNote], +) -> list[discord.Embed]: + """Makes a list of embeds with 6 notes per page, for a given user + + Args: + guild (discord.Guild): The guild where the notes occured + member (discord.Member): The member whose notes are being looked for + notes (list[bot.models.UserNote]): The list of notes from the database + + Returns: + list[discord.Embed]: The list of well formatted embeds + """ + embed = auxiliary.generate_basic_embed( + f"Notes for `{member.display_name}` (`{member.name}`)", + color=discord.Color.dark_blue(), + ) + embed.set_footer(text=f"{len(notes)} total notes.") + + embeds = [] + + if not notes: + embed.description = "No notes" + return [embed] + + for index, note in enumerate(notes): + if index % 6 == 0 and index > 0: + embeds.append(embed) + embed = auxiliary.generate_basic_embed( + f"Notes for `{member.display_name}` (`{member.name}`)", + color=discord.Color.dark_blue(), + ) + embed.set_footer(text=f"{len(notes)} total notes.") + author = guild.get_member(int(note.author_id)) or note.author_id + embed.add_field( + name=f"Note by {author}", + value=f"{note.body}\nNote added ", + ) + embeds.append(embed) + return embeds diff --git a/techsupport_bot/commands/protect.py b/techsupport_bot/commands/protect.py deleted file mode 100644 index 2a4953927..000000000 --- a/techsupport_bot/commands/protect.py +++ /dev/null @@ -1,1264 +0,0 @@ -"""Module for the protect extension of the discord bot.""" - -from __future__ import annotations - -import datetime -import io -import re -from datetime import timedelta -from typing import TYPE_CHECKING, Self - -import dateparser -import discord -import expiringdict -import munch -import ui -from botlogging import LogContext, LogLevel -from core import auxiliary, cogs, extensionconfig -from discord.ext import commands - -if TYPE_CHECKING: - import bot - - -async def setup(bot: bot.TechSupportBot) -> None: - """Loading the ChatGPT plugin into the bot - - Args: - bot (bot.TechSupportBot): The bot object to register the cogs to - """ - - config = extensionconfig.ExtensionConfig() - config.add( - key="channels", - datatype="list", - title="Protected channels", - description=( - "The list of channel ID's associated with the channels to auto-protect" - ), - default=[], - ) - config.add( - key="bypass_roles", - datatype="list", - title="Bypassed role names", - description=( - "The list of role names associated with bypassed roles by the auto-protect" - ), - default=[], - ) - config.add( - key="immune_roles", - datatype="list", - title="Immune role names", - description="The list of role names that are immune to protect commands", - default=[], - ) - config.add( - key="bypass_ids", - datatype="list", - title="Bypassed member ID's", - description=( - "The list of member ID's associated with bypassed members by the" - " auto-protect" - ), - default=[], - ) - config.add( - key="length_limit", - datatype="int", - title="Max length limit", - description=( - "The max char limit on messages before they trigger an action by" - " auto-protect" - ), - default=500, - ) - config.add( - key="string_map", - datatype="dict", - title="Keyword string map", - description=( - "Mapping of keyword strings to data defining the action taken by" - " auto-protect" - ), - default={}, - ) - config.add( - key="banned_file_extensions", - datatype="dict", - title="List of banned file types", - description=( - "A list of all file extensions to be blocked and have a auto warning issued" - ), - default=[], - ) - config.add( - key="alert_channel", - datatype="int", - title="Alert channel ID", - description="The ID of the channel to send auto-protect alerts to", - default=None, - ) - config.add( - key="max_mentions", - datatype="int", - title="Max message mentions", - description=( - "Max number of mentions allowed in a message before triggering auto-protect" - ), - default=3, - ) - config.add( - key="max_warnings", - datatype="int", - title="Max Warnings", - description="The amount of warnings a user should be banned on", - default=3, - ) - config.add( - key="ban_delete_duration", - datatype="int", - title="Ban delete duration (days)", - description=( - "The amount of days to delete messages for a user after they are banned" - ), - default=7, - ) - config.add( - key="max_purge_amount", - datatype="int", - title="Max Purge Amount", - description="The max amount of messages allowed to be purged in one command", - default=50, - ) - config.add( - key="paste_footer_message", - datatype="str", - title="The linx embed footer", - description="The message used on the footer of the large message paste URL", - default="Note: Long messages are automatically pasted", - ) - - await bot.add_cog(Protector(bot=bot, extension_name="protect")) - bot.add_extension_config("protect", config) - - -class Protector(cogs.MatchCog): - """Class for the protector command. - - Attributes: - ALERT_ICON_URL (str): The icon for the alert messages - CLIPBOARD_ICON_URL (str): The icon for the paste messages - CHARS_PER_NEWLINE (int): The arbitrary length of a line - - """ - - ALERT_ICON_URL: str = ( - "https://www.iconarchive.com/download/i76061/martz90/circle-addon2/warning.512.png" - ) - CLIPBOARD_ICON_URL: str = ( - "https://www.iconarchive.com/download/i107916/" - "google/noto-emoji-objects/62930-clipboard.512.png" - ) - CHARS_PER_NEWLINE: int = 80 - - async def preconfig(self: Self) -> None: - """Method to preconfig the protect.""" - self.string_alert_cache = expiringdict.ExpiringDict( - max_len=100, max_age_seconds=3600 - ) - - async def match( - self: Self, config: munch.Munch, ctx: commands.Context, content: str - ) -> bool: - """Checks if the message could be triggered by any protect rules - Checks for channel and that the user isn't exempt - - Args: - config (munch.Munch): The guild config where the message was sent - ctx (commands.Context): The context in which the command was run in - content (str): The string content of the message sent - - Returns: - bool: False if the message shouldn't be checked, True if it should - """ - # exit the match based on exclusion parameters - if not str(ctx.channel.id) in config.extensions.protect.channels.value: - await self.bot.logger.send_log( - message="Channel not in protected channels - ignoring protect check", - level=LogLevel.DEBUG, - context=LogContext(guild=ctx.guild, channel=ctx.channel), - ) - return False - - role_names = [role.name.lower() for role in getattr(ctx.author, "roles", [])] - - if any( - role_name.lower() in role_names - for role_name in config.extensions.protect.bypass_roles.value - ): - return False - - if ctx.author.id in config.extensions.protect.bypass_ids.value: - return False - - return True - - @commands.Cog.listener() - async def on_raw_message_edit( - self: Self, payload: discord.RawMessageUpdateEvent - ) -> None: - """This is called when any message is edited in any guild the bot is in. - There is no guarantee that the message exists or is used - - Args: - payload (discord.RawMessageUpdateEvent): The raw event that the edit generated - """ - guild = self.bot.get_guild(payload.guild_id) - if not guild: - return - - config = self.bot.guild_configs[str(guild.id)] - if not self.extension_enabled(config): - return - - channel = self.bot.get_channel(payload.channel_id) - if not channel: - return - - message = await channel.fetch_message(payload.message_id) - if not message: - return - - # Don't trigger if content hasn't changed - if payload.cached_message and payload.cached_message.content == message.content: - return - - ctx = await self.bot.get_context(message) - matched = await self.match(config, ctx, message.content) - if not matched: - return - - await self.response(config, ctx, message.content, None) - - def search_by_text_regex( - self: Self, config: munch.Munch, content: str - ) -> munch.Munch: - """Searches a given message for static text and regex rule violations - - Args: - config (munch.Munch): The guild config where the message was sent - content (str): The string contents of the message that might be filtered - - Returns: - munch.Munch: The most aggressive filter that is triggered - """ - triggered_config = None - for ( - keyword, - filter_config, - ) in config.extensions.protect.string_map.value.items(): - filter_config = munch.munchify(filter_config) - search_keyword = keyword - search_content = content - - regex = filter_config.get("regex") - if regex: - try: - match = re.search(regex, search_content) - except re.error: - match = None - if match: - filter_config["trigger"] = keyword - triggered_config = filter_config - if triggered_config.get("delete"): - return triggered_config - else: - if filter_config.get("sensitive"): - search_keyword = search_keyword.lower() - search_content = search_content.lower() - if search_keyword in search_content: - filter_config["trigger"] = keyword - triggered_config = filter_config - if triggered_config.get("delete"): - return triggered_config - return triggered_config - - async def response( - self: Self, config: munch.Munch, ctx: commands.Context, content: str, _: bool - ) -> None: - """Checks if a message does violate any set automod rules - - Args: - config (munch.Munch): The guild config where the message was sent - ctx (commands.Context): The context of the original message - content (str): The string content of the message sent - """ - # check mass mentions first - return after handling - if len(ctx.message.mentions) > config.extensions.protect.max_mentions.value: - await self.handle_mass_mention_alert(config, ctx, content) - return - - # search the message against keyword strings - triggered_config = self.search_by_text_regex(config, content) - - for attachment in ctx.message.attachments: - if ( - attachment.filename.split(".")[-1] - in config.extensions.protect.banned_file_extensions.value - ): - await self.handle_file_extension_alert(config, ctx, attachment.filename) - return - - if triggered_config: - await self.handle_string_alert(config, ctx, content, triggered_config) - if triggered_config.get("delete"): - # the message is deleted, no need to pastebin it - return - - # check length of content - if len(content) > config.extensions.protect.length_limit.value or content.count( - "\n" - ) > self.max_newlines(config.extensions.protect.length_limit.value): - await self.handle_length_alert(config, ctx, content) - - def max_newlines(self: Self, max_length: int) -> int: - """Gets a theoretical maximum number of new lines in a given message - - Args: - max_length (int): The max length of characters per theoretical line - - Returns: - int: The maximum number of new lines based on config - """ - return int(max_length / self.CHARS_PER_NEWLINE) + 1 - - async def handle_length_alert( - self: Self, config: munch.Munch, ctx: commands.Context, content: str - ) -> None: - """Moves message into a linx paste if it's too long - - Args: - config (munch.Munch): The guild config where the too long message was sent - ctx (commands.Context): The context where the original message was sent - content (str): The string content of the flagged message - """ - attachments: list[discord.File] = [] - if ctx.message.attachments: - total_attachment_size = 0 - for attch in ctx.message.attachments: - if ( - total_attachment_size := total_attachment_size + attch.size - ) <= ctx.filesize_limit: - attachments.append(await attch.to_file()) - if (lf := len(ctx.message.attachments) - len(attachments)) != 0: - log_channel = config.get("logging_channel") - await self.bot.logger.send_log( - message=( - f"Protect did not reupload {lf} file(s) due to file size limit." - ), - level=LogLevel.WARN, - channel=log_channel, - context=LogContext(guild=ctx.guild, channel=ctx.channel), - ) - await ctx.message.delete() - - reason = "message too long (too many newlines or characters)" - - if not self.bot.file_config.api.api_url.linx: - await self.send_default_delete_response(config, ctx, content, reason) - return - - linx_embed = await self.create_linx_embed(config, ctx, content) - if not linx_embed: - await self.send_default_delete_response(config, ctx, content, reason) - await self.send_alert(config, ctx, "Could not convert text to Linx paste") - return - - await ctx.send( - ctx.message.author.mention, embed=linx_embed, files=attachments[:10] - ) - - async def handle_mass_mention_alert( - self: Self, config: munch.Munch, ctx: commands.Context, content: str - ) -> None: - """Handles a mass mention alert from automod - - Args: - config (munch.Munch): The guild config where the message was sent - ctx (commands.Context): The context where the message was sent - content (str): The string content of the message - """ - await ctx.message.delete() - await self.handle_warn(ctx, ctx.author, "mass mention", bypass=True) - await self.send_alert(config, ctx, f"Mass mentions from {ctx.author}") - - async def handle_file_extension_alert( - self: Self, config: munch.Munch, ctx: commands.Context, filename: str - ) -> None: - """Handles a suspicous file extension flag from automod - - Args: - config (munch.Munch): The guild config from where the message was sent - ctx (commands.Context): The context where the message was sent - filename (str): The filename of the suspicious file that was uploaded - """ - await ctx.message.delete() - await self.handle_warn( - ctx, ctx.author, "Suspicious file extension", bypass=True - ) - await self.send_alert( - config, ctx, f"Suspicious file uploaded by {ctx.author}: {filename}" - ) - - async def handle_string_alert( - self: Self, - config: munch.Munch, - ctx: commands.Context, - content: str, - filter_config: munch.Munch, - ) -> None: - """Handles a static string alert. Is given a rule that was violated - - Args: - config (munch.Munch): The guild config where the message was sent - ctx (commands.Context): The context where the original message was sent - content (str): The string content of the message - filter_config (munch.Munch): The rule that was triggered by the message - """ - # If needed, delete the message - if filter_config.delete: - await ctx.message.delete() - - # Send only 1 response based on warn, deletion, or neither - if filter_config.warn: - await self.handle_warn(ctx, ctx.author, filter_config.message, bypass=True) - elif filter_config.delete: - await self.send_default_delete_response( - config, ctx, content, filter_config.message - ) - else: - # Ensure we don't trigger people more than once if the only trigger is a warning - cache_key = self.get_cache_key(ctx.guild, ctx.author, filter_config.trigger) - if self.string_alert_cache.get(cache_key): - return - - self.string_alert_cache[cache_key] = True - embed = auxiliary.generate_basic_embed( - title="Chat Protection", - description=filter_config.message, - color=discord.Color.gold(), - ) - await ctx.send(ctx.message.author.mention, embed=embed) - - await self.send_alert( - config, - ctx, - f"Message contained trigger: {filter_config.trigger}", - ) - - async def handle_warn( - self: Self, - ctx: commands.Context, - user: discord.Member, - reason: str, - bypass: bool = False, - ) -> None: - """Handles the logic of a warning - - Args: - ctx (commands.Context): The context that generated the warning - user (discord.Member): The member to warn - reason (str): The reason for warning - bypass (bool, optional): If this should bypass the confirmation check. - Defaults to False. - """ - if not bypass: - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - warnings = await self.get_warnings(user, ctx.guild) - - new_count = len(warnings) + 1 - - config = self.bot.guild_configs[str(ctx.guild.id)] - - if new_count >= config.extensions.protect.max_warnings.value: - # Start by assuming we don't want to ban someone - should_ban = False - - # If there is no bypass, ask using ui.Confirm - # If there is a bypass, assume we want to ban - if not bypass: - view = ui.Confirm() - await view.send( - message="This user has exceeded the max warnings of " - + f"{config.extensions.protect.max_warnings.value}. Would " - + "you like to ban them instead?", - channel=ctx.channel, - author=ctx.author, - ) - await view.wait() - if view.value is ui.ConfirmResponse.CONFIRMED: - should_ban = True - else: - should_ban = True - - if should_ban: - await self.handle_ban( - ctx, - user, - f"Over max warning count {new_count} out " - + f"of {config.extensions.protect.max_warnings.value}" - + f" (final warning: {reason})", - bypass=True, - ) - await self.clear_warnings(user, ctx.guild) - return - - embed = await self.generate_user_modified_embed( - user, "warn", f"{reason} ({new_count} total warnings)" - ) - - # Attempt DM for manually initiated, non-banning warns - if ctx.command == self.bot.get_command("warn"): - # Cancel warns in channels invisible to user - if not ctx.channel.permissions_for(user).view_channel: - await auxiliary.send_deny_embed( - message=f"{user} cannot see this warning.", channel=ctx.channel - ) - return - - try: - await user.send(embed=embed) - - except (discord.HTTPException, discord.Forbidden): - channel = config.get("logging_channel") - await self.bot.logger.send_log( - message=f"Failed to DM warning to {user}", - level=LogLevel.WARNING, - channel=channel, - context=LogContext(guild=ctx.guild, channel=ctx.channel), - ) - - finally: - await ctx.send(content=user.mention, embed=embed) - - else: - await ctx.send(ctx.message.author.mention, embed=embed) - - await self.bot.models.Warning( - user_id=str(user.id), guild_id=str(ctx.guild.id), reason=reason - ).create() - - async def handle_unwarn( - self: Self, - ctx: commands.Context, - user: discord.Member, - reason: str, - bypass: bool = False, - ) -> None: - """Handles the logic of clearing all warnings - - Args: - ctx (commands.Context): The context that generated theis unwarn - user (discord.Member): The member to remove warnings from - reason (str): The reason for clearing warnings - bypass (bool, optional): If this should bypass the confirmation check. - Defaults to False. - """ - # Always allow admins to unwarn other admins - if not bypass and not ctx.message.author.guild_permissions.administrator: - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - warnings = await self.get_warnings(user, ctx.guild) - if not warnings: - await auxiliary.send_deny_embed( - message="There are no warnings for that user", channel=ctx.channel - ) - return - - await self.clear_warnings(user, ctx.guild) - - embed = await self.generate_user_modified_embed(user, "unwarn", reason) - await ctx.send(embed=embed) - - async def handle_ban( - self: Self, - ctx: commands.Context, - user: discord.User | discord.Member, - reason: str, - bypass: bool = False, - ) -> None: - """Handles the logic of banning a user. Is not a discord command - - Args: - ctx (commands.Context): The context that generated the need for a bad - user (discord.User | discord.Member): The user or member to be banned - reason (str): The ban reason to be stored in discord - bypass (bool, optional): True will ignore permission chekcks. Defaults to False. - """ - if not bypass: - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - async for ban in ctx.guild.bans(limit=None): - if user == ban.user: - await auxiliary.send_deny_embed( - message="User is already banned.", channel=ctx.channel - ) - return - - config = self.bot.guild_configs[str(ctx.guild.id)] - await ctx.guild.ban( - user, - reason=reason, - delete_message_days=config.extensions.protect.ban_delete_duration.value, - ) - - embed = await self.generate_user_modified_embed(user, "ban", reason) - - await ctx.send(embed=embed) - - async def handle_unban( - self: Self, - ctx: commands.Context, - user: discord.User, - reason: str, - bypass: bool = False, - ) -> None: - """Handles the logic of unbanning a user. Is not a discord command - - Args: - ctx (commands.Context): The context that generated the need for the unban - user (discord.User): The user to be unbanned - reason (str): The unban reason to be saved in the audit log - bypass (bool, optional): True will ignore permission chekcks. Defaults to False. - """ - if not bypass: - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - try: - await ctx.guild.unban(user, reason=reason) - except discord.NotFound: - await auxiliary.send_deny_embed( - message="This user is not banned, or does not exist", - channel=ctx.channel, - ) - return - - embed = await self.generate_user_modified_embed(user, "unban", reason) - - await ctx.send(embed=embed) - - async def handle_kick( - self: Self, - ctx: commands.Context, - user: discord.Member, - reason: str, - bypass: bool = False, - ) -> None: - """Handles the logic of kicking a user. Is not a discord command - - Args: - ctx (commands.Context): The context that generated the need for the kick - user (discord.Member): The user to be kicked - reason (str): The kick reason to be saved in the audit log - bypass (bool, optional): True will ignore permission chekcks. Defaults to False. - """ - if not bypass: - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - await ctx.guild.kick(user, reason=reason) - - embed = await self.generate_user_modified_embed(user, "kick", reason) - - await ctx.send(embed=embed) - - async def clear_warnings( - self: Self, user: discord.User | discord.Member, guild: discord.Guild - ) -> None: - """This clears all warnings for a given user - - Args: - user (discord.User | discord.Member): The user or member to wipe all warnings for - guild (discord.Guild): The guild to clear warning from - """ - await self.bot.models.Warning.delete.where( - self.bot.models.Warning.user_id == str(user.id) - ).where(self.bot.models.Warning.guild_id == str(guild.id)).gino.status() - - async def generate_user_modified_embed( - self: Self, user: discord.User | discord.Member, action: str, reason: str - ) -> discord.Embed: - """This generates an embed to be shown to the user on why their message was actioned - - Args: - user (discord.User | discord.Member): The user or member who was punished - action (str): The action that was taken against the person - reason (str): The reason for the action taken - - Returns: - discord.Embed: The prepared embed ready to be sent - """ - embed = discord.Embed( - title="Chat Protection", description=f"{action.upper()} `{user}`" - ) - embed.set_footer(text=f"Reason: {reason}") - embed.set_thumbnail(url=user.display_avatar.url) - embed.color = discord.Color.gold() - - return embed - - def get_cache_key( - self: Self, guild: discord.Guild, user: discord.Member, trigger: str - ) -> str: - """Gets the cache key for repeated automod triggers - - Args: - guild (discord.Guild): The guild where the trigger has occured - user (discord.Member): The member that triggered the automod - trigger (str): The string representation of the automod rule that triggered - - Returns: - str: The key to lookup the cache entry, if it exists - """ - return f"{guild.id}_{user.id}_{trigger}" - - async def can_execute( - self: Self, ctx: commands.Context, target: discord.User | discord.Member - ) -> bool: - """Checks permissions to determine if the protect command should execute. - This checks: - - If the executer is the same as the target - - If the target is a bot - - If the member is immune to protect - - If the bot doesn't have permissions - - If the user wouldn't have permissions based on their roles - - Args: - ctx (commands.Context): The context that required the need for moderative action - target (discord.User | discord.Member): The target of the moderative action - - Returns: - bool: True if the executer can execute this command, False if they can't - """ - action = ctx.command.name or "do that to" - config = self.bot.guild_configs[str(ctx.guild.id)] - - # Check to see if executed on author - if target == ctx.author: - await auxiliary.send_deny_embed( - message=f"You cannot {action} yourself", channel=ctx.channel - ) - return False - # Check to see if executed on bot - if target == self.bot.user: - await auxiliary.send_deny_embed( - message=f"It would be silly to {action} myself", channel=ctx.channel - ) - return False - # Check to see if target has a role. Will allow execution on Users outside of server - if not hasattr(target, "top_role"): - return True - # Check to see if target has any immune roles - for name in config.extensions.protect.immune_roles.value: - role_check = discord.utils.get(target.guild.roles, name=name) - if role_check and role_check in getattr(target, "roles", []): - await auxiliary.send_deny_embed( - message=( - f"You cannot {action} {target} because they have `{role_check}`" - " role" - ), - channel=ctx.channel, - ) - return False - # Check to see if the Bot can execute on the target - if ctx.guild.me.top_role <= target.top_role: - await auxiliary.send_deny_embed( - message=f"Bot does not have enough permissions to {action} `{target}`", - channel=ctx.channel, - ) - return False - # Check to see if author top role is higher than targets - if target.top_role >= ctx.author.top_role: - await auxiliary.send_deny_embed( - message=f"You do not have enough permissions to {action} `{target}`", - channel=ctx.channel, - ) - return False - return True - - async def send_alert( - self: Self, config: munch.Munch, ctx: commands.Context, message: str - ) -> None: - """Sends a protect alert to the protect events channel to alert the mods - - Args: - config (munch.Munch): The guild config in the guild where the event occured - ctx (commands.Context): The context that generated this alert - message (str): The message to send to the mods about the alert - """ - try: - alert_channel = ctx.guild.get_channel( - int(config.extensions.protect.alert_channel.value) - ) - except TypeError: - alert_channel = None - - if not alert_channel: - return - - embed = discord.Embed(title="Protect Alert", description=message) - - if len(ctx.message.content) >= 256: - message_content = ctx.message.content[0:256] - else: - message_content = ctx.message.content - - embed.add_field(name="Channel", value=f"#{ctx.channel.name}") - embed.add_field(name="User", value=ctx.author.mention) - embed.add_field(name="Message", value=message_content, inline=False) - embed.add_field(name="URL", value=ctx.message.jump_url, inline=False) - - embed.set_thumbnail(url=self.ALERT_ICON_URL) - embed.color = discord.Color.red() - - await alert_channel.send(embed=embed) - - async def send_default_delete_response( - self: Self, - config: munch.Munch, - ctx: commands.Context, - content: str, - reason: str, - ) -> None: - """Sends a DM to a user containing a message that was deleted - - Args: - config (munch.Munch): The config of the guild where the message was sent - ctx (commands.Context): The context of the deleted message - content (str): The context of the deleted message - reason (str): The reason the message was deleted - """ - embed = auxiliary.generate_basic_embed( - title="Chat Protection", - description=f"Message deleted. Reason: *{reason}*", - color=discord.Color.gold(), - ) - await ctx.send(ctx.message.author.mention, embed=embed) - await ctx.author.send(f"Deleted message: ```{content[:1994]}```") - - async def get_warnings( - self: Self, user: discord.Member | discord.User, guild: discord.Guild - ) -> list[bot.models.Warning]: - """Gets a list of every warning for a given user - - Args: - user (discord.Member | discord.User): The user or member object to lookup warnings for - guild (discord.Guild): The guild to get the warnings for - - Returns: - list[bot.models.Warning]: The list of all warnings that - user or member has in the given guild - """ - warnings = ( - await self.bot.models.Warning.query.where( - self.bot.models.Warning.user_id == str(user.id) - ) - .where(self.bot.models.Warning.guild_id == str(guild.id)) - .gino.all() - ) - return warnings - - async def create_linx_embed( - self: Self, config: munch.Munch, ctx: commands.Context, content: str - ) -> discord.Embed | None: - """This function sends a message to the linx url and puts the result in - an embed to be sent to the user - - Args: - config (munch.Munch): The guild config where the message was sent - ctx (commands.Context): The context that generated the need for a paste - content (str): The context of the message to be pasted - - Returns: - discord.Embed | None: The formatted embed, or None if there was an API error - """ - if not content: - return None - - headers = { - "Linx-Expiry": "1800", - "Linx-Randomize": "yes", - "Accept": "application/json", - } - file_to_paste = {"file": io.StringIO(content)} - response = await self.bot.http_functions.http_call( - "post", - self.bot.file_config.api.api_url.linx, - headers=headers, - data=file_to_paste, - ) - - url = response.get("url") - if not url: - return None - - embed = discord.Embed(description=url) - - embed.add_field(name="Paste Link", value=url) - embed.description = content[0:100].replace("\n", " ") - embed.set_author( - name=f"Paste by {ctx.author}", icon_url=ctx.author.display_avatar.url - ) - embed.set_footer(text=config.extensions.protect.paste_footer_message.value) - embed.color = discord.Color.blue() - - return embed - - @commands.has_permissions(ban_members=True) - @commands.bot_has_permissions(ban_members=True) - @commands.command( - name="ban", - brief="Bans a user", - description="Bans a user with a given reason", - usage="@user [reason]", - ) - async def ban_user( - self: Self, ctx: commands.Context, user: discord.User, *, reason: str = None - ) -> None: - """The ban discord command, starts the process of banning a user - - Args: - ctx (commands.Context): The context that called this command - user (discord.User): The user that is going to be banned - reason (str, optional): The reason for the ban. Defaults to None. - """ - - # Uses the discord.Member class to get the top role attribute if the - # user is a part of the target guild - if ctx.guild.get_member(user.id) is not None: - await self.handle_ban(ctx, ctx.guild.get_member(user.id), reason) - else: - await self.handle_ban(ctx, user, reason) - - config = self.bot.guild_configs[str(ctx.guild.id)] - await self.send_alert(config, ctx, "Ban command") - - @commands.has_permissions(ban_members=True) - @commands.bot_has_permissions(ban_members=True) - @commands.command( - name="unban", - brief="Unbans a user", - description="Unbans a user with a given reason", - usage="@user [reason]", - ) - async def unban_user( - self: Self, ctx: commands.Context, user: discord.User, *, reason: str = None - ) -> None: - """The unban discord command, starts the process of unbanning a user - - Args: - ctx (commands.Context): The context that called this command - user (discord.User): The user that is going to be unbanned - reason (str, optional): The reason for the unban. Defaults to None. - """ - - # Uses the discord.Member class to get the top role attribute if the - # user is a part of the target guild - if ctx.guild.get_member(user.id) is not None: - await self.handle_unban(ctx, ctx.guild.get_member(user.id), reason) - else: - await self.handle_unban(ctx, user, reason) - - @commands.has_permissions(kick_members=True) - @commands.bot_has_permissions(kick_members=True) - @commands.command( - name="kick", - brief="Kicks a user", - description="Kicks a user with a given reason", - usage="@user [reason]", - ) - async def kick_user( - self: Self, ctx: commands.Context, user: discord.Member, *, reason: str = None - ) -> None: - """The kick discord command, starts the process of kicking a user - - Args: - ctx (commands.Context): The context that called this command - user (discord.Member): The user that is going to be kicked - reason (str, optional): The reason for the kick. Defaults to None. - """ - await self.handle_kick(ctx, user, reason) - - config = self.bot.guild_configs[str(ctx.guild.id)] - await self.send_alert(config, ctx, "Kick command") - - @commands.has_permissions(kick_members=True) - @commands.bot_has_permissions(kick_members=True) - @commands.command( - name="warn", - brief="Warns a user", - description="Warn a user with a given reason", - usage="@user [reason]", - ) - async def warn_user( - self: Self, ctx: commands.Context, user: discord.Member, *, reason: str = None - ) -> None: - """The warn discord command, starts the process of warning a user - - Args: - ctx (commands.Context): The context that called this command - user (discord.Member): The user that is going to be warned - reason (str, optional): The reason for the warn. Defaults to None. - """ - await self.handle_warn(ctx, user, reason) - - config = self.bot.guild_configs[str(ctx.guild.id)] - await self.send_alert(config, ctx, "Warn command") - - @commands.has_permissions(kick_members=True) - @commands.bot_has_permissions(kick_members=True) - @commands.command( - name="unwarn", - brief="Unwarns a user", - description="Unwarns a user with a given reason", - usage="@user [reason]", - ) - async def unwarn_user( - self: Self, ctx: commands.Context, user: discord.Member, *, reason: str = None - ) -> None: - """The unwarn discord command, starts the process of unwarning a user - This clears ALL warnings from a member - - Args: - ctx (commands.Context): The context that called this command - user (discord.Member): The user that is going to be unwarned - reason (str, optional): The reason for the unwarn. Defaults to None. - """ - await self.handle_unwarn(ctx, user, reason) - - @commands.has_permissions(kick_members=True) - @commands.bot_has_permissions(kick_members=True) - @commands.command( - name="warnings", - brief="Gets warnings", - description="Gets warnings for a user", - usage="@user", - ) - async def get_warnings_command( - self: Self, ctx: commands.Context, user: discord.User - ) -> None: - """Displays all warnings that a given user has - - Args: - ctx (commands.Context): The context that called this command - user (discord.User): The user to get warnings for - """ - warnings = await self.get_warnings(user, ctx.guild) - if not warnings: - await auxiliary.send_deny_embed( - message="There are no warnings for that user", channel=ctx.channel - ) - return - - embed = discord.Embed(title=f"Warnings for {user}") - for warning in warnings: - embed.add_field(name=warning.time, value=warning.reason, inline=False) - - embed.set_thumbnail(url=user.display_avatar.url) - - embed.color = discord.Color.red() - - await ctx.send(embed=embed) - - @commands.has_permissions(moderate_members=True) - @commands.bot_has_permissions(moderate_members=True) - @commands.command( - name="mute", - brief="Mutes a user", - description="Times out a user for the specified duration", - usage="@user [time] [reason]", - aliases=["timeout"], - ) - async def mute( - self: Self, ctx: commands.Context, user: discord.Member, *, duration: str = None - ) -> None: - """Method to mute a user in discord using the native timeout. - This should be run via discord - - Args: - ctx (commands.Context): The context that was generated by running this command - user (discord.Member): The discord.Member to be timed out. - duration (str, optional): Max time is 28 days by discord API. Defaults to 1 hour - - Raises: - ValueError: Raised if the provided duration string cannot be converted into a time - """ - - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - # The API prevents administrators from being timed out. Check it here - if user.guild_permissions.administrator: - await auxiliary.send_deny_embed( - message=( - "Someone with the `administrator` permissions cannot be timed out" - ), - channel=ctx.channel, - ) - return - - delta_duration = None - - if duration: - # The date parser defaults to time in the past, so it is second - # This could be fixed by appending "in" to your query, but this is simpler - try: - delta_duration = datetime.datetime.now() - dateparser.parse(duration) - delta_duration = timedelta( - seconds=round(delta_duration.total_seconds()) - ) - except TypeError as exc: - raise ValueError("Invalid duration") from exc - if not delta_duration: - raise ValueError("Invalid duration") - else: - delta_duration = timedelta(hours=1) - - # Checks to ensure time is valid and within the scope of the API - if delta_duration > timedelta(days=28): - raise ValueError("Timeout duration cannot be more than 28 days") - if delta_duration < timedelta(seconds=1): - raise ValueError("Timeout duration cannot be less than 1 second") - - # Timeout the user and send messages to both the invocation channel, and the protect log - await user.timeout(delta_duration) - - embed = await self.generate_user_modified_embed( - user, f"muted for {delta_duration}", reason=None - ) - - await ctx.send(embed=embed) - - config = self.bot.guild_configs[str(ctx.guild.id)] - await self.send_alert(config, ctx, "Mute command") - - @commands.has_permissions(moderate_members=True) - @commands.bot_has_permissions(moderate_members=True) - @commands.command( - name="unmute", - brief="Unutes a user", - description="Removes a timeout from the user", - usage="@user", - aliases=["untimeout"], - ) - async def unmute( - self: Self, ctx: commands.Context, user: discord.Member, reason: str = None - ) -> None: - """Method to mute a user in discord using the native timeout. - This should be run via discord - - Args: - ctx (commands.Context): The context that was generated by running this command - user (discord.Member): The discord.Member to have mute be cleared. - reason (str, optional): The reason for the unmute. Defaults to None. - """ - can_execute = await self.can_execute(ctx, user) - if not can_execute: - return - - if user.timed_out_until is None: - await auxiliary.send_deny_embed( - message="That user is not timed out", channel=ctx.channel - ) - return - - await user.timeout(None) - - embed = await self.generate_user_modified_embed(user, "unmuted", reason) - - await ctx.send(embed=embed) - - @commands.has_permissions(manage_messages=True) - @commands.bot_has_permissions(manage_messages=True) - @commands.group( - brief="Executes a purge command", - description="Executes a purge command", - ) - async def purge(self: Self, ctx: commands.Context) -> None: - """The bare .purge command. This does nothing but generate the help message - - Args: - ctx (commands.Context): The context in which the command was run in - """ - await auxiliary.extension_help(self, ctx, self.__module__[9:]) - - @purge.command( - name="amount", - aliases=["x"], - brief="Purges messages by amount", - description="Purges the current channel's messages based on amount", - usage="[amount]", - ) - async def purge_amount(self: Self, ctx: commands.Context, amount: int = 1) -> None: - """Purges the most recent amount+1 messages in the channel the command was run in - - Args: - ctx (commands.Context): The context that called the command - amount (int, optional): The amount of messages to purge. Defaults to 1. - """ - config = self.bot.guild_configs[str(ctx.guild.id)] - - if amount <= 0 or amount > config.extensions.protect.max_purge_amount.value: - amount = config.extensions.protect.max_purge_amount.value - - await ctx.channel.purge(limit=amount + 1) - - await self.send_alert(config, ctx, "Purge command") - - @purge.command( - name="duration", - aliases=["d"], - brief="Purges messages by duration", - description="Purges the current channel's messages up to a time", - usage="[duration (minutes)]", - ) - async def purge_duration( - self: Self, ctx: commands.Context, duration_minutes: int - ) -> None: - """Purges the most recent duration_minutes worth of messages in the - channel the command was run in - - Args: - ctx (commands.Context): The context that called the command - duration_minutes (int): The amount of minutes to purge away - """ - if duration_minutes < 0: - await auxiliary.send_deny_embed( - message="I can't use that input", channel=ctx.channel - ) - return - - timestamp = datetime.datetime.utcnow() - datetime.timedelta( - minutes=duration_minutes - ) - - config = self.bot.guild_configs[str(ctx.guild.id)] - - await ctx.channel.purge( - after=timestamp, limit=config.extensions.protect.max_purge_amount.value - ) - - await self.send_alert(config, ctx, "Purge command") diff --git a/techsupport_bot/commands/purge.py b/techsupport_bot/commands/purge.py new file mode 100644 index 000000000..0a25fb8c8 --- /dev/null +++ b/techsupport_bot/commands/purge.py @@ -0,0 +1,96 @@ +"""The file that holds the purge command""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Self + +import discord +from core import auxiliary, cogs, extensionconfig, moderation +from discord import app_commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="max_purge_amount", + datatype="int", + title="Max Purge Amount", + description="The max amount of messages allowed to be purged in one command", + default=50, + ) + await bot.add_cog(Purger(bot=bot, extension_name="purge")) + bot.add_extension_config("purge", config) + + +class Purger(cogs.BaseCog): + """The class that holds the /purge command""" + + @app_commands.checks.has_permissions(manage_messages=True) + @app_commands.checks.bot_has_permissions(manage_messages=True) + @app_commands.command( + name="purge", + description="Purge by pure duration of messages", + extras={"module": "purge"}, + ) + async def purge_command( + self: Self, + interaction: discord.Interaction, + amount: int, + duration_minutes: int = None, + ) -> None: + """The core purge command that can purge by either amount or duration + + Args: + interaction (discord.Interaction): The interaction that called this command + amount (int): The max amount of messages to purge + duration_minutes (int, optional): The max age of a message to purge. Defaults to None. + """ + config = self.bot.guild_configs[str(interaction.guild.id)] + + if amount <= 0 or amount > config.extensions.purge.max_purge_amount.value: + embed = auxiliary.prepare_deny_embed( + message=( + "Messages to purge must be between 1 " + f"and {config.extensions.purge.max_purge_amount.value}" + ), + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + if duration_minutes and duration_minutes < 0: + embed = auxiliary.prepare_deny_embed( + message="Message age must be older than 0 minutes", + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + if duration_minutes: + timestamp = datetime.datetime.utcnow() - datetime.timedelta( + minutes=duration_minutes + ) + else: + timestamp = None + + await interaction.response.send_message("Purge Successful", ephemeral=True) + sent_message = await interaction.original_response() + deleted = await interaction.channel.purge(after=timestamp, limit=amount) + await interaction.followup.edit_message( + message_id=sent_message.id, + content=f"Purge Successful. Deleted {len(deleted)} messages.", + ) + + await moderation.send_command_usage_alert( + bot_object=self.bot, + interaction=interaction, + command=f"/purge amount: {amount}, duration: {duration_minutes}", + guild=interaction.guild, + ) diff --git a/techsupport_bot/commands/relay.py b/techsupport_bot/commands/relay.py index 36f213392..1360ff8b0 100644 --- a/techsupport_bot/commands/relay.py +++ b/techsupport_bot/commands/relay.py @@ -11,6 +11,7 @@ from bidict import bidict from core import auxiliary, cogs from discord.ext import commands +from functions import automod if TYPE_CHECKING: import bot @@ -411,6 +412,37 @@ async def send_message_from_irc(self: Self, split_message: dict[str, str]) -> No mentions = self.get_mentions( message=split_message["content"], channel=discord_channel ) + + config = self.bot.guild_configs[str(discord_channel.guild.id)] + if "automod" in config.get("enabled_extensions", []): + automod_actions = automod.run_only_string_checks( + config, split_message["content"] + ) + automod_final = automod.process_automod_violations(automod_actions) + if automod_final and automod_final.delete_message: + embed = discord.Embed(title="IRC Automod") + embed.description = ( + f"**Blocked message:** {split_message['content']}\n" + f"**Reason: ** {automod_final.violation_string}\n" + f"**Message sent by:** {split_message['username']} " + f"({split_message['hostmask']})\n" + f"**In channel:** {split_message['channel']}" + ) + embed.color = discord.Color.red() + try: + alert_channel = discord_channel.guild.get_channel( + int(config.extensions.automod.alert_channel.value) + ) + except TypeError: + alert_channel = None + + if not alert_channel: + return + + await alert_channel.send(embed=embed) + + return + mentions_string = auxiliary.construct_mention_string(targets=mentions) embed = self.generate_sent_message_embed(split_message=split_message) diff --git a/techsupport_bot/commands/report.py b/techsupport_bot/commands/report.py new file mode 100644 index 000000000..f3c4730f5 --- /dev/null +++ b/techsupport_bot/commands/report.py @@ -0,0 +1,128 @@ +"""The report command""" + +from __future__ import annotations + +import datetime +import re +from typing import TYPE_CHECKING, Self + +import discord +from core import auxiliary, cogs, extensionconfig +from discord import app_commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="alert_channel", + datatype="int", + title="Alert channel ID", + description="The ID of the channel to send auto-protect alerts to", + default=None, + ) + await bot.add_cog(Report(bot=bot, extension_name="report")) + bot.add_extension_config("report", config) + + +class Report(cogs.BaseCog): + """The class that holds the report command and helper function""" + + @app_commands.command( + name="report", + description="Reports something to the moderators", + extras={"module": "report"}, + ) + async def report_command( + self: Self, interaction: discord.Interaction, report_str: str + ) -> None: + """This is the core of the /report command + Allows users to report potential moderation issues to staff + + Args: + interaction (discord.Interaction): The interaction that called this command + report_str (str): The report string that the user submitted + """ + if len(report_str) > 2000: + embed = auxiliary.prepare_deny_embed( + "Your report cannot be longer than 2000 characters." + ) + await interaction.response.send_message(embed=embed) + return + + embed = discord.Embed(title="New Report", description=report_str) + embed.color = discord.Color.red() + embed.set_author( + name=interaction.user.name, + icon_url=interaction.user.avatar.url or interaction.user.default_avatar.url, + ) + + embed.add_field( + name="User info", + value=( + f"**Name:** {interaction.user.name} ({interaction.user.mention})\n" + f"**Joined:** \n" + f"**Created:** \n" + f"**Sent from:** {interaction.channel.mention} [Jump to context]" + f"(https://discord.com/channels/{interaction.guild.id}/{interaction.channel.id}/" + f"{discord.utils.time_snowflake(datetime.datetime.utcnow())})" + ), + ) + + mention_pattern = re.compile(r"<@!?(\d+)>") + mentioned_user_ids = mention_pattern.findall(report_str) + + mentioned_users = [] + for user_id in mentioned_user_ids: + user = None + try: + user = await interaction.guild.fetch_member(int(user_id)) + except discord.NotFound: + user = None + if user: + mentioned_users.append(user) + mentioned_users: list[discord.Member] = set(mentioned_users) + + for index, user in enumerate(mentioned_users): + embed.add_field( + name=f"Mentioned user #{index+1}", + value=( + f"**Name:** {user.name} ({user.mention})\n" + f"**Joined:** \n" + f"**Created:** \n" + f"**ID:** {user.id}" + ), + ) + + embed.set_footer(text=f"Author ID: {interaction.user.id}") + embed.timestamp = datetime.datetime.utcnow() + + config = self.bot.guild_configs[str(interaction.guild.id)] + + try: + alert_channel = interaction.guild.get_channel( + int(config.extensions.report.alert_channel.value) + ) + except TypeError: + alert_channel = None + + if not alert_channel: + user_embed = auxiliary.prepare_deny_embed( + message="An error occurred while processing your report. It was not sent." + ) + await interaction.response.send_message(embed=user_embed, ephemeral=True) + return + + await alert_channel.send(embed=embed) + + user_embed = auxiliary.prepare_confirm_embed( + message="Your report was successfully sent" + ) + await interaction.response.send_message(embed=user_embed, ephemeral=True) diff --git a/techsupport_bot/commands/role.py b/techsupport_bot/commands/role.py index 3a7c7300a..00e827127 100644 --- a/techsupport_bot/commands/role.py +++ b/techsupport_bot/commands/role.py @@ -21,13 +21,6 @@ async def setup(bot: bot.TechSupportBot) -> None: bot (bot.TechSupportBot): The bot object """ config = extensionconfig.ExtensionConfig() - config.add( - key="self_assignable_roles", - datatype="list", - title="All roles people can assign themselves", - description="The list of roles by name that people can assign themselves", - default=[], - ) config.add( key="allow_self_assign", datatype="list", @@ -49,6 +42,13 @@ async def setup(bot: bot.TechSupportBot) -> None: description="The list of roles that are allowed to assign others roles", default=[], ) + config.add( + key="self_assign_map", + datatype="dict", + title="Map of all the roles allowed in self assign", + description="A map in the format target: [use, use]", + default={}, + ) await bot.add_cog(RoleGiver(bot=bot)) bot.add_extension_config("role", config) @@ -92,13 +92,37 @@ async def self_role(self: Self, interaction: discord.Interaction) -> None: # Pull config config = self.bot.guild_configs[str(interaction.guild.id)] + # Interaction user roles + current_roles = getattr(interaction.user, "roles", []) + + # Get the roles map + roles_map = config.extensions.role.self_assign_map.value + + allowed_roles_list = [] + + # Build the allowed roles list + for assignable_role in roles_map: + for allowed_role in roles_map[assignable_role]: + discord_allowed_role = discord.utils.get( + interaction.guild.roles, id=int(allowed_role) + ) + if discord_allowed_role in current_roles: + allowed_roles_list.append(assignable_role) + break + + if len(allowed_roles_list) == 0: + embed = auxiliary.prepare_deny_embed( + message="You cannot assign yourself any roles right now" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + # Get needed config items - roles = config.extensions.role.self_assignable_roles.value allowed_to_execute = config.extensions.role.allow_self_assign.value # Call the base function await self.role_command_base( - interaction, roles, allowed_to_execute, interaction.user + interaction, allowed_roles_list, allowed_to_execute, interaction.user ) @role_group.command( @@ -149,7 +173,7 @@ async def role_command_base( Args: interaction (discord.Interaction): The interaction that called this command - assignable_roles (list[str]): A list of roles that are assignabled for this command + assignable_roles (list[str]): A list of role IDs that are assignabled for this command allowed_roles (list[str]): A list of roles that are allowed to execute this command member (discord.Member): The member to assign roles to """ @@ -245,18 +269,18 @@ def generate_options( Args: user (discord.Member): The user that will be getting the roles applied guild (discord.Guild): The guild that the roles are from - roles (list[str]): A list of roles by name to add to the options + roles (list[str]): A list of roles by ID to add to the options Returns: list[discord.SelectOption]: A list of SelectOption with defaults set """ options = [] - for role_name in roles: + for role_id in roles: default = False # First, get the role - role = discord.utils.get(guild.roles, name=role_name) + role = discord.utils.get(guild.roles, id=int(role_id)) if not role: continue @@ -265,7 +289,7 @@ def generate_options( default = True # Third, the option to the list with relevant default - options.append(discord.SelectOption(label=role_name, default=default)) + options.append(discord.SelectOption(label=role.name, default=default)) return options async def modify_roles( @@ -280,7 +304,7 @@ async def modify_roles( """Modifies a set of roles based on an input and reference list Args: - config_roles (list[str]): The list of roles allowed to be modified + config_roles (list[str]): The list of roles by ID allowed to be modified new_roles (list[str]): The list of roles from the config_roles that should be assigned to the user. Any roles not on this list will be removed guild (discord.Guild): The guild to assign the roles in @@ -291,8 +315,8 @@ async def modify_roles( added_roles = [] removed_roles = [] - for role_name in config_roles: - real_role = discord.utils.get(guild.roles, name=role_name) + for role_id in config_roles: + real_role = discord.utils.get(guild.roles, id=int(role_id)) if not real_role: continue diff --git a/techsupport_bot/commands/who.py b/techsupport_bot/commands/who.py deleted file mode 100644 index 36a7a14d8..000000000 --- a/techsupport_bot/commands/who.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Module for the who extension for the discord bot.""" - -from __future__ import annotations - -import datetime -import io -from typing import TYPE_CHECKING, Self - -import discord -import ui -import yaml -from botlogging import LogContext, LogLevel -from core import auxiliary, cogs, extensionconfig -from discord import app_commands -from discord.ext import commands - -if TYPE_CHECKING: - import bot - - -async def setup(bot: bot.TechSupportBot) -> None: - """Loading the Who plugin into the bot - - Args: - bot (bot.TechSupportBot): The bot object to register the cogs to - """ - - config = extensionconfig.ExtensionConfig() - config.add( - key="note_role", - datatype="str", - title="Note role", - description="The name of the role to be added when a note is added to a user", - default=None, - ) - config.add( - key="note_bypass", - datatype="list", - title="Note bypass list", - description=( - "A list of roles that shouldn't have notes set or the note role assigned" - ), - default=["Moderator"], - ) - config.add( - key="note_readers", - datatype="list", - title="Note Reader Roles", - description="Users with roles in this list will be able to use whois", - default=[], - ) - config.add( - key="note_writers", - datatype="list", - title="Note Writer Roles", - description="Users with roles in this list will be able to create or delete notes", - default=[], - ) - - await bot.add_cog(Who(bot=bot, extension_name="who")) - bot.add_extension_config("who", config) - - -class Who(cogs.BaseCog): - """Class to set up who for the extension. - - Attributes: - notes (app_commands.Group): The group for the /note commands - - """ - - notes: app_commands.Group = app_commands.Group( - name="note", description="Command Group for the Notes Extension" - ) - - @staticmethod - async def is_writer(interaction: discord.Interaction) -> bool: - """Checks whether invoker can write notes. If at least one writer - role is not set, NO members can write notes - - Args: - interaction (discord.Interaction): The interaction in which the whois command occured - - Raises: - MissingAnyRole: Raised if the user is lacking any writer role, - but there are roles defined - AppCommandError: Raised if there are no note_writers set in the config - - Returns: - bool: True if the user can run, False if they cannot - """ - config = interaction.client.guild_configs[str(interaction.guild.id)] - if reader_roles := config.extensions.who.note_writers.value: - roles = ( - discord.utils.get(interaction.guild.roles, name=role) - for role in reader_roles - ) - status = any((role in interaction.user.roles for role in roles)) - if not status: - raise app_commands.MissingAnyRole(reader_roles) - return True - - # Reader_roles are empty (not set) - message = "There aren't any `note_writers` roles set in the config!" - embed = auxiliary.prepare_deny_embed(message=message) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - raise app_commands.AppCommandError(message) - - @staticmethod - async def is_reader(interaction: discord.Interaction) -> bool: - """Checks whether invoker can read notes. If at least one reader - role is not set, NO members can read notes - - Args: - interaction (discord.Interaction): The interaction in which the whois command occured - - Raises: - MissingAnyRole: Raised if the user is lacking any reader role, - but there are roles defined - AppCommandError: Raised if there are no note_readers set in the config - - Returns: - bool: True if the user can run, False if they cannot - """ - - config = interaction.client.guild_configs[str(interaction.guild.id)] - if reader_roles := config.extensions.who.note_readers.value: - roles = ( - discord.utils.get(interaction.guild.roles, name=role) - for role in reader_roles - ) - status = any((role in interaction.user.roles for role in roles)) - if not status: - raise app_commands.MissingAnyRole(reader_roles) - return True - - # Reader_roles are empty (not set) - message = "There aren't any `note_readers` roles set in the config!" - embed = auxiliary.prepare_deny_embed(message=message) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - raise app_commands.AppCommandError(message) - - @app_commands.check(is_reader) - @app_commands.command( - name="whois", - description="Gets Discord user information", - extras={"brief": "Gets user data", "usage": "@user", "module": "who"}, - ) - async def get_note( - self: Self, interaction: discord.Interaction, user: discord.Member - ) -> None: - """This is the base of the /whois command - - Args: - interaction (discord.Interaction): The interaction that called this command - user (discord.Member): The member to lookup. Will not work on discord.User - """ - embed = discord.Embed( - title=f"User info for `{user}`", - description="**Note: this is a bot account!**" if user.bot else "", - ) - - embed.set_thumbnail(url=user.display_avatar.url) - - embed.add_field(name="Created at", value=user.created_at.replace(microsecond=0)) - embed.add_field(name="Joined at", value=user.joined_at.replace(microsecond=0)) - embed.add_field( - name="Status", value=interaction.guild.get_member(user.id).status - ) - embed.add_field(name="Nickname", value=user.display_name) - - role_string = ", ".join(role.name for role in user.roles[1:]) - embed.add_field(name="Roles", value=role_string or "No roles") - - # Adds special information only visible to mods - if interaction.permissions.kick_members: - embed = await self.modify_embed_for_mods(interaction, user, embed) - - user_notes = await self.get_notes(user, interaction.guild) - total_notes = 0 - if user_notes: - total_notes = len(user_notes) - user_notes = user_notes[:3] - embed.set_footer(text=f"{total_notes} total notes") - embed.color = discord.Color.dark_blue() - - for note in user_notes: - author = interaction.guild.get_member(int(note.author_id)) or note.author_id - embed.add_field( - name=f"Note from {author} ({note.updated.date()})", - value=f"*{note.body}*" or "*None*", - inline=False, - ) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - async def modify_embed_for_mods( - self: Self, - interaction: discord.Interaction, - user: discord.Member, - embed: discord.Embed, - ) -> discord.Embed: - """Makes modifications to the whois embed to add mod only information - - Args: - interaction (discord.Interaction): The interaction where the /whois command was called - user (discord.Member): The user being looked up - embed (discord.Embed): The embed already filled with whois information - - Returns: - discord.Embed: The embed with mod only information added - """ - # If the user has warnings, add them - warnings = ( - await self.bot.models.Warning.query.where( - self.bot.models.Warning.user_id == str(user.id) - ) - .where(self.bot.models.Warning.guild_id == str(interaction.guild.id)) - .gino.all() - ) - warning_str = "" - for warning in warnings: - warning_str += f"{warning.reason} - {warning.time.date()}\n" - if warning_str: - embed.add_field( - name="**Warnings**", - value=warning_str, - inline=True, - ) - - # If the user has a pending application, show it - # If the user is banned from making applications, show it - application_cog = interaction.client.get_cog("ApplicationManager") - if application_cog: - has_application = await application_cog.search_for_pending_application(user) - is_banned = await application_cog.get_ban_entry(user) - embed.add_field( - name="Application information:", - value=( - f"Has pending application: {bool(has_application)}\nIs banned from" - f" making applications: {bool(is_banned)}" - ), - inline=True, - ) - return embed - - @app_commands.check(is_writer) - @notes.command( - name="set", - description="Sets a note for a user, which can be read later from their whois", - extras={ - "brief": "Sets a note for a user", - "usage": "@user [note]", - "module": "who", - }, - ) - async def set_note( - self: Self, interaction: discord.Interaction, user: discord.Member, body: str - ) -> None: - """Adds a new note to a user - This is the entrance for the /note set command - - Args: - interaction (discord.Interaction): The interaction that called this command - user (discord.Member): The member to add the note to - body (str): The contents of the note being created - """ - if interaction.user.id == user.id: - embed = auxiliary.prepare_deny_embed( - message="You cannot add a note for yourself" - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - note = self.bot.models.UserNote( - user_id=str(user.id), - guild_id=str(interaction.guild.id), - author_id=str(interaction.user.id), - body=body, - ) - - config = self.bot.guild_configs[str(interaction.guild.id)] - - # Check to make sure notes are allowed to be assigned - for name in config.extensions.who.note_bypass.value: - role_check = discord.utils.get(interaction.guild.roles, name=name) - if not role_check: - continue - if role_check in getattr(user, "roles", []): - embed = auxiliary.prepare_deny_embed( - message=f"You cannot assign notes to `{user}` because " - + f"they have `{role_check}` role", - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - await note.create() - - role = discord.utils.get( - interaction.guild.roles, name=config.extensions.who.note_role.value - ) - - if not role: - embed = auxiliary.prepare_confirm_embed( - message=f"Note created for `{user}`, but no note " - + "role is configured so no role was added", - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - await user.add_roles(role, reason=f"First note was added by {interaction.user}") - - embed = auxiliary.prepare_confirm_embed(message=f"Note created for `{user}`") - await interaction.response.send_message(embed=embed, ephemeral=True) - - @app_commands.check(is_writer) - @notes.command( - name="clear", - description="Clears all existing notes for a user", - extras={ - "brief": "Clears all notes for a user", - "usage": "@user", - "module": "who", - }, - ) - async def clear_notes( - self: Self, interaction: discord.Interaction, user: discord.Member - ) -> None: - """Clears all notes on a given user - This is the entrace for the /note clear command - - Args: - interaction (discord.Interaction): The interaction that called this command - user (discord.Member): The member to remove all notes from - """ - notes = await self.get_notes(user, interaction.guild) - - if not notes: - embed = auxiliary.prepare_deny_embed( - message="There are no notes for that user" - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - await interaction.response.defer(ephemeral=True) - view = ui.Confirm() - - await view.send( - message=f"Are you sure you want to clear {len(notes)} notes?", - channel=interaction.channel, - author=interaction.user, - interaction=interaction, - ephemeral=True, - ) - - await view.wait() - if view.value is ui.ConfirmResponse.TIMEOUT: - return - if view.value is ui.ConfirmResponse.DENIED: - embed = auxiliary.prepare_deny_embed( - message=f"Notes for `{user}` were not cleared" - ) - await view.followup.send(embed=embed, ephemeral=True) - return - - for note in notes: - await note.delete() - - config = self.bot.guild_configs[str(interaction.guild.id)] - role = discord.utils.get( - interaction.guild.roles, name=config.extensions.who.note_role.value - ) - if role: - await user.remove_roles( - role, reason=f"Notes were cleared by {interaction.user}" - ) - - embed = auxiliary.prepare_confirm_embed(message=f"Notes cleared for `{user}`") - await view.followup.send(embed=embed, ephemeral=True) - - @app_commands.check(is_reader) - @notes.command( - name="all", - description="Gets all notes for a user instead of just new ones", - extras={ - "brief": "Gets all notes for a user", - "usage": "@user", - "module": "who", - }, - ) - async def all_notes( - self: Self, interaction: discord.Interaction, user: discord.Member - ) -> None: - """Gets a file containing every note on a user - This is the entrance for the /note all command - - Args: - interaction (discord.Interaction): The interaction that called this command - user (discord.Member): The member to get all notes for - """ - notes = await self.get_notes(user, interaction.guild) - - if not notes: - embed = auxiliary.prepare_deny_embed( - message=f"There are no notes for `{user}`" - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - return - - note_output_data = [] - for note in notes: - author = interaction.guild.get_member(int(note.author_id)) or note.author_id - data = { - "body": note.body, - "from": str(author), - "at": str(note.updated), - } - note_output_data.append(data) - - yaml_file = discord.File( - io.StringIO(yaml.dump({"notes": note_output_data})), - filename=f"notes-for-{user.id}-{datetime.datetime.utcnow()}.yaml", - ) - - await interaction.response.send_message(file=yaml_file, ephemeral=True) - - async def get_notes( - self: Self, user: discord.Member, guild: discord.Guild - ) -> list[bot.models.UserNote]: - """Calls to the database to get a list of note database entries for a given user and guild - - Args: - user (discord.Member): The member to look for notes for - guild (discord.Guild): The guild to fetch the notes from - - Returns: - list[bot.models.UserNote]: The list of notes on the member/guild combo. - Will be an empty list if there are no notes - """ - user_notes = ( - await self.bot.models.UserNote.query.where( - self.bot.models.UserNote.user_id == str(user.id) - ) - .where(self.bot.models.UserNote.guild_id == str(guild.id)) - .order_by(self.bot.models.UserNote.updated.desc()) - .gino.all() - ) - - return user_notes - - # re-adds note role back to joining users - @commands.Cog.listener() - async def on_member_join(self: Self, member: discord.Member) -> None: - """Automatic listener to look at users when they join the guild. - This is to apply the note role back to joining users - - Args: - member (discord.Member): The member who has just joined - """ - config = self.bot.guild_configs[str(member.guild.id)] - if not self.extension_enabled(config): - return - - role = discord.utils.get( - member.guild.roles, name=config.extensions.who.note_role.value - ) - if not role: - return - - user_notes = await self.get_notes(member, member.guild) - if not user_notes: - return - - await member.add_roles(role, reason="Noted user has joined the guild") - - log_channel = config.get("logging_channel") - await self.bot.logger.send_log( - message=f"Found noted user with ID {member.id} joining - re-adding role", - level=LogLevel.INFO, - context=LogContext(guild=member.guild), - channel=log_channel, - ) diff --git a/techsupport_bot/commands/whois.py b/techsupport_bot/commands/whois.py new file mode 100644 index 000000000..26f0008c5 --- /dev/null +++ b/techsupport_bot/commands/whois.py @@ -0,0 +1,171 @@ +"""Module for the who extension for the discord bot.""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Self + +import discord +import ui +from commands import application, moderator, notes +from core import auxiliary, cogs, moderation +from discord import app_commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Loading the Who plugin into the bot + + Args: + bot (bot.TechSupportBot): The bot object to register the cogs to + """ + await bot.add_cog(Whois(bot=bot, extension_name="whois")) + + +class Whois(cogs.BaseCog): + """The class for the /whois command""" + + @app_commands.command( + name="whois", + description="Gets Discord user information", + extras={"brief": "Gets user data", "usage": "@user", "module": "whois"}, + ) + async def whois_command( + self: Self, interaction: discord.Interaction, member: discord.Member + ) -> None: + """This is the base of the /whois command + + Args: + interaction (discord.Interaction): The interaction that called this command + member (discord.Member): The member to lookup. Will not work on discord.User + """ + await interaction.response.defer(ephemeral=True) + + embed = auxiliary.generate_basic_embed( + title=f"User info for `{member.display_name}` (`{member.name}`)", + description="**Note: this is a bot account!**" if member.bot else "", + color=discord.Color.dark_blue(), + url=member.display_avatar.url, + ) + + embed.add_field( + name="Created", value=f"" + ) + embed.add_field(name="Joined", value=f"") + embed.add_field( + name="Status", value=interaction.guild.get_member(member.id).status + ) + embed.add_field(name="Nickname", value=member.display_name) + + role_string = ", ".join(role.name for role in member.roles[1:]) + embed.add_field(name="Roles", value=role_string or "No roles") + + config = self.bot.guild_configs[str(interaction.guild.id)] + + if "application" in config.enabled_extensions: + try: + await application.command_permission_check(interaction) + embed = await add_application_info_field(interaction, member, embed) + except (app_commands.MissingAnyRole, app_commands.AppCommandError): + pass + + if interaction.permissions.kick_members: + flags = [] + if member.flags.automod_quarantined_username: + flags.append("Quarantined by Automod") + if not member.flags.completed_onboarding: + flags.append("Has not completed onboarding") + if member.flags.did_rejoin: + flags.append("Has left and rejoined the server") + if member.flags.guest: + flags.append("Is a guest") + if member.public_flags.staff: + flags.append("Is discord staff") + if member.public_flags.spammer: + flags.append("Is a flagged spammer") + if ( + member.is_timed_out + and member.timed_out_until + and member.timed_out_until.astimezone(datetime.timezone.utc) + > datetime.datetime.now((datetime.timezone.utc)) + ): + flags.append( + f"Is timed out until " + ) + + flag_string = "\n - ".join(flag for flag in flags) + if flag_string: + embed.add_field(name="Flags", value=f"- {flag_string}", inline=False) + + embeds = [embed] + + if "notes" in config.enabled_extensions: + try: + await notes.is_reader(interaction) + all_notes = await moderation.get_all_notes( + self.bot, member, interaction.guild + ) + notes_embeds = notes.build_note_embeds( + interaction.guild, member, all_notes + ) + notes_embeds[0].description = ( + f"Showing {min(len(all_notes), 6)}/{len(all_notes)} notes" + ) + embeds.append(notes_embeds[0]) + except (app_commands.MissingAnyRole, app_commands.AppCommandError): + pass + + if ( + "moderator" in config.enabled_extensions + and interaction.permissions.kick_members + ): + all_warnings = await moderation.get_all_warnings( + self.bot, member, interaction.guild + ) + warning_embeds = moderator.build_warning_embeds( + interaction.guild, member, all_warnings + ) + warning_embeds[0].description = ( + f"Showing {min(len(all_warnings), 6)}/{len(all_warnings)} warnings" + ) + embeds.append(warning_embeds[0]) + + view = ui.PaginateView() + await view.send( + interaction.channel, interaction.user, embeds, interaction, True + ) + return + + +async def add_application_info_field( + interaction: discord.Interaction, + user: discord.Member, + embed: discord.Embed, +) -> discord.Embed: + """Makes modifications to the whois embed to add mod only information + + Args: + interaction (discord.Interaction): The interaction where the /whois command was called + user (discord.Member): The user being looked up + embed (discord.Embed): The embed already filled with whois information + + Returns: + discord.Embed: The embed with mod only information added + """ + # If the user has a pending application, show it + # If the user is banned from making applications, show it + application_cog = interaction.client.get_cog("ApplicationManager") + if application_cog: + has_application = await application_cog.search_for_pending_application(user) + is_banned = await application_cog.get_ban_entry(user) + embed.add_field( + name="Application information:", + value=( + f"Has pending application: {bool(has_application)}\nIs banned from" + f" making applications: {bool(is_banned)}" + ), + inline=True, + ) + return embed diff --git a/techsupport_bot/core/__init__.py b/techsupport_bot/core/__init__.py index c34c5c7c6..220730286 100644 --- a/techsupport_bot/core/__init__.py +++ b/techsupport_bot/core/__init__.py @@ -5,3 +5,4 @@ from .custom_errors import * from .databases import * from .http import * +from .moderation import * diff --git a/techsupport_bot/core/auxiliary.py b/techsupport_bot/core/auxiliary.py index 60ff50b61..c724cbfbd 100644 --- a/techsupport_bot/core/auxiliary.py +++ b/techsupport_bot/core/auxiliary.py @@ -93,7 +93,11 @@ async def add_list_of_reactions(message: discord.Message, reactions: list) -> No reactions (list): A list of all unicode emojis to add """ for emoji in reactions: - await message.add_reaction(emoji) + try: + await message.add_reaction(emoji) + except discord.NotFound: + # Message was deleted, ignore and stop executing + return def construct_mention_string(targets: list[discord.User]) -> str: diff --git a/techsupport_bot/core/custom_errors.py b/techsupport_bot/core/custom_errors.py index f90f7f009..a1ff53e28 100644 --- a/techsupport_bot/core/custom_errors.py +++ b/techsupport_bot/core/custom_errors.py @@ -67,6 +67,17 @@ def __init__(self: Self, wait: int) -> None: self.wait = wait +class HTTPRateLimitAppCommand(app_commands.CommandInvokeError): + """An API call is on rate limit + + Args: + wait (int): The amount of seconds left until the rate limit expires + """ + + def __init__(self: Self, wait: int) -> None: + self.wait = wait + + class ErrorResponse: """Object for generating a custom error message from an exception. @@ -252,6 +263,10 @@ def get_message(self: Self, exception: Exception = None) -> str: "That API is on cooldown. Try again in %.2f seconds", {"key": "wait"}, ), + HTTPRateLimitAppCommand: ErrorResponse( + "That API is on cooldown. Try again in %.2f seconds", + {"key": "wait"}, + ), # -Custom errors- FactoidNotFoundError: ErrorResponse( "I couldn't find the factoid `%s`", {"key": "argument"} diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index 9caf9564f..7f5be522a 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -63,6 +63,29 @@ class ApplicationBans(bot.db.Model): guild_id: str = bot.db.Column(bot.db.String) applicant_id: str = bot.db.Column(bot.db.String) + class BanLog(bot.db.Model): + """The postgres table for banlogs + Currently used in modlog.py + + Attributes: + __tablename__ (str): The name of the table in postgres + pk (int): The automatic primary key + guild_id (str): The string of the guild ID the user was banned in + reason (str): The reason of the ban + banning_moderator (str): The ID of the moderator who banned + banned_member (str): The ID of the user who was banned + ban_time (datetime): The date and time of the ban + """ + + __tablename__ = "banlog" + + pk = bot.db.Column(bot.db.Integer, primary_key=True, autoincrement=True) + guild_id = bot.db.Column(bot.db.String) + reason = bot.db.Column(bot.db.String) + banning_moderator = bot.db.Column(bot.db.String) + banned_member = bot.db.Column(bot.db.String) + ban_time = bot.db.Column(bot.db.DateTime, default=datetime.datetime.utcnow) + class DuckUser(bot.db.Model): """The postgres table for ducks Currently used in duck.py @@ -228,22 +251,22 @@ class Warning(bot.db.Model): Currently used in protect.py and who.py Attributes: + __tablename__ (str): The name of the table in postgres pk (int): The primary key for the database user_id (str): The user who got warned guild_id (str): The guild this warn occured in reason (str): The reason for the warn - time (datetime.datetime): The time the warning was given + time (datetime): The time the warning was given + invoker_id (str): The moderator who made the warning """ __tablename__ = "warnings" - - pk: int = bot.db.Column(bot.db.Integer, primary_key=True) - user_id: str = bot.db.Column(bot.db.String) - guild_id: str = bot.db.Column(bot.db.String) - reason: str = bot.db.Column(bot.db.String) - time: datetime.datetime = bot.db.Column( - bot.db.DateTime, default=datetime.datetime.utcnow - ) + pk = bot.db.Column(bot.db.Integer, primary_key=True) + user_id = bot.db.Column(bot.db.String) + guild_id = bot.db.Column(bot.db.String) + reason = bot.db.Column(bot.db.String) + time = bot.db.Column(bot.db.DateTime, default=datetime.datetime.utcnow) + invoker_id = bot.db.Column(bot.db.String) class Config(bot.db.Model): """The postgres table for guild config @@ -343,6 +366,7 @@ class Votes(bot.db.Model): bot.models.Applications = Applications bot.models.AppBans = ApplicationBans + bot.models.BanLog = BanLog bot.models.DuckUser = DuckUser bot.models.Factoid = Factoid bot.models.FactoidJob = FactoidJob diff --git a/techsupport_bot/core/http.py b/techsupport_bot/core/http.py index 33bc89ba6..ffe747174 100644 --- a/techsupport_bot/core/http.py +++ b/techsupport_bot/core/http.py @@ -100,12 +100,14 @@ async def http_call( Raises: HTTPRateLimit: Raised if the API is currently on cooldown + HTTPRateLimitAppCommand: Raised if the API is currently on cooldown Returns: munch.Munch: The munch object containing the response from the API """ # Get the URL not the endpoint being called + use_app_error = kwargs.pop("use_app_error", False) ignore_rate_limit = False root_url = urlparse(url).netloc @@ -138,6 +140,8 @@ async def http_call( now - self.url_rate_limit_history[root_url][0] ) time_to_wait = max(time_to_wait, 0) + if use_app_error: + raise custom_errors.HTTPRateLimitAppCommand(time_to_wait) raise custom_errors.HTTPRateLimit(time_to_wait) # Add an entry for this call with the timestamp the call was placed diff --git a/techsupport_bot/core/moderation.py b/techsupport_bot/core/moderation.py new file mode 100644 index 000000000..c87859e7c --- /dev/null +++ b/techsupport_bot/core/moderation.py @@ -0,0 +1,281 @@ +"""This file will hold the core moderation functions. These functions will: +Do the proper moderative action and return true if successful, false if not.""" + +import datetime + +import discord +import munch + + +async def ban_user( + guild: discord.Guild, user: discord.User, delete_seconds: int, reason: str +) -> bool: + """A very simple function that bans a given user from the passed guild + + Args: + guild (discord.Guild): The guild to ban from + user (discord.User): The user who needs to be banned + delete_seconds (int): The numbers of seconds of past messages to delete + reason (str): The reason for banning + + Returns: + bool: True if ban was successful + """ + # Ban the user + await guild.ban( + user, + reason=reason, + delete_message_seconds=delete_seconds, + ) + return True + + +async def unban_user(guild: discord.Guild, user: discord.User, reason: str) -> bool: + """A very simple functon that unbans a given user from the passed guild + + Args: + guild (discord.Guild): The guild to unban from + user (discord.User): The user to unban + reason (str): The reason they are being unbanned + + Returns: + bool: True if unban was successful + """ + # Attempt to unban. If the user isn't found, return false + try: + await guild.unban(user, reason=reason) + return True + except discord.NotFound: + return False + + +async def kick_user(guild: discord.Guild, user: discord.Member, reason: str) -> bool: + """A very simple function that kicks a given user from the guild + + Args: + guild (discord.Guild): The guild to kick from + user (discord.Member): The member to kick from the guild + reason (str): The reason they are being kicked + + Returns: + bool: True if kick was successful + """ + await guild.kick(user, reason=reason) + return True + + +async def mute_user( + user: discord.Member, reason: str, duration: datetime.timedelta +) -> bool: + """Times out a given user + + Args: + user (discord.Member): The user to timeout + reason (str): The reason they are being timed out + duration (datetime.timedelta): How long to timeout the user for + + Returns: + bool: True if the timeout was successful + """ + try: + await user.timeout(duration, reason=reason) + except discord.Forbidden: + return False + return True + + +async def unmute_user(user: discord.Member, reason: str) -> bool: + """Untimes out a given user. + + Args: + user (discord.Member): The user to untimeout + reason (str): The reason they are being untimeout + + Returns: + bool: True if untimeout was successful + """ + if not user.timed_out_until: + return False + await user.timeout(None, reason=reason) + return True + + +async def warn_user( + bot_object: object, + user: discord.Member, + invoker: discord.Member, + reason: str, +) -> bool: + """Warns a user. Does NOT check config or how many warnings a user has + + Args: + bot_object (object): The bot object to use + user (discord.Member): The user to warn + invoker (discord.Member): The person who warned the user + reason (str): The reason for the warning + + Returns: + bool: True if warning was successful + """ + await bot_object.models.Warning( + user_id=str(user.id), + guild_id=str(invoker.guild.id), + reason=reason, + invoker_id=str(invoker.id), + ).create() + return True + + +async def unwarn_user(bot_object: object, user: discord.Member, warning: str) -> bool: + """Removes a specific warning from a user by string + + Args: + bot_object (object): The bot object to use + user (discord.Member): The member to remove a warning from + warning (str): The warning to remove + + Returns: + bool: True if unwarning was successful + """ + query = ( + bot_object.models.Warning.query.where( + bot_object.models.Warning.guild_id == str(user.guild.id) + ) + .where(bot_object.models.Warning.reason == warning) + .where(bot_object.models.Warning.user_id == str(user.id)) + ) + entry = await query.gino.first() + if not entry: + return False + await entry.delete() + return True + + +async def get_all_warnings( + bot_object: object, user: discord.User, guild: discord.Guild +) -> list[munch.Munch]: + """Gets a list of all warnings for a specific user in a specific guild + + Args: + bot_object (object): The bot object to use + user (discord.User): The user that we want warns from + guild (discord.Guild): The guild that we want warns from + + Returns: + list[munch.Munch]: The list of all warnings for the user/guild, if any exist + """ + warnings = ( + await bot_object.models.Warning.query.where( + bot_object.models.Warning.user_id == str(user.id) + ) + .where(bot_object.models.Warning.guild_id == str(guild.id)) + .order_by(bot_object.models.Warning.time.desc()) + .gino.all() + ) + return warnings + + +async def get_all_notes( + bot: object, user: discord.Member, guild: discord.Guild +) -> list[munch.Munch]: + """Calls to the database to get a list of note database entries for a given user and guild + + Args: + bot (object): The TS bot object to use for the database lookup + user (discord.Member): The member to look for notes for + guild (discord.Guild): The guild to fetch the notes from + + Returns: + list[munch.Munch]: The list of notes on the member/guild combo. + Will be an empty list if there are no notes + """ + user_notes = ( + await bot.models.UserNote.query.where( + bot.models.UserNote.user_id == str(user.id) + ) + .where(bot.models.UserNote.guild_id == str(guild.id)) + .order_by(bot.models.UserNote.updated.desc()) + .gino.all() + ) + + return user_notes + + +async def send_command_usage_alert( + bot_object: object, + interaction: discord.Interaction, + command: str, + guild: discord.Guild, + target: discord.Member = None, +) -> None: + """Sends a usage alert to the protect events channel, if configured + + Args: + bot_object (object): The bot object to use + interaction (discord.Interaction): The interaction that trigger the command + command (str): The string representation of the command that was run + guild (discord.Guild): The guild the command was run in + target (discord.Member): The target of the command + """ + + ALERT_ICON_URL: str = ( + "https://www.iconarchive.com/download/i76061/martz90/circle-addon2/warning.512.png" + ) + + config = bot_object.guild_configs[str(guild.id)] + + try: + alert_channel = guild.get_channel(int(config.moderation.alert_channel)) + except TypeError: + alert_channel = None + + if not alert_channel: + return + + embed = discord.Embed(title="Command Usage Alert") + + embed.description = f"**Command**\n`{command}`" + embed.add_field( + name="Channel", + value=f"{interaction.channel.name} ({interaction.channel.mention}) [Jump to context]" + f"(https://discord.com/channels/{interaction.guild.id}/{interaction.channel.id}/" + f"{discord.utils.time_snowflake(datetime.datetime.utcnow())})", + ) + + embed.add_field( + name="Invoking User", + value=( + f"{interaction.user.display_name} ({interaction.user.mention}, {interaction.user.id})" + ), + ) + + if target: + embed.add_field( + name="Target", + value=f"{target.display_name} ({target.mention}, {target.id})", + ) + + embed.set_thumbnail(url=ALERT_ICON_URL) + embed.color = discord.Color.red() + embed.timestamp = datetime.datetime.utcnow() + + await alert_channel.send(embed=embed) + + +async def check_if_user_banned(user: discord.User, guild: discord.Guild) -> bool: + """Queries the given guild to find if the given discord.User is banned or not + + Args: + user (discord.User): The user to search for being banned + guild (discord.Guild): The guild to search the bans for + + Returns: + bool: Whether the user is banned or not + """ + + try: + await guild.fetch_ban(user) + except discord.NotFound: + return False + + return True diff --git a/techsupport_bot/functions/__init__.py b/techsupport_bot/functions/__init__.py index 26928a165..df256aa0b 100644 --- a/techsupport_bot/functions/__init__.py +++ b/techsupport_bot/functions/__init__.py @@ -1,3 +1,4 @@ """Functions are commandless cogs""" +from .automod import * from .nickname import * diff --git a/techsupport_bot/functions/automod.py b/techsupport_bot/functions/automod.py new file mode 100644 index 000000000..84c243700 --- /dev/null +++ b/techsupport_bot/functions/automod.py @@ -0,0 +1,616 @@ +"""Handles the automod checks""" + +from __future__ import annotations + +import datetime +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Self + +import discord +import munch +from botlogging import LogContext, LogLevel +from commands import moderator, modlog +from core import cogs, extensionconfig, moderation +from discord.ext import commands + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="channels", + datatype="list", + title="Protected channels", + description=( + "The list of channel ID's associated with the channels to auto-protect" + ), + default=[], + ) + config.add( + key="bypass_roles", + datatype="list", + title="Bypassed role names", + description=( + "The list of role names associated with bypassed roles by the auto-protect" + ), + default=[], + ) + config.add( + key="string_map", + datatype="dict", + title="Keyword string map", + description=( + "Mapping of keyword strings to data defining the action taken by" + " auto-protect" + ), + default={}, + ) + config.add( + key="banned_file_extensions", + datatype="dict", + title="List of banned file types", + description=( + "A list of all file extensions to be blocked and have a auto warning issued" + ), + default=[], + ) + config.add( + key="alert_channel", + datatype="int", + title="Alert channel ID", + description="The ID of the channel to send auto-protect alerts to", + default=None, + ) + config.add( + key="max_mentions", + datatype="int", + title="Max message mentions", + description=( + "Max number of mentions allowed in a message before triggering auto-protect" + ), + default=3, + ) + await bot.add_cog(AutoMod(bot=bot, extension_name="automod")) + bot.add_extension_config("automod", config) + + +@dataclass +class AutoModPunishment: + """This is a base class holding the violation and recommended actions + Since automod is a framework, the actions can translate to different things + + Attributes: + violation_str (str): The string of the policy broken. Should be displayed to user + recommend_delete (bool): If the policy recommends deletion of the message + recommend_warn (bool): If the policy recommends warning the user + recommend_mute (int): If the policy recommends muting the user. + If so, the amount of seconds to mute for. + is_silent (bool, optional): If the punishment should be silent. Defaults to False + score (int): The weighted score for sorting punishments + + """ + + violation_str: str + recommend_delete: bool + recommend_warn: bool + recommend_mute: int + is_silent: bool = False + + @property + def score(self: Self) -> int: + """A score so that the AutoModPunishment object is sortable + This sorts based on actions recommended to be taken + + Returns: + int: The score + """ + score = 0 + if self.recommend_mute: + score += 4 + if self.recommend_warn: + score += 2 + if self.recommend_delete: + score += 1 + return score + + +@dataclass +class AutoModAction: + """The final summarized action for this automod violation + + Attributes: + warn (bool): Whether the user should be warned + delete_message (bool): Whether the message should be deleted + mute (bool): Whether the user should be muted + mute_duration (int): How many seconds to mute the user for + be_silent (bool): If the actions should be taken silently + action_string (str): The string of & separated actions taken + violation_string (str): The most severe punishment to be used as a reason + total_punishments (str): All the punishment reasons + violations_list (list[AutoModPunishment]): The list of original AutoModPunishment items + + """ + + warn: bool + delete_message: bool + mute: bool + mute_duration: int + be_silent: bool + action_string: str + violation_string: str + total_punishments: str + violations_list: list[AutoModPunishment] + + +class AutoMod(cogs.MatchCog): + """Holds all of the discord message specific automod functions + Most of the automod is a class function""" + + async def match( + self: Self, config: munch.Munch, ctx: commands.Context, content: str + ) -> bool: + """Checks to see if a message should be considered for automod violations + + Args: + config (munch.Munch): The config of the guild to check + ctx (commands.Context): The context of the original message + content (str): The string representation of the message + + Returns: + bool: Whether the message should be inspected for automod violations + """ + if not str(ctx.channel.id) in config.extensions.automod.channels.value: + await self.bot.logger.send_log( + message="Channel not in automod channels - ignoring automod check", + level=LogLevel.DEBUG, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + return False + + role_names = [role.name.lower() for role in getattr(ctx.author, "roles", [])] + + if any( + role_name.lower() in role_names + for role_name in config.extensions.automod.bypass_roles.value + ): + return False + + return True + + async def response( + self: Self, + config: munch.Munch, + ctx: commands.Context, + content: str, + result: bool, + ) -> None: + """Handles a discord automod violation + + Args: + config (munch.Munch): The config of the guild where the message was sent + ctx (commands.Context): The context the message was sent in + content (str): The string content of the message + result (bool): What the match() function returned + """ + + # If user outranks bot, do nothing + if ctx.message.author.top_role >= ctx.channel.guild.me.top_role: + return + + all_punishments = run_all_checks(config, ctx.message) + + if len(all_punishments) == 0: + return + + total_punishment = process_automod_violations(all_punishments=all_punishments) + + if total_punishment.mute > 0: + await moderation.mute_user( + user=ctx.author, + reason=total_punishment.violation_string, + duration=datetime.timedelta(seconds=total_punishment.mute_duration), + ) + + if total_punishment.delete_message: + await ctx.message.delete() + + if total_punishment.warn: + count_of_warnings = ( + len(await moderation.get_all_warnings(self.bot, ctx.author, ctx.guild)) + + 1 + ) + total_punishment.violation_string += ( + f" ({count_of_warnings} total warnings)" + ) + await moderation.warn_user( + self.bot, + ctx.author, + ctx.channel.guild.me, + total_punishment.violation_string, + ) + if count_of_warnings >= config.moderation.max_warnings: + ban_embed = moderator.generate_response_embed( + ctx.author, + "ban", + reason=( + f"Over max warning count {count_of_warnings} out of" + f" {config.moderation.max_warnings} (final warning:" + f" {total_punishment.violation_string}) - banned by automod" + ), + ) + if not total_punishment.be_silent: + await ctx.send(content=ctx.author.mention, embed=ban_embed) + try: + await ctx.author.send(embed=ban_embed) + except discord.Forbidden: + await self.bot.logger.send_log( + message=f"Could not DM {ctx.author} about being banned", + level=LogLevel.WARNING, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + + await moderation.ban_user( + ctx.guild, + ctx.author, + delete_seconds=( + config.extensions.moderator.ban_delete_duration.value * 86400 + ), + reason=total_punishment.violation_string, + ) + await modlog.log_ban( + self.bot, + ctx.author, + ctx.guild.me, + ctx.guild, + total_punishment.violation_string, + ) + + if total_punishment.be_silent: + return + + embed = moderator.generate_response_embed( + ctx.author, + total_punishment.action_string, + total_punishment.violation_string, + ) + + await ctx.send(content=ctx.author.mention, embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await self.bot.logger.send_log( + message=f"Could not DM {ctx.author} about being automodded", + level=LogLevel.WARNING, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + + alert_channel_embed = generate_automod_alert_embed( + ctx, total_punishment.total_punishments, total_punishment.action_string + ) + + config = self.bot.guild_configs[str(ctx.guild.id)] + + try: + alert_channel = ctx.guild.get_channel( + int(config.extensions.automod.alert_channel.value) + ) + except TypeError: + alert_channel = None + + if not alert_channel: + return + + await alert_channel.send(embed=alert_channel_embed) + + @commands.Cog.listener() + async def on_raw_message_edit( + self: Self, payload: discord.RawMessageUpdateEvent + ) -> None: + """This is called when any message is edited in any guild the bot is in. + There is no guarantee that the message exists or is used + + Args: + payload (discord.RawMessageUpdateEvent): The raw event that the edit generated + """ + guild = self.bot.get_guild(payload.guild_id) + if not guild: + return + + config = self.bot.guild_configs[str(guild.id)] + if not self.extension_enabled(config): + return + + channel = self.bot.get_channel(payload.channel_id) + if not channel: + return + + message = await channel.fetch_message(payload.message_id) + if not message: + return + + # Don't trigger if content hasn't changed + if payload.cached_message and payload.cached_message.content == message.content: + return + + ctx = await self.bot.get_context(message) + matched = await self.match(config, ctx, message.content) + if not matched: + return + + await self.response(config, ctx, message.content, matched) + + +def process_automod_violations( + all_punishments: list[AutoModPunishment], +) -> AutoModAction: + """This processes a list of potentially many AutoModPunishments into a single + recommended action + + Args: + all_punishments (list[AutoModPunishment]): The list of punishments that should be taken + + Returns: + AutoModAction: The final summarized action that is recommended to be taken + """ + if len(all_punishments) == 0: + return None + + should_delete = False + should_warn = False + mute_duration = 0 + + silent = True + + sorted_punishments = sorted(all_punishments, key=lambda p: p.score, reverse=True) + for punishment in sorted_punishments: + should_delete = should_delete or punishment.recommend_delete + should_warn = should_warn or punishment.recommend_warn + mute_duration = max(mute_duration, punishment.recommend_mute) + + if not punishment.is_silent: + silent = False + + actions = [] + + reason_str = sorted_punishments[0].violation_str + + if mute_duration > 0: + actions.append("mute") + + if should_delete: + actions.append("delete") + + if should_warn: + actions.append("warn") + + if len(actions) == 0: + actions.append("notice") + + actions_str = " & ".join(actions) + + all_alerts_str = "\n".join(violation.violation_str for violation in all_punishments) + + final_action = AutoModAction( + warn=should_warn, + delete_message=should_delete, + mute=mute_duration > 0, + mute_duration=mute_duration, + be_silent=silent, + action_string=actions_str, + violation_string=reason_str, + total_punishments=all_alerts_str, + violations_list=all_punishments, + ) + + return final_action + + +def generate_automod_alert_embed( + ctx: commands.Context, violations: str, action_taken: str +) -> discord.Embed: + """Generates an alert embed for the automod rules that are broken + + Args: + ctx (commands.Context): The context of the message that violated the automod + violations (str): The string form of ALL automod violations the user triggered + action_taken (str): The text based action taken against the user + + Returns: + discord.Embed: The formatted embed ready to be sent to discord + """ + + ALERT_ICON_URL: str = ( + "https://www.iconarchive.com/download/i76061/martz90/circle-addon2/warning.512.png" + ) + + embed = discord.Embed( + title="Automod Violations", + description=violations, + ) + embed.add_field(name="Actions Taken", value=action_taken) + embed.add_field(name="Channel", value=f"{ctx.channel.mention} ({ctx.channel.name})") + embed.add_field( + name="User", value=f"{ctx.author.mention} ({ctx.author.name}, {ctx.author.id})" + ) + embed.add_field(name="Message", value=ctx.message.content[:1024], inline=False) + embed.add_field(name="URL", value=ctx.message.jump_url, inline=False) + + embed.set_thumbnail(url=ALERT_ICON_URL) + embed.color = discord.Color.red() + embed.timestamp = datetime.datetime.utcnow() + + return embed + + +# Automod will only ever be a framework to say something needs to be done +# Outside of running from the response function, NO ACTION will be taken +# All checks will return a list of AutoModPunishment, which may be nothing + + +def run_all_checks( + config: munch.Munch, message: discord.Message +) -> list[AutoModPunishment]: + """This runs all 4 checks on a given discord.Message + handle_file_extensions + handle_mentions + handle_exact_string + handle_regex_string + + Args: + config (munch.Munch): The guild config to check with + message (discord.Message): The message object to use to search + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + all_violations = ( + run_only_string_checks(config, message.clean_content) + + handle_file_extensions(config, message.attachments) + + handle_mentions(config, message) + ) + return all_violations + + +def run_only_string_checks( + config: munch.Munch, content: str +) -> list[AutoModPunishment]: + """This runs the plaintext string texts and returns the combined list of violations + handle_exact_string + handle_regex_string + + Args: + config (munch.Munch): The guild config to check with + content (str): The content of the message to search + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + all_violations = handle_exact_string(config, content) + handle_regex_string( + config, content + ) + return all_violations + + +def handle_file_extensions( + config: munch.Munch, attachments: list[discord.Attachment] +) -> list[AutoModPunishment]: + """This checks a list of attachments for attachments that violate the automod rules + + Args: + config (munch.Munch): The guild config to check with + attachments (list[discord.Attachment]): The list of attachments to search + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + violations = [] + for attachment in attachments: + if ( + attachment.filename.split(".")[-1] + in config.extensions.automod.banned_file_extensions.value + ): + violations.append( + AutoModPunishment( + f"{attachment.filename} has a suspicious file extension", + recommend_delete=True, + recommend_warn=True, + recommend_mute=0, + ) + ) + return violations + + +def handle_mentions( + config: munch.Munch, message: discord.Message +) -> list[AutoModPunishment]: + """This checks a given discord message to make sure it doesn't violate the mentions maximum + + Args: + config (munch.Munch): The guild config to check with + message (discord.Message): The message to check for mentions with + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + if len(message.mentions) > config.extensions.automod.max_mentions.value: + return [ + AutoModPunishment( + "Mass Mentions", + recommend_delete=True, + recommend_warn=True, + recommend_mute=0, + ) + ] + return [] + + +def handle_exact_string(config: munch.Munch, content: str) -> list[AutoModPunishment]: + """This checks the configued automod exact string blocks + If the content matches the string, it's added to a list + + Args: + config (munch.Munch): The guild config to check with + content (str): The content of the message to search + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + violations = [] + for ( + keyword, + filter_config, + ) in config.extensions.automod.string_map.value.items(): + if keyword.lower() in content.lower(): + violations.append( + AutoModPunishment( + filter_config.message, + filter_config.delete, + filter_config.warn, + filter_config.mute, + filter_config.silent_punishment, + ) + ) + return violations + + +def handle_regex_string(config: munch.Munch, content: str) -> list[AutoModPunishment]: + """This checks the configued automod regex blocks + If the content matches the regex, it's added to a list + + Args: + config (munch.Munch): The guild config to check with + content (str): The content of the message to search + + Returns: + list[AutoModPunishment]: The automod violations that the given message violated + """ + violations = [] + for ( + _, + filter_config, + ) in config.extensions.automod.string_map.value.items(): + regex = filter_config.get("regex") + if regex: + try: + match = re.search(regex, content) + except re.error: + match = None + if match: + violations.append( + AutoModPunishment( + filter_config.message, + filter_config.delete, + filter_config.warn, + filter_config.mute, + filter_config.silent_punishment, + ) + ) + return violations diff --git a/techsupport_bot/functions/logger.py b/techsupport_bot/functions/logger.py index dd9982ace..999a2f928 100644 --- a/techsupport_bot/functions/logger.py +++ b/techsupport_bot/functions/logger.py @@ -211,7 +211,7 @@ async def build_attachments( f"Logger did not reupload {lf} file(s) due to file size limit" f" on message {ctx.message.id} in channel {ctx.channel.name}." ), - level=LogLevel.WARN, + level=LogLevel.WARNING, channel=log_channel, context=LogContext(guild=ctx.guild, channel=ctx.channel), ) diff --git a/techsupport_bot/functions/paste.py b/techsupport_bot/functions/paste.py new file mode 100644 index 000000000..8f9b4e946 --- /dev/null +++ b/techsupport_bot/functions/paste.py @@ -0,0 +1,279 @@ +"""The file that holds the paste function""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING, Self + +import discord +import munch +from botlogging import LogContext, LogLevel +from core import cogs, extensionconfig +from discord.ext import commands +from functions import automod + +if TYPE_CHECKING: + import bot + + +async def setup(bot: bot.TechSupportBot) -> None: + """Adds the cog to the bot. Setups config + + Args: + bot (bot.TechSupportBot): The bot object to register the cog with + """ + config = extensionconfig.ExtensionConfig() + config.add( + key="channels", + datatype="list", + title="Protected channels", + description=( + "The list of channel ID's associated with the channels to auto-protect" + ), + default=[], + ) + config.add( + key="bypass_roles", + datatype="list", + title="Bypassed role names", + description=( + "The list of role names associated with bypassed roles by the auto-protect" + ), + default=[], + ) + config.add( + key="length_limit", + datatype="int", + title="Max length limit", + description=( + "The max char limit on messages before they trigger an action by" + " auto-protect" + ), + default=500, + ) + config.add( + key="paste_footer_message", + datatype="str", + title="The linx embed footer", + description="The message used on the footer of the large message paste URL", + default="Note: Long messages are automatically pasted", + ) + await bot.add_cog(Paster(bot=bot, extension_name="paste")) + bot.add_extension_config("paste", config) + + +class Paster(cogs.MatchCog): + """The pasting module""" + + async def match( + self: Self, config: munch.Munch, ctx: commands.Context, content: str + ) -> bool: + """Checks to see if a message should be considered for a paste + + Args: + config (munch.Munch): The config of the guild to check + ctx (commands.Context): The context of the original message + content (str): The string representation of the message + + Returns: + bool: Whether the message should be inspected for a paste + """ + # exit the match based on exclusion parameters + if not str(ctx.channel.id) in config.extensions.paste.channels.value: + await self.bot.logger.send_log( + message="Channel not in protected channels - ignoring protect check", + level=LogLevel.DEBUG, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + return False + + role_names = [role.name.lower() for role in getattr(ctx.author, "roles", [])] + + if any( + role_name.lower() in role_names + for role_name in config.extensions.paste.bypass_roles.value + ): + return False + + return True + + async def response( + self: Self, + config: munch.Munch, + ctx: commands.Context, + content: str, + result: bool, + ) -> None: + """Handles a paste check + + Args: + config (munch.Munch): The config of the guild where the message was sent + ctx (commands.Context): The context the message was sent in + content (str): The string content of the message + result (bool): What the match() function returned + """ + if len(content) > config.extensions.paste.length_limit.value or content.count( + "\n" + ) > self.max_newlines(config.extensions.paste.length_limit.value): + if "automod" in config.get("enabled_extensions", []): + automod_actions = automod.run_all_checks(config, ctx.message) + automod_final = automod.process_automod_violations(automod_actions) + if automod_final and automod_final.delete_message: + return + await self.paste_message(config, ctx, content) + + def max_newlines(self: Self, max_length: int) -> int: + """Gets a theoretical maximum number of new lines in a given message + + Args: + max_length (int): The max length of characters per theoretical line + + Returns: + int: The maximum number of new lines based on config + """ + return int(max_length / 80) + 1 + + @commands.Cog.listener() + async def on_raw_message_edit( + self: Self, payload: discord.RawMessageUpdateEvent + ) -> None: + """This is called when any message is edited in any guild the bot is in. + There is no guarantee that the message exists or is used + + Args: + payload (discord.RawMessageUpdateEvent): The raw event that the edit generated + """ + guild = self.bot.get_guild(payload.guild_id) + if not guild: + return + + config = self.bot.guild_configs[str(guild.id)] + if not self.extension_enabled(config): + return + + channel = self.bot.get_channel(payload.channel_id) + if not channel: + return + + message = await channel.fetch_message(payload.message_id) + if not message: + return + + # Don't trigger if content hasn't changed + if payload.cached_message and payload.cached_message.content == message.content: + return + + ctx = await self.bot.get_context(message) + matched = await self.match(config, ctx, message.content) + if not matched: + return + + await self.response(config, ctx, message.content, None) + + async def paste_message( + self: Self, config: munch.Munch, ctx: commands.Context, content: str + ) -> None: + """Moves message into a linx paste if it's too long + + Args: + config (munch.Munch): The guild config where the too long message was sent + ctx (commands.Context): The context where the original message was sent + content (str): The string content of the flagged message + """ + log_channel = config.get("logging_channel") + if not self.bot.file_config.api.api_url.linx: + await self.bot.logger.send_log( + message=( + f"Would have pasted message {ctx.message.id}" + " but no linx url has been configured." + ), + level=LogLevel.WARNING, + channel=log_channel, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + return + + linx_embed = await self.create_linx_embed(config, ctx, content) + + if not linx_embed: + await self.bot.logger.send_log( + message=( + f"Would have pasted message {ctx.message.id}" + " but uploading the file to linx failed." + ), + level=LogLevel.WARNING, + channel=log_channel, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + return + + attachments: list[discord.File] = [] + if ctx.message.attachments: + total_attachment_size = 0 + for attch in ctx.message.attachments: + if ( + total_attachment_size := total_attachment_size + attch.size + ) <= ctx.filesize_limit: + attachments.append(await attch.to_file()) + if (lf := len(ctx.message.attachments) - len(attachments)) != 0: + await self.bot.logger.send_log( + message=( + f"Protect did not reupload {lf} file(s) due to file size limit." + ), + level=LogLevel.WARNING, + channel=log_channel, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + ) + + message = await ctx.send( + ctx.message.author.mention, embed=linx_embed, files=attachments[:10] + ) + + if message: + await ctx.message.delete() + + async def create_linx_embed( + self: Self, config: munch.Munch, ctx: commands.Context, content: str + ) -> discord.Embed | None: + """This function sends a message to the linx url and puts the result in + an embed to be sent to the user + + Args: + config (munch.Munch): The guild config where the message was sent + ctx (commands.Context): The context that generated the need for a paste + content (str): The context of the message to be pasted + + Returns: + discord.Embed | None: The formatted embed, or None if there was an API error + """ + if not content: + return None + + headers = { + "Linx-Expiry": "1800", + "Linx-Randomize": "yes", + "Accept": "application/json", + } + html_file = {"file": io.StringIO(content)} + response = await self.bot.http_functions.http_call( + "post", + self.bot.file_config.api.api_url.linx, + headers=headers, + data=html_file, + ) + + url = response.get("url") + if not url: + return None + + embed = discord.Embed(description=url) + + embed.add_field(name="Paste Link", value=url) + embed.description = content[0:100].replace("\n", " ") + embed.set_author( + name=f"Paste by {ctx.author}", icon_url=ctx.author.display_avatar.url + ) + embed.set_footer(text=config.extensions.paste.paste_footer_message.value) + embed.color = discord.Color.blue() + + return embed diff --git a/techsupport_bot/ircrelay/__init__.py b/techsupport_bot/ircrelay/__init__.py index 1fd809266..8c48666b8 100644 --- a/techsupport_bot/ircrelay/__init__.py +++ b/techsupport_bot/ircrelay/__init__.py @@ -1,4 +1,4 @@ """Allows python to find the irc packages""" from .formatting import * -from .irc import * +from .relay import * diff --git a/techsupport_bot/ircrelay/irc.py b/techsupport_bot/ircrelay/relay.py similarity index 93% rename from techsupport_bot/ircrelay/irc.py rename to techsupport_bot/ircrelay/relay.py index 725f8d36b..23e668df2 100644 --- a/techsupport_bot/ircrelay/irc.py +++ b/techsupport_bot/ircrelay/relay.py @@ -4,21 +4,22 @@ from __future__ import annotations import asyncio +import functools import logging import os +import ssl import threading from typing import Self import commands import discord -import ib3.auth import irc.bot import irc.client -import irc.strings +import irc.connection from ircrelay import formatting -class IRCBot(ib3.auth.SASL, irc.bot.SingleServerIRCBot): +class IRCBot(irc.bot.SingleServerIRCBot): """The IRC bot class. This is the class that runs the entire IRC side of the bot The class to start the entire IRC bot @@ -58,18 +59,28 @@ def __init__( username: str, password: str, ) -> None: - self.loop = loop + self.username = username + self.password = password + self.join_channel_list = channels + + # SSL context setup + context = ssl.create_default_context() + factory = irc.connection.Factory( + wrapper=functools.partial(context.wrap_socket, server_hostname=server) + ) + + # Pass the correct server info and password super().__init__( - server_list=[(server, port)], + server_list=[ + (server, port, password) + ], # Ensure this has the correct password realname=username, nickname=username, - ident_password=password, - channels=channels, + connect_factory=factory, ) - self.join_channel_list = channels - self.username = username - self.password = password + + # Reconnect handler if disconnected self._on_disconnect = self.reconnect_from_disconnect def exit_irc(self: Self) -> None: @@ -77,6 +88,13 @@ def exit_irc(self: Self) -> None: # pylint: disable=protected-access os._exit(1) + def start_bot(self: Self) -> None: + """Start the bot and handle SASL authentication.""" + self.connection.set_rate_limit(1) # Be nice to server + self.connection.username = self.username + self.connection.sasl_login = self.username + self.start() # Starts the IRC bot's main loop + def reconnect_from_disconnect( self: Self, connection: irc.client.ServerConnection, event: irc.client.Event ) -> None: diff --git a/techsupport_bot/tests/commands_tests/test_extensions_burn.py b/techsupport_bot/tests/commands_tests/test_extensions_burn.py deleted file mode 100644 index 66ec2e473..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_burn.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -This is a file to test the extensions/burn.py file -This contains 6 tests -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock - -import pytest -from core import auxiliary -from tests import config_for_tests - - -class Test_Phrases: - """A simple set of tests to ensure the PHRASES variable won't cause any problems""" - - def test_all_phrases_are_short(self: Self) -> None: - """ - This is a test to ensure that the generate burn embed function is working correctly - This specifically looks at the description for every phrase in the PHRASES array - This looks at the length of the description as well to ensure that - the phrases aren't too long - """ - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - test_phrases = discord_env.burn.PHRASES - - # Step 3 - Assert that everything works - for phrase in test_phrases: - assert len(f"🔥🔥🔥 {phrase} 🔥🔥🔥") <= 4096 - - -class Test_HandleBurn: - """A set of test cases testing the handle_burn function""" - - @pytest.mark.asyncio - async def test_handle_burn_calls_reactions(self: Self) -> None: - """ - This is a test to ensure that handle_burn works correctly when a valid message can be found - It checks to ensure that the reactions are added correctly - """ - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - message_history = [discord_env.message_person1_noprefix_1] - discord_env.channel.message_history = message_history - discord_env.context.send = AsyncMock() - auxiliary.add_list_of_reactions = AsyncMock() - - # Step 2 - Call the function - await discord_env.burn.handle_burn( - discord_env.context, - discord_env.person1, - discord_env.message_person1_noprefix_1, - ) - - # Step 3 - Assert that everything works - auxiliary.add_list_of_reactions.assert_called_once_with( - message=discord_env.message_person1_noprefix_1, - reactions=["🔥", "🚒", "👨‍🚒"], - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_burn_calls_send(self: Self) -> None: - """ - This is a test to ensure that handle_burn works correctly when a valid message can be found - It checks to ensure that the reactions are added correctly - """ - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - message_history = [discord_env.message_person1_noprefix_1] - discord_env.channel.message_history = message_history - discord_env.context.send = AsyncMock() - auxiliary.add_list_of_reactions = AsyncMock() - - # Step 2 - Call the function - await discord_env.burn.handle_burn( - discord_env.context, - discord_env.person1, - discord_env.message_person1_noprefix_1, - ) - - # Step 3 - Assert that everything works - discord_env.context.send.assert_called_once() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_burn_no_message(self: Self) -> None: - """ - This is a test to ensure that the send_deny_embed - function is called if no message can be found - """ - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.burn.handle_burn(discord_env.context, None, None) - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="I could not a find a message to reply to", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - -class Test_BurnCommand: - """A set of test cases for testing the burn_command function""" - - @pytest.mark.asyncio - async def test_calls_search_channel(self: Self) -> None: - """A simple test to ensure that burn_command calls search_channel_for_message - with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - auxiliary.search_channel_for_message = AsyncMock() - discord_env.burn.handle_burn = AsyncMock() - - # Step 2 - Call the function - await discord_env.burn.burn_command(discord_env.context, discord_env.person1) - - # Step 3 - Assert that everything works - auxiliary.search_channel_for_message.assert_called_once_with( - channel=discord_env.context.channel, - prefix=config_for_tests.PREFIX, - member_to_match=discord_env.person1, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_calls_handle_burn(self: Self) -> None: - """A simple test to ensure that burn_command calls handle_burn - with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person1_noprefix_1 - ) - discord_env.burn.handle_burn = AsyncMock() - - # Step 2 - Call the function - await discord_env.burn.burn_command(discord_env.context, discord_env.person1) - - # Step 3 - Assert that everything works - discord_env.burn.handle_burn.assert_called_once_with( - discord_env.context, - discord_env.person1, - discord_env.message_person1_noprefix_1, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_correct.py b/techsupport_bot/tests/commands_tests/test_extensions_correct.py deleted file mode 100644 index d153e7694..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_correct.py +++ /dev/null @@ -1,190 +0,0 @@ -""" -This is a file to test the extensions/correct.py file -This contains 9 tests -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock - -import pytest -from core import auxiliary -from tests import config_for_tests - - -class Test_PrepareMessage: - """A set of tests to test the prepare_message function""" - - def test_prepare_message_success(self: Self) -> None: - """Test to ensure that replacement when the entire message needs to be replaced works""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_content = discord_env.correct.prepare_message( - discord_env.message_person2_noprefix_1.content, "message", "bbbb" - ) - - # Step 3 - Assert that everything works - assert new_content == "**bbbb**" - - def test_prepare_message_multi(self: Self) -> None: - """Test to ensure that replacement works if multiple parts need to be replaced""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_content = discord_env.correct.prepare_message( - discord_env.message_person2_noprefix_1.content, "e", "bbbb" - ) - - # Step 3 - Assert that everything works - assert new_content == "m**bbbb**ssag**bbbb**" - - def test_prepare_message_partial(self: Self) -> None: - """Test to ensure that replacement works if multiple - parts of the message need to be replaced""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_content = discord_env.correct.prepare_message( - discord_env.message_person2_noprefix_1.content, "mes", "bbbb" - ) - - # Step 3 - Assert that everything works - assert new_content == "**bbbb**sage" - - def test_prepare_message_fail(self: Self) -> None: - """Test to ensure that replacement doesnt change anything if needed - This should never happen, but test it here anyway""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - - # Step 2 - Call the function - new_content = discord_env.correct.prepare_message( - discord_env.message_person2_noprefix_1.content, "asdf", "bbbb" - ) - - # Step 3 - Assert that everything works - assert new_content == "message" - - -class Test_HandleCorrect: - """Tests to test the handle_correct function""" - - @pytest.mark.asyncio - async def test_handle_calls_search_for_message(self: Self) -> None: - """This ensures that the search_channel_for_message function is called, - with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.context.message = discord_env.message_person2_noprefix_1 - discord_env.correct.prepare_message = AsyncMock() - auxiliary.search_channel_for_message = AsyncMock() - auxiliary.generate_basic_embed = AsyncMock() - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await discord_env.correct.correct_command(discord_env.context, "a", "b") - - # Step 3 - Assert that everything works - auxiliary.search_channel_for_message.assert_called_once_with( - channel=discord_env.channel, - prefix=config_for_tests.PREFIX, - content_to_match="a", - allow_bot=False, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_calls_prepare_message(self: Self) -> None: - """This ensures that the prepare_message function is called, with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.context.message = discord_env.message_person2_noprefix_1 - discord_env.correct.prepare_message = AsyncMock() - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person2_noprefix_1 - ) - auxiliary.generate_basic_embed = AsyncMock() - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await discord_env.correct.correct_command(discord_env.context, "a", "b") - - # Step 3 - Assert that everything works - discord_env.correct.prepare_message.assert_called_once_with( - discord_env.message_person2_noprefix_1.content, "a", "b" - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_calls_generate_embed(self: Self) -> None: - """This ensures that the generate_basic_embed function is called, with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.context.message = discord_env.message_person2_noprefix_1 - discord_env.correct.prepare_message = AsyncMock() - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person2_noprefix_1 - ) - auxiliary.generate_basic_embed = AsyncMock() - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await discord_env.correct.correct_command(discord_env.context, "a", "b") - - # Step 3 - Assert that everything works - auxiliary.generate_basic_embed.assert_called_once() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_calls_send(self: Self) -> None: - """This ensures that the ctx.send function is called, with the correct args""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - discord_env.context.message = discord_env.message_person2_noprefix_1 - discord_env.correct.prepare_message = AsyncMock() - auxiliary.search_channel_for_message = AsyncMock( - return_value=discord_env.message_person2_noprefix_1 - ) - auxiliary.generate_basic_embed = AsyncMock() - discord_env.context.send = AsyncMock() - - # Step 2 - Call the function - await discord_env.correct.correct_command(discord_env.context, "a", "b") - - # Step 3 - Assert that everything works - discord_env.context.send.assert_called_once() - - # Step 4 - Cleanup - importlib.reload(auxiliary) - - @pytest.mark.asyncio - async def test_handle_no_message_found(self: Self) -> None: - """This test ensures that a deny embed is sent if no message could be found""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - auxiliary.search_channel_for_message = AsyncMock(return_value=None) - auxiliary.send_deny_embed = AsyncMock() - - # Step 2 - Call the function - await discord_env.correct.correct_command(discord_env.context, "a", "b") - - # Step 3 - Assert that everything works - auxiliary.send_deny_embed.assert_called_once_with( - message="I couldn't find any message to correct", - channel=discord_env.channel, - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) diff --git a/techsupport_bot/tests/commands_tests/test_extensions_hello.py b/techsupport_bot/tests/commands_tests/test_extensions_hello.py deleted file mode 100644 index 2f0afc3ed..000000000 --- a/techsupport_bot/tests/commands_tests/test_extensions_hello.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -This is a file to test the extensions/hello.py file -This contains 1 test -""" - -from __future__ import annotations - -import importlib -from typing import Self -from unittest.mock import AsyncMock - -import pytest -from core import auxiliary -from tests import config_for_tests - - -class Test_Hello: - """A single test to test the hello command""" - - @pytest.mark.asyncio - async def test_hello_command(self: Self) -> None: - """This is a test to ensure that the proper reactions are called, - and in the proper order""" - # Step 1 - Setup env - discord_env = config_for_tests.FakeDiscordEnv() - auxiliary.add_list_of_reactions = AsyncMock() - discord_env.context.message = discord_env.message_person1_noprefix_1 - - # Step 2 - Call the function - await discord_env.hello.hello_command(discord_env.context) - - # Step 3 - Assert that everything works - auxiliary.add_list_of_reactions.assert_called_once_with( - message=discord_env.message_person1_noprefix_1, reactions=["🇭", "🇪", "🇾"] - ) - - # Step 4 - Cleanup - importlib.reload(auxiliary) diff --git a/techsupport_bot/ui/pagination.py b/techsupport_bot/ui/pagination.py index 874290f7e..7ad384db7 100644 --- a/techsupport_bot/ui/pagination.py +++ b/techsupport_bot/ui/pagination.py @@ -38,6 +38,7 @@ async def send( author: discord.Member, data: list[str | discord.Embed], interaction: discord.Interaction | None = None, + ephemeral: bool = False, ) -> None: """Entry point for PaginateView @@ -48,6 +49,7 @@ async def send( with [0] being the first page interaction (discord.Interaction | None): The interaction this should followup with (Optional) + ephemeral (bool): Whether the response should be ephemeral (optional) """ self.author = author self.data = data @@ -57,7 +59,7 @@ async def send( if interaction: self.followup = interaction.followup - self.message = await self.followup.send(view=self) + self.message = await self.followup.send(view=self, ephemeral=ephemeral) else: self.message = await channel.send(view=self)