├── .github └── workflows │ ├── formatting_testing.yml │ ├── publish_production.yml │ ├── publish_testing.yml │ └── testing.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── Pipfile ├── Pipfile.lock ├── README.md ├── alembic.ini ├── app.py ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.testing.yml ├── entrypoint.sh ├── env.template ├── migrations ├── __init__.py ├── env.py ├── script.py.mako └── versions │ ├── 0336b796d052_create_tables_for_timeouts_and_whois_.py │ ├── 0336b796d052_filter_only_new_users.py │ ├── 2345e3de1ccc_add_on_kick_message.py │ ├── 296da7f6d724_remove_default_values.py │ ├── 65330214bff4_add_regex_filter.py │ ├── 85798d8901da_add_on_introduce_message_update_column.py │ ├── ae0105c2205b_create_new_messages.py │ ├── d3a10581c664_create_chat_table.py │ └── fd40280af78a_create_user_table.py ├── src ├── __init__.py ├── constants.py ├── custom_filters.py ├── handlers │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── menu_handler.py │ │ ├── start_handler.py │ │ └── utils.py │ ├── debug │ │ ├── __init__.py │ │ └── list_jobs_handler.py │ ├── error_handler.py │ ├── group │ │ ├── __init__.py │ │ ├── group_handler.py │ │ └── my_chat_member_handler.py │ ├── help_handler.py │ └── utils.py ├── logging.py ├── model.py └── texts.py └── test ├── conftest.py ├── group_handler_test.py ├── group_onboarding_test.py ├── menu_handler_test.py └── start_handler_test.py /.github/workflows/formatting_testing.yml: -------------------------------------------------------------------------------- 1 | name: Formatting check on pull requests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | pre_commit: 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: testing 13 | 14 | steps: 15 | - name: checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - name: set up Python 19 | uses: actions/setup-python@v2 20 | 21 | - name: install pre-commit 22 | run: python -m pip install pre-commit 23 | shell: bash 24 | 25 | - name: pip freeze 26 | run: python -m pip freeze --local 27 | shell: bash 28 | 29 | - name: Get changed files 30 | id: changed-files 31 | uses: tj-actions/changed-files@v44 32 | 33 | - name: run pre-commit 34 | run: | 35 | FILES="${{ steps.changed-files.outputs.all_changed_files }}" 36 | if [ -n "$FILES" ]; then 37 | echo "$FILES" | xargs pre-commit run --files 38 | else 39 | echo "No files to check." 40 | fi 41 | shell: bash 42 | -------------------------------------------------------------------------------- /.github/workflows/publish_production.yml: -------------------------------------------------------------------------------- 1 | name: Docker prod 2 | 3 | on: 4 | push: 5 | # Publish `master` as Docker `prod` image. 6 | branches: 7 | - master 8 | 9 | workflow_dispatch: 10 | 11 | env: 12 | IMAGE_NAME: wachterbot 13 | GITHUB_USERNAME: alexeyqu 14 | PACKAGE_LABEL: prod 15 | 16 | jobs: 17 | # Push image to GitHub Packages. 18 | # See also https://docs.docker.com/docker-hub/builds/ 19 | push: 20 | runs-on: ubuntu-latest 21 | environment: production 22 | permissions: write-all 23 | 24 | steps: 25 | - name: Cancel Previous Runs 26 | uses: styfle/cancel-workflow-action@0.9.1 27 | with: 28 | access_token: ${{ github.token }} 29 | 30 | - uses: actions/checkout@v2 31 | with: 32 | ref: master 33 | 34 | - name: Publish to Github Packages Registry with cache 35 | uses: whoan/docker-build-with-cache-action@v5 36 | env: 37 | IMAGE_NAME: ${{ env.IMAGE_NAME }} 38 | with: 39 | image_name: ${{ github.repository }}/${{ env.IMAGE_NAME }} 40 | registry: ghcr.io 41 | username: ${{ env.GITHUB_USERNAME }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | dockerfile: Dockerfile 44 | image_tag: ${{ env.PACKAGE_LABEL }} 45 | 46 | - name: copy the docker-compose file 47 | uses: appleboy/scp-action@master 48 | env: 49 | ROOT_DIR: /home/${{ secrets.DO_USER }}/${{ env.PACKAGE_LABEL }} 50 | with: 51 | host: ${{ secrets.DO_HOST }} 52 | username: ${{ secrets.DO_USER }} 53 | passphrase: ${{ secrets.DO_SSH_KEY_PASSWORD }} 54 | key: ${{ secrets.DO_SSH_KEY }} 55 | port: ${{ secrets.DO_PORT }} 56 | source: "docker-compose.${{ env.PACKAGE_LABEL }}.yml" 57 | target: "${{ env.ROOT_DIR }}" 58 | 59 | - name: Deploy package to digitalocean 60 | uses: appleboy/ssh-action@master 61 | env: 62 | GITHUB_USERNAME: ${{ env.GITHUB_USERNAME }} 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | IMAGE_NAME: ${{ env.IMAGE_NAME }} 65 | ROOT_DIR: /home/${{ secrets.DO_USER }}/${{ env.PACKAGE_LABEL }} 66 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 67 | DATABASE_USER: ${{ secrets.DATABASE_USER }} 68 | DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} 69 | PERSISTENCE_DATABASE_URL: ${{ secrets.PERSISTENCE_DATABASE_URL }} 70 | PERSISTENCE_DATABASE_USER: ${{ secrets.PERSISTENCE_DATABASE_USER }} 71 | PERSISTENCE_DATABASE_PASSWORD: ${{ secrets.PERSISTENCE_DATABASE_PASSWORD }} 72 | TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} 73 | UPTRACE_DSN: ${{ secrets.UPTRACE_DSN }} 74 | TELEGRAM_ERROR_CHAT_ID: ${{ secrets.TELEGRAM_ERROR_CHAT_ID }} 75 | TEAM_TELEGRAM_IDS: ${{ secrets.TEAM_TELEGRAM_IDS }} 76 | with: 77 | host: ${{ secrets.DO_HOST }} 78 | username: ${{ secrets.DO_USER }} 79 | passphrase: ${{ secrets.DO_SSH_KEY_PASSWORD }} 80 | key: ${{ secrets.DO_SSH_KEY }} 81 | port: ${{ secrets.DO_PORT }} 82 | envs: GITHUB_USERNAME, GITHUB_TOKEN, ROOT_DIR, DATABASE_URL, PERSISTENCE_DATABASE_URL, TELEGRAM_TOKEN, TEAM_TELEGRAM_IDS, UPTRACE_DSN, DATABASE_USER, DATABASE_PASSWORD, PERSISTENCE_DATABASE_USER, PERSISTENCE_DATABASE_PASSWORD 83 | script: | 84 | echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USERNAME" --password-stdin || exit 1 85 | cd ${{ env.ROOT_DIR }} 86 | touch .env 87 | echo DATABASE_URL=${{ env.DATABASE_URL }} >> .env 88 | echo DATABASE_USER=${{ env.DATABASE_USER }} >> .env 89 | echo DATABASE_PASSWORD=${{ env.DATABASE_PASSWORD }} >> .env 90 | echo PERSISTENCE_DATABASE_URL=${{ env.PERSISTENCE_DATABASE_URL }} >> .env 91 | echo PERSISTENCE_DATABASE_USER=${{ env.PERSISTENCE_DATABASE_USER }} >> .env 92 | echo PERSISTENCE_DATABASE_PASSWORD=${{ env.PERSISTENCE_DATABASE_PASSWORD }} >> .env 93 | echo TELEGRAM_TOKEN=${{ env.TELEGRAM_TOKEN }} >> .env 94 | echo UPTRACE_DSN=${{ env.UPTRACE_DSN }} >> .env 95 | echo TELEGRAM_ERROR_CHAT_ID=${{ env.TELEGRAM_ERROR_CHAT_ID }} >> .env 96 | echo DEBUG=False >> .env 97 | echo TEAM_TELEGRAM_IDS=${{ env.TEAM_TELEGRAM_IDS }} >> .env 98 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml stop 99 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml pull 100 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml up --force-recreate -d 101 | docker image prune -a -f 102 | -------------------------------------------------------------------------------- /.github/workflows/publish_testing.yml: -------------------------------------------------------------------------------- 1 | name: Docker testing 2 | 3 | on: 4 | push: 5 | # Publish `dev` as Docker `testing` image. 6 | branches: 7 | - dev 8 | 9 | workflow_dispatch: 10 | 11 | env: 12 | IMAGE_NAME: wachterbot 13 | GITHUB_USERNAME: alexeyqu 14 | PACKAGE_LABEL: testing 15 | 16 | jobs: 17 | # Push image to GitHub Packages. 18 | # See also https://docs.docker.com/docker-hub/builds/ 19 | push: 20 | runs-on: ubuntu-latest 21 | environment: testing 22 | permissions: write-all 23 | 24 | steps: 25 | - name: Cancel Previous Runs 26 | uses: styfle/cancel-workflow-action@0.9.1 27 | with: 28 | access_token: ${{ github.token }} 29 | 30 | - uses: actions/checkout@v2 31 | with: 32 | ref: dev 33 | 34 | - name: Publish to Github Packages Registry with cache 35 | uses: whoan/docker-build-with-cache-action@v5 36 | env: 37 | IMAGE_NAME: ${{ env.IMAGE_NAME }} 38 | with: 39 | image_name: ${{ github.repository }}/${{ env.IMAGE_NAME }} 40 | registry: ghcr.io 41 | username: ${{ env.GITHUB_USERNAME }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | dockerfile: Dockerfile 44 | image_tag: ${{ env.PACKAGE_LABEL }} 45 | 46 | - name: copy the docker-compose file 47 | uses: appleboy/scp-action@master 48 | env: 49 | ROOT_DIR: /home/${{ secrets.DO_USER }}/${{ env.PACKAGE_LABEL }} 50 | with: 51 | host: ${{ secrets.DO_HOST }} 52 | username: ${{ secrets.DO_USER }} 53 | passphrase: ${{ secrets.DO_SSH_KEY_PASSWORD }} 54 | key: ${{ secrets.DO_SSH_KEY }} 55 | port: ${{ secrets.DO_PORT }} 56 | source: "docker-compose.${{ env.PACKAGE_LABEL }}.yml" 57 | target: "${{ env.ROOT_DIR }}" 58 | 59 | - name: Deploy package to digitalocean 60 | uses: appleboy/ssh-action@master 61 | env: 62 | GITHUB_USERNAME: ${{ env.GITHUB_USERNAME }} 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | IMAGE_NAME: ${{ env.IMAGE_NAME }} 65 | ROOT_DIR: /home/${{ secrets.DO_USER }}/${{ env.PACKAGE_LABEL }} 66 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 67 | DATABASE_USER: ${{ secrets.DATABASE_USER }} 68 | DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} 69 | PERSISTENCE_DATABASE_URL: ${{ secrets.PERSISTENCE_DATABASE_URL }} 70 | PERSISTENCE_DATABASE_USER: ${{ secrets.PERSISTENCE_DATABASE_USER }} 71 | PERSISTENCE_DATABASE_PASSWORD: ${{ secrets.PERSISTENCE_DATABASE_PASSWORD }} 72 | TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} 73 | TELEGRAM_ERROR_CHAT_ID: ${{ secrets.TELEGRAM_ERROR_CHAT_ID }} 74 | TEAM_TELEGRAM_IDS: ${{ secrets.TEAM_TELEGRAM_IDS }} 75 | UPTRACE_DSN: ${{ secrets.UPTRACE_DSN }} 76 | with: 77 | host: ${{ secrets.DO_HOST }} 78 | username: ${{ secrets.DO_USER }} 79 | passphrase: ${{ secrets.DO_SSH_KEY_PASSWORD }} 80 | key: ${{ secrets.DO_SSH_KEY }} 81 | port: ${{ secrets.DO_PORT }} 82 | envs: GITHUB_USERNAME, GITHUB_TOKEN, ROOT_DIR, DATABASE_URL, PERSISTENCE_DATABASE_URL, TELEGRAM_TOKEN, TEAM_TELEGRAM_IDS, UPTRACE_DSN, DATABASE_USER, DATABASE_PASSWORD, PERSISTENCE_DATABASE_USER, PERSISTENCE_DATABASE_PASSWORD 83 | script: | 84 | echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USERNAME" --password-stdin || exit 1 85 | cd ${{ env.ROOT_DIR }} 86 | touch .env 87 | echo DATABASE_URL=${{ env.DATABASE_URL }} >> .env 88 | echo DATABASE_USER=${{ env.DATABASE_USER }} >> .env 89 | echo DATABASE_PASSWORD=${{ env.DATABASE_PASSWORD }} >> .env 90 | echo PERSISTENCE_DATABASE_URL=${{ env.PERSISTENCE_DATABASE_URL }} >> .env 91 | echo PERSISTENCE_DATABASE_USER=${{ env.PERSISTENCE_DATABASE_USER }} >> .env 92 | echo PERSISTENCE_DATABASE_PASSWORD=${{ env.PERSISTENCE_DATABASE_PASSWORD }} >> .env 93 | echo TELEGRAM_TOKEN=${{ env.TELEGRAM_TOKEN }} >> .env 94 | echo TELEGRAM_ERROR_CHAT_ID=${{ env.TELEGRAM_ERROR_CHAT_ID }} >> .env 95 | echo UPTRACE_DSN=${{ env.UPTRACE_DSN }} >> .env 96 | echo DEBUG=False >> .env 97 | echo TEAM_TELEGRAM_IDS=${{ env.TEAM_TELEGRAM_IDS }} >> .env 98 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml stop 99 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml pull 100 | docker-compose -f docker-compose.${{ env.PACKAGE_LABEL }}.yml up --force-recreate -d 101 | docker image prune -a -f 102 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.9 # required by Pipfile 20 | 21 | - name: Cache pipenv dependencies 22 | uses: actions/cache@v2 23 | with: 24 | path: ~/.local/share/virtualenvs 25 | key: ${{ runner.os }}-pipenv-${{ hashFiles('Pipfile.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-pipenv- 28 | 29 | - name: Cache pip 30 | uses: actions/cache@v2 31 | with: 32 | path: ~/.cache/pip 33 | key: ${{ runner.os }}-pip-${{ hashFiles('Pipfile.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | 37 | - name: Install pipenv 38 | run: pip install pipenv 39 | 40 | - name: Install dependencies 41 | run: pipenv install 42 | 43 | - name: Install pytest explicitly (failes without) 44 | run: pipenv run pip install pytest 45 | 46 | - name: Install asyncio (we doing it in Dockerfile) 47 | run: pipenv run pip install 'httpcore[asyncio]' 48 | 49 | - name: Run tests 50 | run: pipenv run python -m pytest ./test -x -s 51 | shell: bash 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | [v]env/ 4 | .env 5 | .DS_Store 6 | .vscode 7 | data/ 8 | persistent_storage.pickle 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 23.9.1 6 | hooks: 7 | - id: black 8 | exclude: ^(.+)/migrations/.*\.py 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | RUN pip install "setuptools<46" && pip install pipenv 4 | 5 | COPY Pipfile /Pipfile 6 | COPY Pipfile.lock /Pipfile.lock 7 | 8 | RUN pip install "httpcore[asyncio]" 9 | 10 | RUN pipenv install --deploy --system 11 | 12 | COPY . /app 13 | WORKDIR /app 14 | 15 | RUN ["chmod", "+x", "/app/entrypoint.sh"] 16 | ENTRYPOINT ["/app/entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | sqlalchemy = "*" 8 | alembic = "*" 9 | pre-commit = "*" 10 | pylint = "*" 11 | black = "*" 12 | python-telegram-handler = "*" 13 | pytest = "*" 14 | pytest-mock = "*" 15 | httpcore = {extras = ["asyncio"], version = "*"} 16 | asyncio = "*" 17 | asyncpg = "*" 18 | python-telegram-bot = {extras = ["job-queue"], version = "*"} 19 | ptbcontrib = {extras = ["ptb_jobstores_sqlalchemy"], git = "git+https://github.com/python-telegram-bot/ptbcontrib.git@main"} 20 | "psycopg2-binary" = "*" # need for ptb_jobstores_sqlalchemy 21 | pytest-asyncio = "*" 22 | aiosqlite = "*" 23 | grpcio = "*" 24 | opentelemetry-sdk = "*" 25 | opentelemetry-exporter-otlp = "*" 26 | opentelemetry-api = "*" 27 | 28 | [dev-packages] 29 | "autopep8" = "*" 30 | 31 | [requires] 32 | python_version = "3.9" 33 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e903d17cd9d86285f15e7184fec65b13946f3869872b68f900a5f8df98e9a1f9" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiosqlite": { 20 | "hashes": [ 21 | "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", 22 | "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7" 23 | ], 24 | "index": "pypi", 25 | "markers": "python_version >= '3.8'", 26 | "version": "==0.20.0" 27 | }, 28 | "alembic": { 29 | "hashes": [ 30 | "sha256:203503117415561e203aa14541740643a611f641517f0209fcae63e9fa09f1a2", 31 | "sha256:908e905976d15235fae59c9ac42c4c5b75cfcefe3d27c0fbf7ae15a37715d80e" 32 | ], 33 | "index": "pypi", 34 | "markers": "python_version >= '3.8'", 35 | "version": "==1.13.3" 36 | }, 37 | "anyio": { 38 | "hashes": [ 39 | "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", 40 | "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d" 41 | ], 42 | "version": "==4.6.2.post1" 43 | }, 44 | "apscheduler": { 45 | "hashes": [ 46 | "sha256:e6df071b27d9be898e486bc7940a7be50b4af2e9da7c08f0744a96d4bd4cef4a", 47 | "sha256:fb91e8a768632a4756a585f79ec834e0e27aad5860bac7eaa523d9ccefd87661" 48 | ], 49 | "version": "==3.10.4" 50 | }, 51 | "astroid": { 52 | "hashes": [ 53 | "sha256:5cfc40ae9f68311075d27ef68a4841bdc5cc7f6cf86671b49f00607d30188e2d", 54 | "sha256:a9d1c946ada25098d790e079ba2a1b112157278f3fb7e718ae6a9252f5835dc8" 55 | ], 56 | "markers": "python_full_version >= '3.9.0'", 57 | "version": "==3.3.5" 58 | }, 59 | "async-timeout": { 60 | "hashes": [ 61 | "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", 62 | "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" 63 | ], 64 | "markers": "python_full_version < '3.12.0'", 65 | "version": "==4.0.3" 66 | }, 67 | "asyncio": { 68 | "hashes": [ 69 | "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", 70 | "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", 71 | "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", 72 | "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" 73 | ], 74 | "index": "pypi", 75 | "version": "==3.4.3" 76 | }, 77 | "asyncpg": { 78 | "hashes": [ 79 | "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9", 80 | "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7", 81 | "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548", 82 | "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23", 83 | "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3", 84 | "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675", 85 | "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe", 86 | "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175", 87 | "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83", 88 | "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385", 89 | "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da", 90 | "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106", 91 | "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870", 92 | "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449", 93 | "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc", 94 | "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178", 95 | "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9", 96 | "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b", 97 | "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169", 98 | "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610", 99 | "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772", 100 | "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2", 101 | "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c", 102 | "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb", 103 | "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac", 104 | "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408", 105 | "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22", 106 | "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb", 107 | "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02", 108 | "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59", 109 | "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8", 110 | "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3", 111 | "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e", 112 | "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4", 113 | "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364", 114 | "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f", 115 | "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775", 116 | "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3", 117 | "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090", 118 | "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810", 119 | "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397" 120 | ], 121 | "index": "pypi", 122 | "markers": "python_full_version >= '3.8.0'", 123 | "version": "==0.29.0" 124 | }, 125 | "black": { 126 | "hashes": [ 127 | "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", 128 | "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", 129 | "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", 130 | "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", 131 | "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", 132 | "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", 133 | "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", 134 | "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", 135 | "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", 136 | "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", 137 | "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", 138 | "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", 139 | "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", 140 | "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", 141 | "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", 142 | "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", 143 | "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", 144 | "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", 145 | "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", 146 | "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", 147 | "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", 148 | "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" 149 | ], 150 | "index": "pypi", 151 | "markers": "python_version >= '3.9'", 152 | "version": "==24.10.0" 153 | }, 154 | "certifi": { 155 | "hashes": [ 156 | "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", 157 | "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" 158 | ], 159 | "markers": "python_version >= '3.6'", 160 | "version": "==2024.8.30" 161 | }, 162 | "cfgv": { 163 | "hashes": [ 164 | "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", 165 | "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560" 166 | ], 167 | "markers": "python_version >= '3.8'", 168 | "version": "==3.4.0" 169 | }, 170 | "charset-normalizer": { 171 | "hashes": [ 172 | "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", 173 | "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", 174 | "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", 175 | "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", 176 | "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", 177 | "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", 178 | "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", 179 | "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", 180 | "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", 181 | "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", 182 | "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", 183 | "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", 184 | "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", 185 | "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", 186 | "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", 187 | "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", 188 | "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", 189 | "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", 190 | "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", 191 | "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", 192 | "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", 193 | "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", 194 | "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", 195 | "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", 196 | "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", 197 | "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", 198 | "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", 199 | "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", 200 | "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", 201 | "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", 202 | "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", 203 | "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", 204 | "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", 205 | "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", 206 | "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", 207 | "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", 208 | "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", 209 | "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", 210 | "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", 211 | "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", 212 | "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", 213 | "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", 214 | "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", 215 | "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", 216 | "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", 217 | "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", 218 | "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", 219 | "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", 220 | "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", 221 | "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", 222 | "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", 223 | "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", 224 | "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", 225 | "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", 226 | "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", 227 | "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", 228 | "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", 229 | "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", 230 | "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", 231 | "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", 232 | "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", 233 | "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", 234 | "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", 235 | "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", 236 | "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", 237 | "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", 238 | "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", 239 | "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", 240 | "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", 241 | "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", 242 | "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", 243 | "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", 244 | "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", 245 | "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", 246 | "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", 247 | "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", 248 | "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", 249 | "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", 250 | "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", 251 | "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", 252 | "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", 253 | "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", 254 | "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", 255 | "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", 256 | "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", 257 | "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", 258 | "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", 259 | "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", 260 | "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", 261 | "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", 262 | "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", 263 | "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", 264 | "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", 265 | "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", 266 | "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", 267 | "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", 268 | "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", 269 | "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", 270 | "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", 271 | "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", 272 | "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", 273 | "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", 274 | "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", 275 | "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", 276 | "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" 277 | ], 278 | "markers": "python_full_version >= '3.7.0'", 279 | "version": "==3.4.0" 280 | }, 281 | "click": { 282 | "hashes": [ 283 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 284 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 285 | ], 286 | "markers": "python_version >= '3.7'", 287 | "version": "==8.1.7" 288 | }, 289 | "deprecated": { 290 | "hashes": [ 291 | "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", 292 | "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3" 293 | ], 294 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 295 | "version": "==1.2.14" 296 | }, 297 | "dill": { 298 | "hashes": [ 299 | "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", 300 | "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c" 301 | ], 302 | "markers": "python_version < '3.11'", 303 | "version": "==0.3.9" 304 | }, 305 | "distlib": { 306 | "hashes": [ 307 | "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", 308 | "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403" 309 | ], 310 | "version": "==0.3.9" 311 | }, 312 | "exceptiongroup": { 313 | "hashes": [ 314 | "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", 315 | "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" 316 | ], 317 | "markers": "python_version < '3.11'", 318 | "version": "==1.2.2" 319 | }, 320 | "filelock": { 321 | "hashes": [ 322 | "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", 323 | "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435" 324 | ], 325 | "markers": "python_version >= '3.8'", 326 | "version": "==3.16.1" 327 | }, 328 | "googleapis-common-protos": { 329 | "hashes": [ 330 | "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63", 331 | "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0" 332 | ], 333 | "markers": "python_version >= '3.7'", 334 | "version": "==1.65.0" 335 | }, 336 | "greenlet": { 337 | "hashes": [ 338 | "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", 339 | "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7", 340 | "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", 341 | "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", 342 | "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", 343 | "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", 344 | "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", 345 | "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", 346 | "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", 347 | "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa", 348 | "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", 349 | "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", 350 | "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", 351 | "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", 352 | "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9", 353 | "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", 354 | "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba", 355 | "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3", 356 | "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", 357 | "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", 358 | "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291", 359 | "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", 360 | "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", 361 | "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", 362 | "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", 363 | "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef", 364 | "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c", 365 | "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", 366 | "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c", 367 | "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", 368 | "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", 369 | "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8", 370 | "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d", 371 | "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", 372 | "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145", 373 | "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", 374 | "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", 375 | "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e", 376 | "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", 377 | "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1", 378 | "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef", 379 | "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", 380 | "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", 381 | "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", 382 | "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437", 383 | "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd", 384 | "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981", 385 | "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", 386 | "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", 387 | "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798", 388 | "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", 389 | "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", 390 | "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", 391 | "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", 392 | "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af", 393 | "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", 394 | "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", 395 | "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42", 396 | "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e", 397 | "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81", 398 | "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", 399 | "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", 400 | "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc", 401 | "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de", 402 | "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111", 403 | "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", 404 | "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", 405 | "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", 406 | "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", 407 | "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", 408 | "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803", 409 | "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", 410 | "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f" 411 | ], 412 | "markers": "python_version < '3.13' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", 413 | "version": "==3.1.1" 414 | }, 415 | "grpcio": { 416 | "hashes": [ 417 | "sha256:014dfc020e28a0d9be7e93a91f85ff9f4a87158b7df9952fe23cc42d29d31e1e", 418 | "sha256:0892dd200ece4822d72dd0952f7112c542a487fc48fe77568deaaa399c1e717d", 419 | "sha256:0bb94e66cd8f0baf29bd3184b6aa09aeb1a660f9ec3d85da615c5003154bc2bf", 420 | "sha256:0c69bf11894cad9da00047f46584d5758d6ebc9b5950c0dc96fec7e0bce5cde9", 421 | "sha256:15c05a26a0f7047f720da41dc49406b395c1470eef44ff7e2c506a47ac2c0591", 422 | "sha256:16724ffc956ea42967f5758c2f043faef43cb7e48a51948ab593570570d1e68b", 423 | "sha256:227316b5631260e0bef8a3ce04fa7db4cc81756fea1258b007950b6efc90c05d", 424 | "sha256:2b7183c80b602b0ad816315d66f2fb7887614ead950416d60913a9a71c12560d", 425 | "sha256:2f55c1e0e2ae9bdd23b3c63459ee4c06d223b68aeb1961d83c48fb63dc29bc03", 426 | "sha256:30d47dbacfd20cbd0c8be9bfa52fdb833b395d4ec32fe5cff7220afc05d08571", 427 | "sha256:323741b6699cd2b04a71cb38f502db98f90532e8a40cb675393d248126a268af", 428 | "sha256:3840994689cc8cbb73d60485c594424ad8adb56c71a30d8948d6453083624b52", 429 | "sha256:391df8b0faac84d42f5b8dfc65f5152c48ed914e13c522fd05f2aca211f8bfad", 430 | "sha256:42199e704095b62688998c2d84c89e59a26a7d5d32eed86d43dc90e7a3bd04aa", 431 | "sha256:54d16383044e681f8beb50f905249e4e7261dd169d4aaf6e52eab67b01cbbbe2", 432 | "sha256:5a1e03c3102b6451028d5dc9f8591131d6ab3c8a0e023d94c28cb930ed4b5f81", 433 | "sha256:62492bd534979e6d7127b8a6b29093161a742dee3875873e01964049d5250a74", 434 | "sha256:662c8e105c5e5cee0317d500eb186ed7a93229586e431c1bf0c9236c2407352c", 435 | "sha256:682968427a63d898759474e3b3178d42546e878fdce034fd7474ef75143b64e3", 436 | "sha256:74b900566bdf68241118f2918d312d3bf554b2ce0b12b90178091ea7d0a17b3d", 437 | "sha256:77196216d5dd6f99af1c51e235af2dd339159f657280e65ce7e12c1a8feffd1d", 438 | "sha256:7f200aca719c1c5dc72ab68be3479b9dafccdf03df530d137632c534bb6f1ee3", 439 | "sha256:7fc1d2b9fd549264ae585026b266ac2db53735510a207381be509c315b4af4e8", 440 | "sha256:82e5bd4b67b17c8c597273663794a6a46a45e44165b960517fe6d8a2f7f16d23", 441 | "sha256:8c9a35b8bc50db35ab8e3e02a4f2a35cfba46c8705c3911c34ce343bd777813a", 442 | "sha256:985b2686f786f3e20326c4367eebdaed3e7aa65848260ff0c6644f817042cb15", 443 | "sha256:9d75641a2fca9ae1ae86454fd25d4c298ea8cc195dbc962852234d54a07060ad", 444 | "sha256:a4e95e43447a02aa603abcc6b5e727d093d161a869c83b073f50b9390ecf0fa8", 445 | "sha256:a6b9a5c18863fd4b6624a42e2712103fb0f57799a3b29651c0e5b8119a519d65", 446 | "sha256:aa8d025fae1595a207b4e47c2e087cb88d47008494db258ac561c00877d4c8f8", 447 | "sha256:ac11ecb34a86b831239cc38245403a8de25037b448464f95c3315819e7519772", 448 | "sha256:ae6de510f670137e755eb2a74b04d1041e7210af2444103c8c95f193340d17ee", 449 | "sha256:b2a44e572fb762c668e4812156b81835f7aba8a721b027e2d4bb29fb50ff4d33", 450 | "sha256:b6eb68493a05d38b426604e1dc93bfc0137c4157f7ab4fac5771fd9a104bbaa6", 451 | "sha256:b9bca3ca0c5e74dea44bf57d27e15a3a3996ce7e5780d61b7c72386356d231db", 452 | "sha256:bd79929b3bb96b54df1296cd3bf4d2b770bd1df6c2bdf549b49bab286b925cdc", 453 | "sha256:c4c425f440fb81f8d0237c07b9322fc0fb6ee2b29fbef5f62a322ff8fcce240d", 454 | "sha256:cb204a742997277da678611a809a8409657b1398aaeebf73b3d9563b7d154c13", 455 | "sha256:cf51d28063338608cd8d3cd64677e922134837902b70ce00dad7f116e3998210", 456 | "sha256:cfd9306511fdfc623a1ba1dc3bc07fbd24e6cfbe3c28b4d1e05177baa2f99617", 457 | "sha256:cff8e54d6a463883cda2fab94d2062aad2f5edd7f06ae3ed030f2a74756db365", 458 | "sha256:d01793653248f49cf47e5695e0a79805b1d9d4eacef85b310118ba1dfcd1b955", 459 | "sha256:d4ea4509d42c6797539e9ec7496c15473177ce9abc89bc5c71e7abe50fc25737", 460 | "sha256:d90cfdafcf4b45a7a076e3e2a58e7bc3d59c698c4f6470b0bb13a4d869cf2273", 461 | "sha256:e090b2553e0da1c875449c8e75073dd4415dd71c9bde6a406240fdf4c0ee467c", 462 | "sha256:e91d154689639932305b6ea6f45c6e46bb51ecc8ea77c10ef25aa77f75443ad4", 463 | "sha256:eef1dce9d1a46119fd09f9a992cf6ab9d9178b696382439446ca5f399d7b96fe", 464 | "sha256:efe32b45dd6d118f5ea2e5deaed417d8a14976325c93812dd831908522b402c9", 465 | "sha256:f4d613fbf868b2e2444f490d18af472ccb47660ea3df52f068c9c8801e1f3e85", 466 | "sha256:f55f077685f61f0fbd06ea355142b71e47e4a26d2d678b3ba27248abfe67163a", 467 | "sha256:f623c57a5321461c84498a99dddf9d13dac0e40ee056d884d6ec4ebcab647a78", 468 | "sha256:f6bd2ab135c64a4d1e9e44679a616c9bc944547357c830fafea5c3caa3de5153", 469 | "sha256:f95e15db43e75a534420e04822df91f645664bf4ad21dfaad7d51773c80e6bb4", 470 | "sha256:fd6bc27861e460fe28e94226e3673d46e294ca4673d46b224428d197c5935e69", 471 | "sha256:fe89295219b9c9e47780a0f1c75ca44211e706d1c598242249fe717af3385ec8" 472 | ], 473 | "index": "pypi", 474 | "markers": "python_version >= '3.8'", 475 | "version": "==1.67.0" 476 | }, 477 | "h11": { 478 | "hashes": [ 479 | "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", 480 | "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" 481 | ], 482 | "markers": "python_version >= '3.7'", 483 | "version": "==0.14.0" 484 | }, 485 | "httpcore": { 486 | "extras": [ 487 | "asyncio" 488 | ], 489 | "hashes": [ 490 | "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", 491 | "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f" 492 | ], 493 | "markers": "python_version >= '3.8'", 494 | "version": "==1.0.6" 495 | }, 496 | "httpx": { 497 | "hashes": [ 498 | "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", 499 | "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2" 500 | ], 501 | "markers": "python_version >= '3.8'", 502 | "version": "==0.27.2" 503 | }, 504 | "identify": { 505 | "hashes": [ 506 | "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", 507 | "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98" 508 | ], 509 | "markers": "python_version >= '3.8'", 510 | "version": "==2.6.1" 511 | }, 512 | "idna": { 513 | "hashes": [ 514 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 515 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 516 | ], 517 | "markers": "python_version >= '3.6'", 518 | "version": "==3.10" 519 | }, 520 | "importlib-metadata": { 521 | "hashes": [ 522 | "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", 523 | "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5" 524 | ], 525 | "markers": "python_version >= '3.8'", 526 | "version": "==8.4.0" 527 | }, 528 | "iniconfig": { 529 | "hashes": [ 530 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 531 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 532 | ], 533 | "markers": "python_version >= '3.7'", 534 | "version": "==2.0.0" 535 | }, 536 | "isort": { 537 | "hashes": [ 538 | "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", 539 | "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" 540 | ], 541 | "markers": "python_full_version >= '3.8.0'", 542 | "version": "==5.13.2" 543 | }, 544 | "mako": { 545 | "hashes": [ 546 | "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a", 547 | "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc" 548 | ], 549 | "markers": "python_version >= '3.8'", 550 | "version": "==1.3.5" 551 | }, 552 | "markupsafe": { 553 | "hashes": [ 554 | "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396", 555 | "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38", 556 | "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a", 557 | "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8", 558 | "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b", 559 | "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad", 560 | "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a", 561 | "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a", 562 | "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da", 563 | "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6", 564 | "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8", 565 | "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344", 566 | "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a", 567 | "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8", 568 | "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5", 569 | "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7", 570 | "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170", 571 | "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132", 572 | "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9", 573 | "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd", 574 | "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9", 575 | "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346", 576 | "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc", 577 | "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589", 578 | "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5", 579 | "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915", 580 | "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295", 581 | "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453", 582 | "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea", 583 | "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b", 584 | "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d", 585 | "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b", 586 | "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4", 587 | "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b", 588 | "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7", 589 | "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf", 590 | "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f", 591 | "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91", 592 | "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd", 593 | "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50", 594 | "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b", 595 | "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583", 596 | "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a", 597 | "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984", 598 | "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c", 599 | "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c", 600 | "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25", 601 | "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa", 602 | "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4", 603 | "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3", 604 | "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97", 605 | "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1", 606 | "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd", 607 | "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772", 608 | "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a", 609 | "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729", 610 | "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca", 611 | "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6", 612 | "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635", 613 | "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b", 614 | "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f" 615 | ], 616 | "markers": "python_version >= '3.9'", 617 | "version": "==3.0.1" 618 | }, 619 | "mccabe": { 620 | "hashes": [ 621 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 622 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 623 | ], 624 | "markers": "python_version >= '3.6'", 625 | "version": "==0.7.0" 626 | }, 627 | "mypy-extensions": { 628 | "hashes": [ 629 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 630 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 631 | ], 632 | "markers": "python_version >= '3.5'", 633 | "version": "==1.0.0" 634 | }, 635 | "nodeenv": { 636 | "hashes": [ 637 | "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", 638 | "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9" 639 | ], 640 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 641 | "version": "==1.9.1" 642 | }, 643 | "opentelemetry-api": { 644 | "hashes": [ 645 | "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", 646 | "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342" 647 | ], 648 | "index": "pypi", 649 | "markers": "python_version >= '3.8'", 650 | "version": "==1.27.0" 651 | }, 652 | "opentelemetry-exporter-otlp": { 653 | "hashes": [ 654 | "sha256:4a599459e623868cc95d933c301199c2367e530f089750e115599fccd67cb2a1", 655 | "sha256:7688791cbdd951d71eb6445951d1cfbb7b6b2d7ee5948fac805d404802931145" 656 | ], 657 | "index": "pypi", 658 | "markers": "python_version >= '3.8'", 659 | "version": "==1.27.0" 660 | }, 661 | "opentelemetry-exporter-otlp-proto-common": { 662 | "hashes": [ 663 | "sha256:159d27cf49f359e3798c4c3eb8da6ef4020e292571bd8c5604a2a573231dd5c8", 664 | "sha256:675db7fffcb60946f3a5c43e17d1168a3307a94a930ecf8d2ea1f286f3d4f79a" 665 | ], 666 | "markers": "python_version >= '3.8'", 667 | "version": "==1.27.0" 668 | }, 669 | "opentelemetry-exporter-otlp-proto-grpc": { 670 | "hashes": [ 671 | "sha256:56b5bbd5d61aab05e300d9d62a6b3c134827bbd28d0b12f2649c2da368006c9e", 672 | "sha256:af6f72f76bcf425dfb5ad11c1a6d6eca2863b91e63575f89bb7b4b55099d968f" 673 | ], 674 | "markers": "python_version >= '3.8'", 675 | "version": "==1.27.0" 676 | }, 677 | "opentelemetry-exporter-otlp-proto-http": { 678 | "hashes": [ 679 | "sha256:2103479092d8eb18f61f3fbff084f67cc7f2d4a7d37e75304b8b56c1d09ebef5", 680 | "sha256:688027575c9da42e179a69fe17e2d1eba9b14d81de8d13553a21d3114f3b4d75" 681 | ], 682 | "markers": "python_version >= '3.8'", 683 | "version": "==1.27.0" 684 | }, 685 | "opentelemetry-proto": { 686 | "hashes": [ 687 | "sha256:33c9345d91dafd8a74fc3d7576c5a38f18b7fdf8d02983ac67485386132aedd6", 688 | "sha256:b133873de5581a50063e1e4b29cdcf0c5e253a8c2d8dc1229add20a4c3830ace" 689 | ], 690 | "markers": "python_version >= '3.8'", 691 | "version": "==1.27.0" 692 | }, 693 | "opentelemetry-sdk": { 694 | "hashes": [ 695 | "sha256:365f5e32f920faf0fd9e14fdfd92c086e317eaa5f860edba9cdc17a380d9197d", 696 | "sha256:d525017dea0ccce9ba4e0245100ec46ecdc043f2d7b8315d56b19aff0904fa6f" 697 | ], 698 | "index": "pypi", 699 | "markers": "python_version >= '3.8'", 700 | "version": "==1.27.0" 701 | }, 702 | "opentelemetry-semantic-conventions": { 703 | "hashes": [ 704 | "sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a", 705 | "sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f" 706 | ], 707 | "markers": "python_version >= '3.8'", 708 | "version": "==0.48b0" 709 | }, 710 | "packaging": { 711 | "hashes": [ 712 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 713 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 714 | ], 715 | "markers": "python_version >= '3.8'", 716 | "version": "==24.1" 717 | }, 718 | "pathspec": { 719 | "hashes": [ 720 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 721 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 722 | ], 723 | "markers": "python_version >= '3.8'", 724 | "version": "==0.12.1" 725 | }, 726 | "platformdirs": { 727 | "hashes": [ 728 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", 729 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" 730 | ], 731 | "markers": "python_version >= '3.8'", 732 | "version": "==4.3.6" 733 | }, 734 | "pluggy": { 735 | "hashes": [ 736 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 737 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 738 | ], 739 | "markers": "python_version >= '3.8'", 740 | "version": "==1.5.0" 741 | }, 742 | "pre-commit": { 743 | "hashes": [ 744 | "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", 745 | "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878" 746 | ], 747 | "index": "pypi", 748 | "markers": "python_version >= '3.9'", 749 | "version": "==4.0.1" 750 | }, 751 | "protobuf": { 752 | "hashes": [ 753 | "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41", 754 | "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea", 755 | "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8", 756 | "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45", 757 | "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584", 758 | "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d", 759 | "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1", 760 | "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f", 761 | "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a", 762 | "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173", 763 | "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331" 764 | ], 765 | "markers": "python_version >= '3.8'", 766 | "version": "==4.25.5" 767 | }, 768 | "psycopg2-binary": { 769 | "hashes": [ 770 | "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", 771 | "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5", 772 | "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", 773 | "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", 774 | "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", 775 | "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", 776 | "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", 777 | "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", 778 | "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", 779 | "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", 780 | "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", 781 | "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", 782 | "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", 783 | "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", 784 | "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", 785 | "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", 786 | "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", 787 | "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", 788 | "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", 789 | "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", 790 | "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", 791 | "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", 792 | "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", 793 | "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", 794 | "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", 795 | "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", 796 | "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", 797 | "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", 798 | "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44", 799 | "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", 800 | "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", 801 | "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", 802 | "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa", 803 | "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", 804 | "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", 805 | "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", 806 | "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", 807 | "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", 808 | "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", 809 | "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", 810 | "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", 811 | "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", 812 | "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", 813 | "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3", 814 | "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", 815 | "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92", 816 | "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", 817 | "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", 818 | "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8", 819 | "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", 820 | "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", 821 | "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864", 822 | "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", 823 | "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", 824 | "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", 825 | "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", 826 | "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", 827 | "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", 828 | "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", 829 | "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4", 830 | "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", 831 | "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", 832 | "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", 833 | "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", 834 | "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", 835 | "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", 836 | "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863" 837 | ], 838 | "index": "pypi", 839 | "markers": "python_version >= '3.8'", 840 | "version": "==2.9.10" 841 | }, 842 | "ptbcontrib": { 843 | "extras": [ 844 | "ptb_jobstores_sqlalchemy" 845 | ], 846 | "git": "git+https://github.com/python-telegram-bot/ptbcontrib.git@main", 847 | "ref": "df735c17b4e00a65d4a6057f942f9be04e6ed786" 848 | }, 849 | "pylint": { 850 | "hashes": [ 851 | "sha256:2f846a466dd023513240bc140ad2dd73bfc080a5d85a710afdb728c420a5a2b9", 852 | "sha256:9f3dcc87b1203e612b78d91a896407787e708b3f189b5fa0b307712d49ff0c6e" 853 | ], 854 | "index": "pypi", 855 | "markers": "python_full_version >= '3.9.0'", 856 | "version": "==3.3.1" 857 | }, 858 | "pytest": { 859 | "hashes": [ 860 | "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", 861 | "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" 862 | ], 863 | "index": "pypi", 864 | "markers": "python_version >= '3.8'", 865 | "version": "==8.3.3" 866 | }, 867 | "pytest-asyncio": { 868 | "hashes": [ 869 | "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", 870 | "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276" 871 | ], 872 | "index": "pypi", 873 | "markers": "python_version >= '3.8'", 874 | "version": "==0.24.0" 875 | }, 876 | "pytest-mock": { 877 | "hashes": [ 878 | "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", 879 | "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0" 880 | ], 881 | "index": "pypi", 882 | "markers": "python_version >= '3.8'", 883 | "version": "==3.14.0" 884 | }, 885 | "python-telegram-bot": { 886 | "extras": [ 887 | "job-queue" 888 | ], 889 | "hashes": [ 890 | "sha256:8b2b37836c3ff9c2924e990474a1c4731df21b1668acebff5099f475666426c6", 891 | "sha256:f2d6431bf154a53f40cdfc6c1d492a66102c0e4938709f6d8202bcd951c840cb" 892 | ], 893 | "markers": "python_version >= '3.8'", 894 | "version": "==21.6" 895 | }, 896 | "python-telegram-handler": { 897 | "hashes": [ 898 | "sha256:f6e9ca60e15fa4e4595e323cc57362fe20cca3ca16e06158ad726caa48b3b16e" 899 | ], 900 | "index": "pypi", 901 | "version": "==2.2.1" 902 | }, 903 | "pytz": { 904 | "hashes": [ 905 | "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", 906 | "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725" 907 | ], 908 | "version": "==2024.2" 909 | }, 910 | "pyyaml": { 911 | "hashes": [ 912 | "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", 913 | "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", 914 | "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", 915 | "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", 916 | "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", 917 | "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", 918 | "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", 919 | "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", 920 | "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", 921 | "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", 922 | "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", 923 | "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", 924 | "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", 925 | "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", 926 | "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", 927 | "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", 928 | "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", 929 | "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", 930 | "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", 931 | "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", 932 | "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", 933 | "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", 934 | "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", 935 | "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", 936 | "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", 937 | "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", 938 | "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", 939 | "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", 940 | "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", 941 | "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", 942 | "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", 943 | "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", 944 | "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", 945 | "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", 946 | "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", 947 | "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", 948 | "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", 949 | "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", 950 | "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", 951 | "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", 952 | "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", 953 | "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", 954 | "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", 955 | "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", 956 | "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", 957 | "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", 958 | "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", 959 | "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", 960 | "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", 961 | "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", 962 | "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", 963 | "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", 964 | "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" 965 | ], 966 | "markers": "python_version >= '3.8'", 967 | "version": "==6.0.2" 968 | }, 969 | "requests": { 970 | "hashes": [ 971 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 972 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 973 | ], 974 | "markers": "python_version >= '3.8'", 975 | "version": "==2.32.3" 976 | }, 977 | "six": { 978 | "hashes": [ 979 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 980 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 981 | ], 982 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 983 | "version": "==1.16.0" 984 | }, 985 | "sniffio": { 986 | "hashes": [ 987 | "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", 988 | "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" 989 | ], 990 | "markers": "python_version >= '3.7'", 991 | "version": "==1.3.1" 992 | }, 993 | "sqlalchemy": { 994 | "hashes": [ 995 | "sha256:03e08af7a5f9386a43919eda9de33ffda16b44eb11f3b313e6822243770e9763", 996 | "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", 997 | "sha256:07b441f7d03b9a66299ce7ccf3ef2900abc81c0db434f42a5694a37bd73870f2", 998 | "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", 999 | "sha256:1e0d612a17581b6616ff03c8e3d5eff7452f34655c901f75d62bd86449d9750e", 1000 | "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959", 1001 | "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d", 1002 | "sha256:28120ef39c92c2dd60f2721af9328479516844c6b550b077ca450c7d7dc68575", 1003 | "sha256:37350015056a553e442ff672c2d20e6f4b6d0b2495691fa239d8aa18bb3bc908", 1004 | "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8", 1005 | "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", 1006 | "sha256:3d6718667da04294d7df1670d70eeddd414f313738d20a6f1d1f379e3139a545", 1007 | "sha256:3dbb986bad3ed5ceaf090200eba750b5245150bd97d3e67343a3cfed06feecf7", 1008 | "sha256:4557e1f11c5f653ebfdd924f3f9d5ebfc718283b0b9beebaa5dd6b77ec290971", 1009 | "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", 1010 | "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", 1011 | "sha256:4f5e9cd989b45b73bd359f693b935364f7e1f79486e29015813c338450aa5a71", 1012 | "sha256:50aae840ebbd6cdd41af1c14590e5741665e5272d2fee999306673a1bb1fdb4d", 1013 | "sha256:59b1ee96617135f6e1d6f275bbe988f419c5178016f3d41d3c0abb0c819f75bb", 1014 | "sha256:59b8f3adb3971929a3e660337f5dacc5942c2cdb760afcabb2614ffbda9f9f72", 1015 | "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f", 1016 | "sha256:69f93723edbca7342624d09f6704e7126b152eaed3cdbb634cb657a54332a3c5", 1017 | "sha256:6a440293d802d3011028e14e4226da1434b373cbaf4a4bbb63f845761a708346", 1018 | "sha256:72c28b84b174ce8af8504ca28ae9347d317f9dba3999e5981a3cd441f3712e24", 1019 | "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", 1020 | "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", 1021 | "sha256:8318f4776c85abc3f40ab185e388bee7a6ea99e7fa3a30686580b209eaa35c08", 1022 | "sha256:8958b10490125124463095bbdadda5aa22ec799f91958e410438ad6c97a7b793", 1023 | "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", 1024 | "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", 1025 | "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", 1026 | "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", 1027 | "sha256:9fe53b404f24789b5ea9003fc25b9a3988feddebd7e7b369c8fac27ad6f52f28", 1028 | "sha256:a4e46a888b54be23d03a89be510f24a7652fe6ff660787b96cd0e57a4ebcb46d", 1029 | "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5", 1030 | "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", 1031 | "sha256:af148a33ff0349f53512a049c6406923e4e02bf2f26c5fb285f143faf4f0e46a", 1032 | "sha256:b11d0cfdd2b095e7b0686cf5fabeb9c67fae5b06d265d8180715b8cfa86522e3", 1033 | "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", 1034 | "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", 1035 | "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", 1036 | "sha256:b817d41d692bf286abc181f8af476c4fbef3fd05e798777492618378448ee689", 1037 | "sha256:b81ee3d84803fd42d0b154cb6892ae57ea6b7c55d8359a02379965706c7efe6c", 1038 | "sha256:be9812b766cad94a25bc63bec11f88c4ad3629a0cec1cd5d4ba48dc23860486b", 1039 | "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07", 1040 | "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa", 1041 | "sha256:c4ae3005ed83f5967f961fd091f2f8c5329161f69ce8480aa8168b2d7fe37f06", 1042 | "sha256:c54a1e53a0c308a8e8a7dffb59097bff7facda27c70c286f005327f21b2bd6b1", 1043 | "sha256:d0ddd9db6e59c44875211bc4c7953a9f6638b937b0a88ae6d09eb46cced54eff", 1044 | "sha256:dc022184d3e5cacc9579e41805a681187650e170eb2fd70e28b86192a479dcaa", 1045 | "sha256:e32092c47011d113dc01ab3e1d3ce9f006a47223b18422c5c0d150af13a00687", 1046 | "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", 1047 | "sha256:f942a799516184c855e1a32fbc7b29d7e571b52612647866d4ec1c3242578fcb", 1048 | "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44", 1049 | "sha256:fd3a55deef00f689ce931d4d1b23fa9f04c880a48ee97af488fd215cf24e2a6c", 1050 | "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", 1051 | "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53" 1052 | ], 1053 | "index": "pypi", 1054 | "markers": "python_version >= '3.7'", 1055 | "version": "==2.0.36" 1056 | }, 1057 | "tomli": { 1058 | "hashes": [ 1059 | "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", 1060 | "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" 1061 | ], 1062 | "markers": "python_version < '3.11'", 1063 | "version": "==2.0.2" 1064 | }, 1065 | "tomlkit": { 1066 | "hashes": [ 1067 | "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", 1068 | "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79" 1069 | ], 1070 | "markers": "python_version >= '3.8'", 1071 | "version": "==0.13.2" 1072 | }, 1073 | "typing-extensions": { 1074 | "hashes": [ 1075 | "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", 1076 | "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" 1077 | ], 1078 | "markers": "python_version >= '3.8'", 1079 | "version": "==4.12.2" 1080 | }, 1081 | "tzlocal": { 1082 | "hashes": [ 1083 | "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", 1084 | "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e" 1085 | ], 1086 | "markers": "python_version >= '3.8'", 1087 | "version": "==5.2" 1088 | }, 1089 | "urllib3": { 1090 | "hashes": [ 1091 | "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", 1092 | "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" 1093 | ], 1094 | "markers": "python_version >= '3.8'", 1095 | "version": "==2.2.3" 1096 | }, 1097 | "virtualenv": { 1098 | "hashes": [ 1099 | "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2", 1100 | "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655" 1101 | ], 1102 | "markers": "python_version >= '3.8'", 1103 | "version": "==20.27.0" 1104 | }, 1105 | "wrapt": { 1106 | "hashes": [ 1107 | "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc", 1108 | "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", 1109 | "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", 1110 | "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e", 1111 | "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca", 1112 | "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0", 1113 | "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb", 1114 | "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487", 1115 | "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40", 1116 | "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", 1117 | "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", 1118 | "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202", 1119 | "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41", 1120 | "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", 1121 | "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", 1122 | "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664", 1123 | "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", 1124 | "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", 1125 | "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00", 1126 | "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", 1127 | "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", 1128 | "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267", 1129 | "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", 1130 | "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966", 1131 | "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", 1132 | "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228", 1133 | "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72", 1134 | "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", 1135 | "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292", 1136 | "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0", 1137 | "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0", 1138 | "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", 1139 | "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c", 1140 | "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5", 1141 | "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f", 1142 | "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", 1143 | "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", 1144 | "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2", 1145 | "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593", 1146 | "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39", 1147 | "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", 1148 | "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf", 1149 | "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf", 1150 | "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", 1151 | "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c", 1152 | "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c", 1153 | "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f", 1154 | "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440", 1155 | "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465", 1156 | "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136", 1157 | "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b", 1158 | "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8", 1159 | "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", 1160 | "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8", 1161 | "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6", 1162 | "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e", 1163 | "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f", 1164 | "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c", 1165 | "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e", 1166 | "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", 1167 | "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2", 1168 | "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020", 1169 | "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35", 1170 | "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", 1171 | "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3", 1172 | "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537", 1173 | "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", 1174 | "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d", 1175 | "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a", 1176 | "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4" 1177 | ], 1178 | "markers": "python_version >= '3.6'", 1179 | "version": "==1.16.0" 1180 | }, 1181 | "zipp": { 1182 | "hashes": [ 1183 | "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", 1184 | "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29" 1185 | ], 1186 | "markers": "python_version >= '3.8'", 1187 | "version": "==3.20.2" 1188 | } 1189 | }, 1190 | "develop": { 1191 | "autopep8": { 1192 | "hashes": [ 1193 | "sha256:8d6c87eba648fdcfc83e29b788910b8643171c395d9c4bcf115ece035b9c9dda", 1194 | "sha256:a203fe0fcad7939987422140ab17a930f684763bf7335bdb6709991dd7ef6c2d" 1195 | ], 1196 | "index": "pypi", 1197 | "markers": "python_version >= '3.8'", 1198 | "version": "==2.3.1" 1199 | }, 1200 | "pycodestyle": { 1201 | "hashes": [ 1202 | "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", 1203 | "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" 1204 | ], 1205 | "markers": "python_version >= '3.8'", 1206 | "version": "==2.12.1" 1207 | }, 1208 | "tomli": { 1209 | "hashes": [ 1210 | "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", 1211 | "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" 1212 | ], 1213 | "markers": "python_version < '3.11'", 1214 | "version": "==2.0.2" 1215 | } 1216 | } 1217 | } 1218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wachter Telegram bot 2 | 3 | [![Docker prod](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_production.yml/badge.svg)](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_production.yml) [![Docker testing](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_testing.yml/badge.svg)](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_testing.yml) 4 | 5 | telegram logo [Вахтёр Бот](https://t.me/wachter_bot) 6 | 7 | 8 | ![photo_2023-10-28_15-36-58](https://github.com/alexeyqu/wachter_bot/assets/7394728/dac59c1b-0868-4bcc-aa07-48944c9a15b8) 9 | 10 | 11 | ## Как добавить в свою группу 12 | 13 | 1. Добавить в группу. 14 | 2. Сделать администратором. 15 | 3. Опционально, настроить бота в личном чате. 16 | 17 | ## Local Development 18 | 19 | ### Prerequisites 20 | 21 | We are using black to keep our code looking nice and tidy. To make things easier, there's a pre-commit hook which ensures that files to commit are properly formatted. You need to install and initialize pre-commit. Any installation should suffice, one can find pipenv good choice since we use it in this project. After installation run: 22 | 23 | ```bash 24 | pre-commit install 25 | ``` 26 | 27 | Then will be executed black against changed files in commits. 28 | 29 | ### Running 30 | 31 | 1) Run `cp env.template .env`; 32 | 33 | 1) Set `TELEGRAM_TOKEN` and `TELEGRAM_ERROR_CHAT_ID` in `.env`; 34 | 35 | 2) Run: 36 | 37 | ```bash 38 | docker-compose -f docker-compose.dev.yml build && docker-compose -f docker-compose.dev.yml up 39 | ``` 40 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = postgresql+asyncpg://user:password@wachter-db/db 39 | 40 | # Logging configuration 41 | [loggers] 42 | keys = root,sqlalchemy,alembic 43 | 44 | [handlers] 45 | keys = console 46 | 47 | [formatters] 48 | keys = generic 49 | 50 | [logger_root] 51 | level = WARN 52 | handlers = console 53 | qualname = 54 | 55 | [logger_sqlalchemy] 56 | level = WARN 57 | handlers = 58 | qualname = sqlalchemy.engine 59 | 60 | [logger_alembic] 61 | level = INFO 62 | handlers = 63 | qualname = alembic 64 | 65 | [handler_console] 66 | class = StreamHandler 67 | args = (sys.stderr,) 68 | level = NOTSET 69 | formatter = generic 70 | 71 | [formatter_generic] 72 | format = %(levelname)-5.5s [%(name)s] %(message)s 73 | datefmt = %H:%M:%S 74 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import ( 2 | ApplicationBuilder, 3 | CommandHandler, 4 | filters, 5 | MessageHandler, 6 | CallbackQueryHandler, 7 | ChatMemberHandler, 8 | PicklePersistence, 9 | ) 10 | from ptbcontrib.ptb_jobstores.sqlalchemy import PTBSQLAlchemyJobStore 11 | 12 | import grpc 13 | from opentelemetry import metrics 14 | from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( 15 | OTLPMetricExporter, 16 | ) 17 | from opentelemetry.sdk import metrics as sdkmetrics 18 | from opentelemetry.sdk.metrics import MeterProvider 19 | from opentelemetry.sdk.metrics.export import ( 20 | AggregationTemporality, 21 | PeriodicExportingMetricReader, 22 | ) 23 | from opentelemetry.sdk.resources import Resource 24 | 25 | from src.custom_filters import filter_bot_added 26 | from src.logging import tg_logger 27 | from src import handlers 28 | import os 29 | 30 | 31 | temporality_delta = { 32 | sdkmetrics.Counter: AggregationTemporality.DELTA, 33 | sdkmetrics.UpDownCounter: AggregationTemporality.DELTA, 34 | sdkmetrics.Histogram: AggregationTemporality.DELTA, 35 | sdkmetrics.ObservableCounter: AggregationTemporality.DELTA, 36 | sdkmetrics.ObservableUpDownCounter: AggregationTemporality.DELTA, 37 | sdkmetrics.ObservableGauge: AggregationTemporality.DELTA, 38 | } 39 | 40 | 41 | def main(): 42 | dsn = os.environ.get("UPTRACE_DSN") 43 | 44 | exporter = OTLPMetricExporter( 45 | endpoint="otlp.uptrace.dev:4317", 46 | headers=(("uptrace-dsn", dsn),), 47 | timeout=5, 48 | compression=grpc.Compression.Gzip, 49 | preferred_temporality=temporality_delta, 50 | ) 51 | reader = PeriodicExportingMetricReader(exporter) 52 | 53 | resource = Resource( 54 | attributes={ 55 | "service.name": "wachter", 56 | "service.version": "1.1.0", 57 | "deployment.environment": os.environ.get("DEPLOYMENT_ENVIRONMENT"), 58 | } 59 | ) 60 | provider = MeterProvider(metric_readers=[reader], resource=resource) 61 | metrics.set_meter_provider(provider) 62 | 63 | application = ( 64 | ApplicationBuilder() 65 | .persistence(PicklePersistence(filepath="persistent_storage.pickle")) 66 | .token(os.environ["TELEGRAM_TOKEN"]) 67 | .build() 68 | ) 69 | if "PERSISTENCE_DATABASE_URL" in os.environ: 70 | tg_logger.info(f"Using SQLAlchemy job store with PERSISTENCE_DATABASE_URL") 71 | application.job_queue.scheduler.add_jobstore( 72 | PTBSQLAlchemyJobStore( 73 | application=application, 74 | url=os.environ["PERSISTENCE_DATABASE_URL"], 75 | ) 76 | ) 77 | else: 78 | tg_logger.info("No PERSISTENCE_DATABASE_URL set, using in-memory job store") 79 | 80 | application.add_handler(CommandHandler("help", handlers.help_handler)) 81 | application.add_handler(CommandHandler("listjobs", handlers.list_jobs_handler)) 82 | 83 | # group UX 84 | application.add_handler( 85 | ChatMemberHandler( 86 | handlers.my_chat_member_handler, 87 | ChatMemberHandler.MY_CHAT_MEMBER, 88 | ) 89 | ) 90 | application.add_handler( 91 | MessageHandler( 92 | filters.Entity("hashtag") & filters.ChatType.GROUPS, 93 | handlers.on_hashtag_message, 94 | ) 95 | ) 96 | application.add_handler( 97 | MessageHandler( 98 | filters.StatusUpdate.NEW_CHAT_MEMBERS & filter_bot_added, 99 | handlers.on_new_chat_members, 100 | ) 101 | ) 102 | 103 | # admin UX 104 | application.add_handler(CommandHandler("start", handlers.start_handler)) 105 | application.add_handler(CallbackQueryHandler(handlers.button_handler)) 106 | application.add_handler(MessageHandler(filters.TEXT, handlers.message_handler)) 107 | application.add_error_handler(handlers.error_handler) 108 | 109 | # Remove any existing metrics_exporter jobs to avoid duplicates 110 | existing_jobs = [job for job in application.job_queue.jobs() if job.name == "metrics_exporter"] 111 | if existing_jobs: 112 | tg_logger.info(f"Removing {len(existing_jobs)} existing metrics_exporter job(s)") 113 | for job in existing_jobs: 114 | job.schedule_removal() 115 | 116 | job = application.job_queue.run_repeating( 117 | handlers.group.group_handler.db_metrics_reader_helper, 118 | 3600, 119 | name="metrics_exporter", 120 | ) 121 | tg_logger.info(f"Scheduled metrics_exporter job: {job.name}") 122 | 123 | tg_logger.info("Bot has started successfully") 124 | application.run_polling() 125 | 126 | 127 | if __name__ == "__main__": 128 | main() 129 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | wachter-db: 5 | container_name: wachter-db-dev 6 | image: postgres:alpine 7 | restart: always 8 | ports: 9 | - 5433:5432 10 | environment: 11 | POSTGRES_DB: db 12 | POSTGRES_HOST: wachter-db 13 | POSTGRES_USER: user 14 | POSTGRES_PASSWORD: password 15 | volumes: 16 | - volume-db:/data/db 17 | - postgres-data:/var/lib/postgresql/data 18 | - ./share/sql:/docker-entrypoint-initdb.d 19 | healthcheck: 20 | test: pg_isready -U user -d db 21 | interval: 5s 22 | timeout: 2s 23 | retries: 3 24 | 25 | wachter-persistence-db: 26 | container_name: wachter-persistence-db-dev 27 | image: postgres:alpine 28 | restart: always 29 | ports: 30 | - 5434:5432 31 | environment: 32 | POSTGRES_DB: db 33 | POSTGRES_HOST: wachter-persistence-db 34 | POSTGRES_USER: user 35 | POSTGRES_PASSWORD: password 36 | volumes: 37 | - volume-persistence-db:/data/db 38 | - postgres-persistence-data:/var/lib/postgresql/data 39 | - ./share/sql-persistence:/docker-entrypoint-initdb.d 40 | healthcheck: 41 | test: pg_isready -U user -d db 42 | interval: 5s 43 | timeout: 2s 44 | retries: 3 45 | 46 | wachter: 47 | container_name: wachter-dev 48 | build: 49 | context: . 50 | environment: 51 | - TELEGRAM_TOKEN=${TELEGRAM_TOKEN} 52 | - TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID} 53 | - DATABASE_URL=postgresql+asyncpg://user:password@wachter-db/db 54 | - PERSISTENCE_DATABASE_URL=postgresql://user:password@wachter-persistence-db/db 55 | restart: unless-stopped 56 | depends_on: 57 | wachter-db: 58 | condition: service_healthy 59 | 60 | volumes: 61 | volume-db: 62 | postgres-data: 63 | volume-persistence-db: 64 | postgres-persistence-data: 65 | 66 | networks: 67 | default: 68 | name: network-wachter-dev 69 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | wachterbot-prod: 5 | container_name: wachterbot-prod 6 | image: ghcr.io/wachter-org/wachter-bot/wachterbot:prod 7 | environment: 8 | - TELEGRAM_TOKEN=${TELEGRAM_TOKEN} 9 | - UPTRACE_DSN=${UPTRACE_DSN} 10 | - TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID} 11 | - DATABASE_URL=${DATABASE_URL} 12 | - PERSISTENCE_DATABASE_URL=${PERSISTENCE_DATABASE_URL} 13 | - DEBUG=${DEBUG} 14 | - DEPLOYMENT_ENVIRONMENT=production 15 | - TEAM_TELEGRAM_IDS=${TEAM_TELEGRAM_IDS} 16 | restart: unless-stopped 17 | 18 | postgres-prod: 19 | image: postgres:17-alpine 20 | restart: always 21 | ports: 22 | - 5435:5432 23 | environment: 24 | POSTGRES_DB: db 25 | POSTGRES_HOST: postgres-prod 26 | POSTGRES_USER: ${DATABASE_USER} 27 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 28 | volumes: 29 | - volume-db:/data/db 30 | - postgres-data:/var/lib/postgresql/data 31 | - ./backup/db.sql:/docker-entrypoint-initdb.d/backupfile.sql 32 | healthcheck: 33 | test: pg_isready -U ${DATABASE_USER} -d db 34 | interval: 5s 35 | timeout: 2s 36 | retries: 3 37 | 38 | postgres-persistence-prod: 39 | image: postgres:17-alpine 40 | restart: always 41 | ports: 42 | - 5436:5432 43 | environment: 44 | POSTGRES_DB: persistence-db 45 | POSTGRES_HOST: postgres-persistence-prod 46 | POSTGRES_USER: ${PERSISTENCE_DATABASE_USER} 47 | POSTGRES_PASSWORD: ${PERSISTENCE_DATABASE_PASSWORD} 48 | volumes: 49 | - volume-persistence-db:/data/db 50 | - postgres-persistence-data:/var/lib/postgresql/data 51 | - ./backup/db-persistence.sql:/docker-entrypoint-initdb.d/backupfile.sql 52 | healthcheck: 53 | test: pg_isready -U ${PERSISTENCE_DATABASE_USER} -d persistence-db 54 | interval: 5s 55 | timeout: 2s 56 | retries: 3 57 | 58 | volumes: 59 | volume-db: 60 | postgres-data: 61 | volume-persistence-db: 62 | postgres-persistence-data: 63 | 64 | networks: 65 | default: 66 | name: network-wachterbot-prod 67 | -------------------------------------------------------------------------------- /docker-compose.testing.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | wachterbot-testing: 5 | container_name: wachterbot-testing 6 | image: ghcr.io/wachter-org/wachter-bot/wachterbot:testing 7 | environment: 8 | - TELEGRAM_TOKEN=${TELEGRAM_TOKEN} 9 | - TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID} 10 | - DATABASE_URL=${DATABASE_URL} 11 | - UPTRACE_DSN=${UPTRACE_DSN} 12 | - PERSISTENCE_DATABASE_URL=${PERSISTENCE_DATABASE_URL} 13 | - DEBUG=${DEBUG} 14 | - DEPLOYMENT_ENVIRONMENT=testing 15 | - TEAM_TELEGRAM_IDS=${TEAM_TELEGRAM_IDS} 16 | restart: unless-stopped 17 | 18 | postgres-testing: 19 | image: postgres:17-alpine 20 | restart: always 21 | ports: 22 | - 5433:5432 23 | environment: 24 | POSTGRES_DB: db 25 | POSTGRES_HOST: postgres-testing 26 | POSTGRES_USER: ${DATABASE_USER} 27 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 28 | volumes: 29 | - volume-db:/data/db 30 | - postgres-data:/var/lib/postgresql/data 31 | - ./backup/db.sql:/docker-entrypoint-initdb.d/backupfile.sql 32 | healthcheck: 33 | test: pg_isready -U ${DATABASE_USER} -d db 34 | interval: 5s 35 | timeout: 2s 36 | retries: 3 37 | 38 | postgres-persistence-testing: 39 | image: postgres:17-alpine 40 | restart: always 41 | ports: 42 | - 5434:5432 43 | environment: 44 | POSTGRES_DB: persistence-db 45 | POSTGRES_HOST: postgres-persistence-testing 46 | POSTGRES_USER: ${PERSISTENCE_DATABASE_USER} 47 | POSTGRES_PASSWORD: ${PERSISTENCE_DATABASE_PASSWORD} 48 | volumes: 49 | - volume-persistence-db:/data/db 50 | - postgres-persistence-data:/var/lib/postgresql/data 51 | - ./backup/db-persistence.sql:/docker-entrypoint-initdb.d/backupfile.sql 52 | healthcheck: 53 | test: pg_isready -U ${PERSISTENCE_DATABASE_USER} -d persistence-db 54 | interval: 5s 55 | timeout: 2s 56 | retries: 3 57 | 58 | volumes: 59 | volume-db: 60 | postgres-data: 61 | volume-persistence-db: 62 | postgres-persistence-data: 63 | 64 | networks: 65 | default: 66 | name: network-wachterbot-testing 67 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | alembic upgrade head 4 | exec python app.py 5 | -------------------------------------------------------------------------------- /env.template: -------------------------------------------------------------------------------- 1 | TELEGRAM_TOKEN= 2 | TELEGRAM_ERROR_CHAT_ID= 3 | -------------------------------------------------------------------------------- /migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wachter-org/wachter-bot/66dd483355ca429e5197bfb538a2f262e5cd2f84/migrations/__init__.py -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | import os 4 | 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | target_metadata = None 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | config.set_main_option('sqlalchemy.url', os.environ.get( 32 | "DATABASE_URL", "postgresql+asyncpg://user:password@wachter-db/db" 33 | )) 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def do_run_migrations(connection: Connection) -> None: 60 | context.configure(connection=connection, target_metadata=target_metadata) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | async def run_async_migrations() -> None: 67 | """In this scenario we need to create an Engine 68 | and associate a connection with the context. 69 | 70 | """ 71 | 72 | connectable = async_engine_from_config( 73 | config.get_section(config.config_ini_section, {}), 74 | prefix="sqlalchemy.", 75 | poolclass=pool.NullPool, 76 | ) 77 | 78 | async with connectable.connect() as connection: 79 | await connection.run_sync(do_run_migrations) 80 | 81 | await connectable.dispose() 82 | 83 | 84 | def run_migrations_online() -> None: 85 | """Run migrations in 'online' mode.""" 86 | 87 | asyncio.run(run_async_migrations()) 88 | 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/0336b796d052_create_tables_for_timeouts_and_whois_.py: -------------------------------------------------------------------------------- 1 | """create tables for timeouts and whois length 2 | 3 | Revision ID: e34af99a19b5 4 | Revises: 0336b796d052 5 | Create Date: 2023-10-21 16:56:05.421923 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "e34af99a19b5" 14 | down_revision = "0336b796d052" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column( 21 | "chats", 22 | sa.Column("notify_timeout", sa.Integer(), nullable=False, server_default="0"), 23 | ) 24 | op.add_column( 25 | "chats", 26 | sa.Column("whois_length", sa.Integer(), nullable=False, server_default="60"), 27 | ) 28 | 29 | 30 | def downgrade(): 31 | op.drop_column( 32 | "chats", 33 | sa.Column("notify_timeout", sa.Integer(), nullable=False, server_default="0"), 34 | ) 35 | op.drop_column( 36 | "chats", 37 | sa.Column("whois_length", sa.Integer(), nullable=False, server_default="60"), 38 | ) 39 | -------------------------------------------------------------------------------- /migrations/versions/0336b796d052_filter_only_new_users.py: -------------------------------------------------------------------------------- 1 | """create filter_only_new_users 2 | 3 | Revision ID: 0336b796d052 4 | Revises: 65330214bff4 5 | Create Date: 2019-01-05 15:25:08.201425 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '0336b796d052' 14 | down_revision = '65330214bff4' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column('chats', sa.Column('filter_only_new_users', sa.Boolean, nullable=False, server_default="False")) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column('chats', 'filter_only_new_users') 27 | # ### end Alembic commands ### -------------------------------------------------------------------------------- /migrations/versions/2345e3de1ccc_add_on_kick_message.py: -------------------------------------------------------------------------------- 1 | """add on kick message 2 | 3 | Revision ID: 2345e3de1ccc 4 | Revises: ae0105c2205b 5 | Create Date: 2018-08-19 18:19:30.031804 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2345e3de1ccc' 14 | down_revision = 'ae0105c2205b' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('chats', sa.Column('on_kick_message', sa.Text(), nullable=False, server_default='')) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('chats', 'on_kick_message') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/296da7f6d724_remove_default_values.py: -------------------------------------------------------------------------------- 1 | """remove default values 2 | 3 | Revision ID: 296da7f6d724 4 | Revises: 85798d8901da 5 | Create Date: 2023-10-25 15:37:35.767976 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "296da7f6d724" 14 | down_revision = "85798d8901da" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | with op.batch_alter_table("chats", schema=None) as batch_op: 21 | batch_op.alter_column( 22 | "on_new_chat_member_message", existing_type=sa.TEXT(), server_default=None 23 | ) 24 | batch_op.alter_column( 25 | "on_known_new_chat_member_message", 26 | existing_type=sa.TEXT(), 27 | server_default=None, 28 | ) 29 | batch_op.alter_column( 30 | "on_introduce_message", existing_type=sa.TEXT(), server_default=None 31 | ) 32 | batch_op.alter_column( 33 | "on_kick_message", existing_type=sa.TEXT(), server_default=None 34 | ) 35 | batch_op.alter_column( 36 | "notify_message", existing_type=sa.TEXT(), server_default=None 37 | ) 38 | batch_op.alter_column( 39 | "kick_timeout", existing_type=sa.INTEGER(), server_default=None 40 | ) 41 | batch_op.alter_column( 42 | "notify_timeout", existing_type=sa.INTEGER(), server_default=None 43 | ) 44 | batch_op.alter_column( 45 | "whois_length", existing_type=sa.INTEGER(), server_default=None 46 | ) 47 | batch_op.alter_column( 48 | "on_introduce_message_update", existing_type=sa.TEXT(), server_default=None 49 | ) 50 | 51 | 52 | def downgrade(): 53 | with op.batch_alter_table("chats", schema=None) as batch_op: 54 | batch_op.alter_column( 55 | "on_new_chat_member_message", 56 | existing_type=sa.TEXT(), 57 | server_default="Пожалуйста, представьтесь и поздоровайтесь с сообществом.", 58 | ) 59 | batch_op.alter_column( 60 | "on_known_new_chat_member_message", 61 | existing_type=sa.TEXT(), 62 | server_default="Добро пожаловать. Снова", 63 | ) 64 | batch_op.alter_column( 65 | "on_introduce_message", 66 | existing_type=sa.TEXT(), 67 | server_default="Добро пожаловать.", 68 | ) 69 | batch_op.alter_column( 70 | "on_kick_message", 71 | existing_type=sa.TEXT(), 72 | server_default="%USER\_MENTION% молчит и покидает чат", 73 | ) 74 | batch_op.alter_column( 75 | "notify_message", 76 | existing_type=sa.TEXT(), 77 | server_default="%USER\_MENTION%, пожалуйста, представьтесь и поздоровайтесь с сообществом.", 78 | ) 79 | batch_op.alter_column( 80 | "kick_timeout", existing_type=sa.INTEGER(), server_default="0" 81 | ) 82 | batch_op.alter_column( 83 | "notify_timeout", existing_type=sa.INTEGER(), server_default="0" 84 | ) 85 | batch_op.alter_column( 86 | "whois_length", existing_type=sa.INTEGER(), server_default="60" 87 | ) 88 | batch_op.alter_column( 89 | "on_introduce_message_update", 90 | existing_type=sa.TEXT(), 91 | server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.", 92 | ) 93 | -------------------------------------------------------------------------------- /migrations/versions/65330214bff4_add_regex_filter.py: -------------------------------------------------------------------------------- 1 | """add regex filter 2 | 3 | Revision ID: 65330214bff4 4 | Revises: 2345e3de1ccc 5 | Create Date: 2018-08-19 21:52:23.437227 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '65330214bff4' 14 | down_revision = '2345e3de1ccc' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('chats', sa.Column('regex_filter', sa.Text(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('chats', 'regex_filter') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/85798d8901da_add_on_introduce_message_update_column.py: -------------------------------------------------------------------------------- 1 | """add on_introduce_message_update column 2 | 3 | Revision ID: 85798d8901da 4 | Revises: e34af99a19b5 5 | Create Date: 2023-10-23 22:48:45.471633 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "85798d8901da" 14 | down_revision = "e34af99a19b5" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column( 21 | "chats", 22 | sa.Column( 23 | "on_introduce_message_update", 24 | sa.Text(), 25 | nullable=False, 26 | server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.", 27 | ), 28 | ) 29 | 30 | 31 | def downgrade(): 32 | op.drop_column( 33 | "chats", 34 | sa.Column( 35 | "on_introduce_message_update", 36 | sa.Text(), 37 | nullable=False, 38 | server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.", 39 | ), 40 | ) 41 | -------------------------------------------------------------------------------- /migrations/versions/ae0105c2205b_create_new_messages.py: -------------------------------------------------------------------------------- 1 | """create new messages 2 | 3 | Revision ID: ae0105c2205b 4 | Revises: fd40280af78a 5 | Create Date: 2018-07-04 20:05:09.847739 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ae0105c2205b' 14 | down_revision = 'fd40280af78a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('chats', sa.Column('notify_message', sa.Text(), nullable=False)) 22 | op.add_column('chats', sa.Column('on_known_new_chat_member_message', sa.Text(), nullable=False)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('chats', 'on_known_new_chat_member_message') 29 | op.drop_column('chats', 'notify_message') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/d3a10581c664_create_chat_table.py: -------------------------------------------------------------------------------- 1 | """create chat table 2 | 3 | Revision ID: d3a10581c664 4 | Revises: 5 | Create Date: 2018-06-07 13:22:16.857444 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd3a10581c664' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('chats', 22 | sa.Column('id', sa.BigInteger(), nullable=False), 23 | sa.Column('on_new_chat_member_message', sa.Text(), nullable=False), 24 | sa.Column('on_introduce_message', sa.Text(), nullable=False), 25 | sa.Column('kick_timeout', sa.Integer(), nullable=False), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('chats') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /migrations/versions/fd40280af78a_create_user_table.py: -------------------------------------------------------------------------------- 1 | """create user table 2 | 3 | Revision ID: fd40280af78a 4 | Revises: d3a10581c664 5 | Create Date: 2018-06-10 16:43:36.824598 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'fd40280af78a' 14 | down_revision = 'd3a10581c664' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('user_id', sa.BigInteger(), nullable=False), 23 | sa.Column('chat_id', sa.BigInteger(), nullable=False), 24 | sa.Column('whois', sa.Text(), nullable=False), 25 | sa.PrimaryKeyConstraint('user_id', 'chat_id') 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('users') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, auto 2 | import json, os 3 | 4 | 5 | default_kick_timeout_m = 1440 # 24h in minutes 6 | default_notify_timeout_m = 1380 # 23h in minutes 7 | default_delete_message_timeout_m = 60 # 1h in minutes 8 | default_whois_length = 60 9 | 10 | 11 | # ACTIONS 12 | class Actions(IntEnum): 13 | start_select_chat = auto() 14 | select_chat = auto() 15 | set_on_new_chat_member_message_response = auto() 16 | set_notify_message = auto() 17 | set_on_successful_introducion_response = auto() 18 | set_on_known_new_chat_member_message_response = auto() 19 | set_kick_timeout = auto() 20 | set_on_kick_message = auto() 21 | get_current_settings = auto() 22 | set_intro_settings = auto() 23 | set_kick_bans_settings = auto() 24 | back_to_chats = auto() 25 | set_notify_timeout = auto() 26 | get_current_kick_settings = auto() 27 | get_current_intro_settings = auto() 28 | set_whois_length = auto() 29 | set_on_introduce_message_update = auto() 30 | 31 | 32 | DEBUG = os.environ.get("DEBUG", "True") in ["True"] 33 | TEAM_TELEGRAM_IDS = json.loads(os.environ.get("TEAM_TELEGRAM_IDS", "[]")) 34 | 35 | 36 | def get_uri(): 37 | return os.environ.get( 38 | "DATABASE_URL", "postgresql+asyncpg://user:password@wachter-db/db" 39 | ) 40 | -------------------------------------------------------------------------------- /src/custom_filters.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import filters 2 | 3 | 4 | class FilterBotAdded( 5 | filters.MessageFilter 6 | ): # filter for message, that bot was added to group 7 | def filter(self, message): 8 | if message.new_chat_members[-1].is_bot: 9 | return False 10 | return True 11 | 12 | 13 | filter_bot_added = FilterBotAdded() 14 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module with all the telegram handlers. 3 | """ 4 | 5 | from .help_handler import help_handler 6 | from .error_handler import error_handler 7 | 8 | from .admin import * 9 | from .debug import * 10 | from .group import * 11 | -------------------------------------------------------------------------------- /src/handlers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module with all the telegram handlers related to admin flow. 3 | """ 4 | 5 | from .start_handler import start_handler 6 | from .menu_handler import button_handler, message_handler 7 | -------------------------------------------------------------------------------- /src/handlers/admin/menu_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from telegram import InlineKeyboardMarkup, Update 3 | from telegram.ext import ContextTypes 4 | from telegram.constants import ParseMode 5 | from datetime import datetime, timedelta 6 | from typing import Callable, Optional 7 | 8 | from sqlalchemy import select 9 | 10 | from src import constants 11 | from src.model import Chat, session_scope 12 | from src.texts import _ 13 | 14 | from src.handlers.group.group_handler import on_kick_timeout, on_notify_timeout 15 | 16 | from .utils import ( 17 | get_chats_list, 18 | create_chats_list_keyboard, 19 | new_button, 20 | new_keyboard_layout, 21 | get_chat_name, 22 | ) 23 | 24 | from src.logging import tg_logger 25 | 26 | 27 | async def _job_rescheduling_helper( 28 | job_func: Callable, timeout: int, context: ContextTypes.DEFAULT_TYPE, chat_id: int 29 | ) -> None: 30 | """ 31 | This function helps in rescheduling a job in the Telegram bot's job queue. 32 | 33 | Args: 34 | job_func (Callable): The function that is to be scheduled as a job. This is the callback function that is executed when the job runs. 35 | timeout (int): The amount of time (in minutes) after which the job should be executed. 36 | context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API. This provides a context containing information about the current state of the bot and the update it is handling. 37 | chat_id (int): The unique identifier for the chat. This is used to query the database for chat-specific settings. 38 | 39 | Returns: 40 | None: This function does not return anything. 41 | """ 42 | # Iterating through all the jobs currently in the job queue 43 | for job in context.job_queue.jobs(): 44 | # If the job's name matches the name of the job function provided 45 | if job.name == job_func.__name__ and chat_id == job.data.get('chat_id'): 46 | # Extracting the job context and calculating the new timeout 47 | job_data = job.data 48 | job_creation_time = datetime.fromtimestamp(job_data.get("creation_time")) 49 | new_timeout = job_creation_time + timedelta(seconds=timeout * 60) 50 | 51 | # If the new timeout is in the past, set it to now 52 | if new_timeout < datetime.now(): 53 | new_timeout = datetime.now() 54 | 55 | # Schedule the current job for removal 56 | job.schedule_removal() 57 | 58 | # If the job is a notification timeout, perform additional checks 59 | if job_func == on_notify_timeout: 60 | # Querying the database to get the chat's kick timeout setting 61 | async with session_scope() as sess: 62 | result = await sess.execute(select(Chat).filter(Chat.id == chat_id)) 63 | chat: Optional[Chat] = result.scalars().first() 64 | kick_timeout = chat.kick_timeout if chat else 0 65 | 66 | # If the new timeout is greater than the kick timeout, skip to the next job 67 | if ( 68 | job_creation_time + timedelta(seconds=kick_timeout * 60) 69 | ) > new_timeout: 70 | continue 71 | 72 | # Update the job context with the new timeout 73 | job_data["timeout"] = new_timeout 74 | 75 | # Schedule the new job with the updated context and timeout 76 | job = context.job_queue.run_once(job_func, new_timeout, data=job_data) 77 | 78 | 79 | async def _get_current_settings_helper( 80 | chat_id: int, settings: str, chat_name: str 81 | ) -> str: 82 | """ 83 | Retrieve the current settings for a specific chat based on the settings category provided. 84 | This function is now an asynchronous function. 85 | 86 | Args: 87 | chat_id (int): The ID of the chat for which the settings are to be retrieved. 88 | settings (str): A string indicating the category of settings to retrieve. 89 | 90 | Returns: 91 | str: A formatted message string containing the current settings. 92 | """ 93 | async with session_scope() as session: 94 | result = await session.execute(select(Chat).filter(Chat.id == chat_id)) 95 | chat: Optional[Chat] = result.scalars().first() 96 | 97 | if settings == constants.Actions.get_current_intro_settings: 98 | print("Loading intro settings for chat_id:", chat_id) 99 | return ( 100 | _("msg__get_intro_settings") 101 | .format(chat_name=chat_name, **chat.__dict__) 102 | .replace("%USER\\_MENTION%", "%USER_MENTION%") 103 | ) 104 | else: 105 | return ( 106 | _("msg__get_kick_settings") 107 | .format(chat_name=chat_name, **chat.__dict__) 108 | .replace("%USER\\_MENTION%", "%USER_MENTION%") 109 | ) 110 | 111 | 112 | # todo rework into callback folder 113 | async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 114 | """ 115 | Handle button presses in the Telegram bot's inline keyboard. 116 | 117 | Args: 118 | update (Update): The Telegram update object. 119 | context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API. 120 | 121 | Returns: 122 | None 123 | """ 124 | query = update.callback_query 125 | data = json.loads(query.data) 126 | 127 | if data["action"] == constants.Actions.start_select_chat: 128 | user_id = query.from_user.id 129 | user_chats = await get_chats_list(user_id, context) 130 | if len(user_chats) == 0: 131 | await update.message.reply_text(_("msg__no_chats_available")) 132 | return 133 | reply_markup = InlineKeyboardMarkup( 134 | await create_chats_list_keyboard(user_chats, context, user_id) 135 | ) 136 | await context.bot.edit_message_text( 137 | _("msg__start_command"), 138 | reply_markup=reply_markup, 139 | chat_id=query.message.chat_id, 140 | message_id=query.message.message_id, 141 | ) 142 | 143 | if data["action"] == constants.Actions.select_chat: 144 | selected_chat_id = data["chat_id"] 145 | button_configs = [ 146 | [{"text": _("btn__intro"), "action": constants.Actions.set_intro_settings}], 147 | [ 148 | { 149 | "text": _("btn__kicks"), 150 | "action": constants.Actions.set_kick_bans_settings, 151 | } 152 | ], 153 | [ 154 | { 155 | "text": _("btn__back_to_chats"), 156 | "action": constants.Actions.back_to_chats, 157 | } 158 | ], 159 | ] 160 | reply_markup = new_keyboard_layout(button_configs, selected_chat_id) 161 | chat_name = await get_chat_name(context.bot, selected_chat_id) 162 | await context.bot.edit_message_text( 163 | _("msg__select_chat").format(chat_name=chat_name), 164 | reply_markup=reply_markup, 165 | chat_id=query.message.chat_id, 166 | message_id=query.message.message_id, 167 | ) 168 | elif data["action"] == constants.Actions.set_intro_settings: 169 | selected_chat_id = data["chat_id"] 170 | button_configs = [ 171 | [ 172 | { 173 | "text": _("btn__current_settings"), 174 | "action": constants.Actions.get_current_intro_settings, 175 | } 176 | ], 177 | [ 178 | { 179 | "text": _("btn__change_welcome_message"), 180 | "action": constants.Actions.set_on_new_chat_member_message_response, 181 | } 182 | ], 183 | [ 184 | { 185 | "text": _("btn__change_rewelcome_message"), 186 | "action": constants.Actions.set_on_known_new_chat_member_message_response, 187 | } 188 | ], 189 | [ 190 | { 191 | "text": _("btn__change_notify_message"), 192 | "action": constants.Actions.set_notify_message, 193 | } 194 | ], 195 | [ 196 | { 197 | "text": _("btn__change_sucess_message"), 198 | "action": constants.Actions.set_on_successful_introducion_response, 199 | } 200 | ], 201 | [ 202 | { 203 | "text": _("btn__change_notify_timeout"), 204 | "action": constants.Actions.set_notify_timeout, 205 | } 206 | ], 207 | [ 208 | { 209 | "text": _("btn__change_whois_length"), 210 | "action": constants.Actions.set_whois_length, 211 | } 212 | ], 213 | [ 214 | { 215 | "text": _("btn__change_whois_message"), 216 | "action": constants.Actions.set_on_introduce_message_update, 217 | } 218 | ], 219 | [{"text": _("btn__back"), "action": constants.Actions.select_chat}], 220 | ] 221 | reply_markup = new_keyboard_layout(button_configs, selected_chat_id) 222 | await context.bot.edit_message_reply_markup( 223 | reply_markup=reply_markup, 224 | chat_id=query.message.chat_id, 225 | message_id=query.message.message_id, 226 | ) 227 | 228 | elif data["action"] == constants.Actions.set_kick_bans_settings: 229 | selected_chat_id = data["chat_id"] 230 | button_configs = [ 231 | [ 232 | { 233 | "text": _("btn__current_settings"), 234 | "action": constants.Actions.get_current_kick_settings, 235 | } 236 | ], 237 | [ 238 | { 239 | "text": _("btn__change_kick_timeout"), 240 | "action": constants.Actions.set_kick_timeout, 241 | } 242 | ], 243 | [ 244 | { 245 | "text": _("btn__change_kick_message"), 246 | "action": constants.Actions.set_on_kick_message, 247 | } 248 | ], 249 | [{"text": _("btn__back"), "action": constants.Actions.select_chat}], 250 | ] 251 | reply_markup = new_keyboard_layout(button_configs, selected_chat_id) 252 | await context.bot.edit_message_reply_markup( 253 | reply_markup=reply_markup, 254 | chat_id=query.message.chat_id, 255 | message_id=query.message.message_id, 256 | ) 257 | 258 | elif data["action"] == constants.Actions.back_to_chats: 259 | user_id = query.message.chat_id 260 | user_chats = await get_chats_list(user_id, context) 261 | reply_markup = InlineKeyboardMarkup( 262 | await create_chats_list_keyboard(user_chats, context, user_id) 263 | ) 264 | await context.bot.edit_message_text( 265 | _("msg__start_command"), 266 | reply_markup=reply_markup, 267 | chat_id=query.message.chat_id, 268 | message_id=query.message.message_id, 269 | ) 270 | 271 | elif data["action"] == constants.Actions.set_on_new_chat_member_message_response: 272 | await context.bot.edit_message_text( 273 | text=_("msg__set_new_welcome_message"), 274 | chat_id=query.message.chat_id, 275 | message_id=query.message.message_id, 276 | parse_mode=ParseMode.MARKDOWN, 277 | ) 278 | context.user_data["chat_id"] = data["chat_id"] 279 | context.user_data["action"] = data["action"] 280 | 281 | elif data["action"] == constants.Actions.set_kick_timeout: 282 | await context.bot.edit_message_text( 283 | text=_("msg__set_new_kick_timout"), 284 | chat_id=query.message.chat_id, 285 | message_id=query.message.message_id, 286 | ) 287 | context.user_data["chat_id"] = data["chat_id"] 288 | context.user_data["action"] = data["action"] 289 | 290 | elif ( 291 | data["action"] 292 | == constants.Actions.set_on_known_new_chat_member_message_response 293 | ): 294 | await context.bot.edit_message_text( 295 | text=_("msg__set_new_rewelcome_message"), 296 | chat_id=query.message.chat_id, 297 | message_id=query.message.message_id, 298 | parse_mode=ParseMode.MARKDOWN, 299 | ) 300 | context.user_data["chat_id"] = data["chat_id"] 301 | context.user_data["action"] = data["action"] 302 | 303 | elif data["action"] == constants.Actions.set_notify_message: 304 | await context.bot.edit_message_text( 305 | text=_("msg__set_new_notify_message"), 306 | chat_id=query.message.chat_id, 307 | message_id=query.message.message_id, 308 | parse_mode=ParseMode.MARKDOWN, 309 | ) 310 | context.user_data["chat_id"] = data["chat_id"] 311 | context.user_data["action"] = data["action"] 312 | 313 | elif data["action"] == constants.Actions.set_on_new_chat_member_message_response: 314 | await context.bot.edit_message_text( 315 | text=_("msg__set_new_welcome_message"), 316 | chat_id=query.message.chat_id, 317 | message_id=query.message.message_id, 318 | parse_mode=ParseMode.MARKDOWN, 319 | ) 320 | context.user_data["chat_id"] = data["chat_id"] 321 | context.user_data["action"] = data["action"] 322 | 323 | elif data["action"] == constants.Actions.set_on_successful_introducion_response: 324 | await context.bot.edit_message_text( 325 | text=_("msg__set_new_sucess_message"), 326 | chat_id=query.message.chat_id, 327 | message_id=query.message.message_id, 328 | parse_mode=ParseMode.MARKDOWN, 329 | ) 330 | context.user_data["chat_id"] = data["chat_id"] 331 | context.user_data["action"] = data["action"] 332 | 333 | elif data["action"] == constants.Actions.set_whois_length: 334 | await context.bot.edit_message_text( 335 | text=_("msg__set_new_whois_length"), 336 | chat_id=query.message.chat_id, 337 | message_id=query.message.message_id, 338 | ) 339 | context.user_data["chat_id"] = data["chat_id"] 340 | context.user_data["action"] = data["action"] 341 | 342 | elif data["action"] == constants.Actions.set_on_kick_message: 343 | await context.bot.edit_message_text( 344 | text=_("msg__set_new_kick_message"), 345 | chat_id=query.message.chat_id, 346 | message_id=query.message.message_id, 347 | parse_mode=ParseMode.MARKDOWN, 348 | ) 349 | context.user_data["chat_id"] = data["chat_id"] 350 | context.user_data["action"] = data["action"] 351 | 352 | elif data["action"] == constants.Actions.set_notify_timeout: 353 | await context.bot.edit_message_text( 354 | text=_("msg__set_new_notify_timeout"), 355 | chat_id=query.message.chat_id, 356 | message_id=query.message.message_id, 357 | ) 358 | context.user_data["chat_id"] = data["chat_id"] 359 | context.user_data["action"] = data["action"] 360 | 361 | elif data["action"] == constants.Actions.set_on_introduce_message_update: 362 | await context.bot.edit_message_text( 363 | text=_("msg__set_new_whois_message"), 364 | chat_id=query.message.chat_id, 365 | message_id=query.message.message_id, 366 | parse_mode=ParseMode.MARKDOWN, 367 | ) 368 | context.user_data["chat_id"] = data["chat_id"] 369 | context.user_data["action"] = data["action"] 370 | 371 | elif data["action"] == constants.Actions.get_current_intro_settings: 372 | keyboard = [ 373 | [ 374 | new_button( 375 | _("btn__back"), 376 | data["chat_id"], 377 | constants.Actions.set_intro_settings, 378 | ) 379 | ] 380 | ] 381 | chat_name = await get_chat_name(context.bot, data["chat_id"]) 382 | reply_markup = InlineKeyboardMarkup(keyboard) 383 | settings = await _get_current_settings_helper( 384 | data["chat_id"], data["action"], chat_name 385 | ) 386 | await context.bot.edit_message_text( 387 | text=settings, 388 | parse_mode=ParseMode.MARKDOWN, 389 | chat_id=query.message.chat_id, 390 | message_id=query.message.message_id, 391 | reply_markup=reply_markup, 392 | ) 393 | 394 | context.user_data["action"] = None 395 | 396 | elif data["action"] == constants.Actions.get_current_kick_settings: 397 | keyboard = [ 398 | [ 399 | new_button( 400 | _("btn__back"), 401 | data["chat_id"], 402 | constants.Actions.set_kick_bans_settings, 403 | ) 404 | ] 405 | ] 406 | reply_markup = InlineKeyboardMarkup(keyboard) 407 | chat_name = await get_chat_name(context.bot, data["chat_id"]) 408 | settings = await _get_current_settings_helper( 409 | data["chat_id"], data["action"], chat_name 410 | ) 411 | await context.bot.edit_message_text( 412 | text=settings, 413 | parse_mode=ParseMode.MARKDOWN, 414 | chat_id=query.message.chat_id, 415 | message_id=query.message.message_id, 416 | reply_markup=reply_markup, 417 | ) 418 | 419 | context.user_data["action"] = None 420 | 421 | 422 | async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 423 | """ 424 | Handle text messages received by the Telegram bot. 425 | 426 | Args: 427 | update (Update): The Telegram update object. 428 | context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API. 429 | 430 | Returns: 431 | None: This function returns nothing. 432 | """ 433 | chat_id = update.effective_message.chat_id 434 | 435 | if chat_id > 0: 436 | action = context.user_data.get("action") 437 | 438 | if action is None: 439 | return 440 | 441 | chat_id = context.user_data["chat_id"] 442 | 443 | if action == constants.Actions.set_kick_timeout: 444 | message = update.effective_message.text 445 | try: 446 | timeout = int(message) 447 | assert timeout >= 0 448 | except: 449 | await update.effective_message.reply_text( 450 | _("msg__failed_set_kick_timeout_response") 451 | ) 452 | return 453 | async with session_scope() as sess: 454 | chat = Chat(id=chat_id, kick_timeout=timeout) 455 | await sess.merge(chat) 456 | context.user_data["action"] = None 457 | await _job_rescheduling_helper(on_kick_timeout, timeout, context, chat_id) 458 | 459 | keyboard = [ 460 | [ 461 | new_button( 462 | _("btn__back"), 463 | chat_id, 464 | constants.Actions.set_kick_bans_settings, 465 | ) 466 | ] 467 | ] 468 | reply_markup = InlineKeyboardMarkup(keyboard) 469 | await update.effective_message.reply_text( 470 | _("msg__success_set_kick_timeout_response"), 471 | reply_markup=reply_markup, 472 | ) 473 | 474 | elif action == constants.Actions.set_notify_timeout: 475 | message = update.effective_message.text 476 | try: 477 | timeout = int(message) 478 | assert timeout >= 0 479 | except: 480 | await update.effective_message.reply_text(_("msg__failed_kick_response")) 481 | return 482 | async with session_scope() as sess: 483 | chat = Chat(id=chat_id, notify_timeout=timeout) 484 | await sess.merge(chat) 485 | context.user_data["action"] = None 486 | await _job_rescheduling_helper(on_notify_timeout, timeout, context, chat_id) 487 | 488 | keyboard = [ 489 | [ 490 | new_button( 491 | _("btn__back"), chat_id, constants.Actions.set_intro_settings 492 | ) 493 | ] 494 | ] 495 | reply_markup = InlineKeyboardMarkup(keyboard) 496 | await update.effective_message.reply_text( 497 | _("msg__sucess_set_notify_timeout_response"), 498 | reply_markup=reply_markup, 499 | ) 500 | 501 | elif action in [ 502 | constants.Actions.set_on_new_chat_member_message_response, 503 | constants.Actions.set_notify_message, 504 | constants.Actions.set_on_known_new_chat_member_message_response, 505 | constants.Actions.set_on_successful_introducion_response, 506 | constants.Actions.set_on_kick_message, 507 | constants.Actions.set_whois_length, 508 | constants.Actions.set_on_introduce_message_update, 509 | ]: 510 | message = update.effective_message.text_markdown 511 | reply_message = _("msg__set_new_message") 512 | async with session_scope() as sess: 513 | if action == constants.Actions.set_on_new_chat_member_message_response: 514 | chat = Chat(id=chat_id, on_new_chat_member_message=message) 515 | if ( 516 | action 517 | == constants.Actions.set_on_known_new_chat_member_message_response 518 | ): 519 | chat = Chat(id=chat_id, on_known_new_chat_member_message=message) 520 | if action == constants.Actions.set_on_successful_introducion_response: 521 | chat = Chat(id=chat_id, on_introduce_message=message) 522 | if action == constants.Actions.set_notify_message: 523 | chat = Chat(id=chat_id, notify_message=message) 524 | if action == constants.Actions.set_on_kick_message: 525 | chat = Chat(id=chat_id, on_kick_message=message) 526 | if action == constants.Actions.set_whois_length: 527 | try: 528 | whois_length = int(message) 529 | assert whois_length >= 0 530 | chat = Chat(id=chat_id, whois_length=whois_length) 531 | reply_message = _("msg__sucess_whois_length") 532 | except: 533 | await update.effective_message.reply_text(_("msg__failed_whois_response")) 534 | return 535 | 536 | if action == constants.Actions.set_on_introduce_message_update: 537 | if ( 538 | "#update" 539 | not in update.effective_message.parse_entities(types=["hashtag"]).values() 540 | ): 541 | await update.effective_message.reply_text( 542 | _("msg__need_hashtag_update_response") 543 | ) 544 | return 545 | chat = Chat(id=chat_id, on_introduce_message_update=message) 546 | await sess.merge(chat) 547 | 548 | if action in [ 549 | constants.Actions.set_on_kick_message, 550 | constants.Actions.set_kick_timeout, 551 | ]: 552 | keyboard = [ 553 | [ 554 | new_button( 555 | _("btn__back"), 556 | chat_id, 557 | constants.Actions.set_kick_bans_settings, 558 | ) 559 | ] 560 | ] 561 | else: 562 | keyboard = [ 563 | [ 564 | new_button( 565 | _("btn__back"), 566 | chat_id, 567 | constants.Actions.set_intro_settings, 568 | ) 569 | ] 570 | ] 571 | context.user_data["action"] = None 572 | 573 | reply_markup = InlineKeyboardMarkup(keyboard) 574 | await update.effective_message.reply_text(reply_message, reply_markup=reply_markup) 575 | -------------------------------------------------------------------------------- /src/handlers/admin/start_handler.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardMarkup, Update 2 | from telegram.ext import ContextTypes 3 | 4 | 5 | from src.handlers.utils import admin 6 | from src.texts import _ 7 | 8 | 9 | from .utils import get_chats_list, create_chats_list_keyboard 10 | 11 | 12 | @admin 13 | async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 14 | """ 15 | Handle the /start command in a Telegram chat. 16 | 17 | Args: 18 | update (Update): The update object that represents the incoming update. 19 | context (ContextTypes.DEFAULT_TYPE): The context object that contains information about the current state of the bot. 20 | 21 | Returns: 22 | None 23 | """ 24 | # Get the ID of the user who sent the message 25 | user_id = update.message.chat_id 26 | 27 | # Retrieve the list of chats where the user has administrative privileges 28 | user_chats = await get_chats_list(user_id, context) 29 | 30 | # If the user does not have administrative privileges in any chat, inform them 31 | if len(user_chats) == 0: 32 | await update.message.reply_text(_("msg__no_chats_available")) 33 | return 34 | 35 | # Create an inline keyboard with the list of available chats 36 | reply_markup = InlineKeyboardMarkup( 37 | await create_chats_list_keyboard(user_chats, context, user_id) 38 | ) 39 | 40 | # Send a message to the user with the inline keyboard 41 | await update.message.reply_text(_("msg__start_command"), reply_markup=reply_markup) 42 | -------------------------------------------------------------------------------- /src/handlers/admin/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from typing import Iterator, Dict, List 4 | 5 | from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup 6 | from telegram.ext import CallbackContext 7 | 8 | from sqlalchemy import select 9 | 10 | from src.logging import tg_logger 11 | from src.model import User, session_scope 12 | from src import constants 13 | 14 | 15 | async def get_chat_name(bot, chat_id): 16 | chat = await bot.get_chat(chat_id) 17 | return chat.title or str(chat_id) 18 | 19 | 20 | def new_button(text: str, chat_id: int, action) -> InlineKeyboardButton: 21 | """ 22 | Create a new InlineKeyboardButton with associated callback data. 23 | 24 | Args: 25 | text (str): The text to be displayed on the button. 26 | chat_id (int): The chat ID to be included in the callback data. 27 | action (str): The action to be performed, included in the callback data. 28 | 29 | Returns: 30 | InlineKeyboardButton: The created InlineKeyboardButton instance. 31 | """ 32 | callback_data = json.dumps({"chat_id": chat_id, "action": action}) 33 | return InlineKeyboardButton(text, callback_data=callback_data) 34 | 35 | 36 | def new_keyboard_layout( 37 | button_configs: List[List[Dict[str, str]]], chat_id: int 38 | ) -> InlineKeyboardMarkup: 39 | """ 40 | Create a new InlineKeyboardMarkup layout based on a configuration list. 41 | 42 | Args: 43 | button_configs (List[List[Dict[str, str]]]): A list of button configurations. 44 | chat_id (int): The chat ID to be included in the callback data of each button. 45 | 46 | Returns: 47 | InlineKeyboardMarkup: The created InlineKeyboardMarkup instance. 48 | """ 49 | keyboard = [ 50 | [new_button(button["text"], chat_id, button["action"]) for button in row] 51 | for row in button_configs 52 | ] 53 | return InlineKeyboardMarkup(keyboard) 54 | 55 | 56 | async def authorize_user(bot: Bot, chat_id: int, user_id: int) -> bool: 57 | """ 58 | Asynchronously check if a user is an administrator or the creator of a chat. 59 | 60 | Args: 61 | bot (Bot): The Telegram Bot instance. 62 | chat_id (int): The ID of the chat. 63 | user_id (int): The ID of the user. 64 | 65 | Returns: 66 | bool: True if the user is an administrator or creator of the chat, False otherwise. 67 | """ 68 | try: 69 | chat_member = await bot.get_chat_member(chat_id, user_id) 70 | return chat_member.status in ["creator", "administrator"] 71 | except Exception as e: 72 | print(f"Failed to check if user {user_id} is admin in chat {chat_id}: {e}") 73 | return False 74 | 75 | 76 | async def get_chats_list( 77 | user_id: int, context: CallbackContext 78 | ) -> List[Dict[str, int]]: 79 | """ 80 | Retrieve a list of chats where the user is an administrator or creator. 81 | 82 | This function queries the database for User instances associated with the provided 83 | user_id. For each User instance found, it checks whether the provided user_id 84 | is an authorized user of the associated chat. If so, the function retrieves the 85 | chat's title and id, and adds them to a list which is returned after all 86 | authorized chats have been processed. 87 | 88 | Args: 89 | user_id (int): The ID of the user. 90 | context (CallbackContext): The callback context as provided by the Telegram Bot API. 91 | 92 | Returns: 93 | List[Dict[str, int]]: A list of dictionaries, each containing the 'title' and 'id' 94 | of a chat where the user has administrative or creator rights. 95 | """ 96 | time_start = time.time() 97 | async with session_scope() as session: # Ensure this yields an AsyncSession object. 98 | result = await session.execute(select(User).filter(User.user_id == user_id)) 99 | users = result.scalars().all() 100 | chats_list = [] 101 | for user in users: 102 | try: 103 | if await authorize_user(context.bot, user.chat_id, user_id): 104 | chat_name = await get_chat_name(context.bot, user.chat_id) 105 | chats_list.append({"title": chat_name, "id": user.chat_id}) 106 | except Exception as e: 107 | context.bot.logger.exception( 108 | e 109 | ) # Ensure your CallbackContext has a logger configured. 110 | tg_logger.info(f'get_chats_list time elapsed {time.time() - time_start}s') 111 | return chats_list 112 | 113 | 114 | async def create_chats_list_keyboard( 115 | user_chats: Iterator[Dict[str, int]], context: CallbackContext, user_id: int 116 | ) -> List[List[InlineKeyboardButton]]: 117 | """ 118 | Create a keyboard layout for the list of chats where the user is an administrator or creator. 119 | 120 | Args: 121 | user_chats (Iterator[Dict[str, int]]): An iterator over dictionaries containing chat information. 122 | context (CallbackContext): The callback context as provided by the Telegram API. 123 | user_id (int): The ID of the user. 124 | 125 | Returns: 126 | List[List[InlineKeyboardButton]]: The created keyboard layout. 127 | """ 128 | return [ 129 | [new_button(chat["title"], chat["id"], constants.Actions.select_chat)] 130 | for chat in user_chats 131 | if await authorize_user(context.bot, chat["id"], user_id) 132 | ] 133 | -------------------------------------------------------------------------------- /src/handlers/debug/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module with all the telegram handlers used for debugging. 3 | """ 4 | 5 | from .list_jobs_handler import list_jobs_handler 6 | -------------------------------------------------------------------------------- /src/handlers/debug/list_jobs_handler.py: -------------------------------------------------------------------------------- 1 | import html 2 | from telegram import Update 3 | from telegram.constants import ParseMode 4 | from telegram.ext import CallbackContext 5 | 6 | from src import constants 7 | from src.handlers.utils import debug 8 | 9 | 10 | @debug 11 | async def list_jobs_handler(update: Update, context: CallbackContext) -> None: 12 | args = update.effective_message.text.split() 13 | chat_id = int(args[1]) if len(args) > 1 else None 14 | jobs = [job for job in context.job_queue.jobs() if chat_id is None or job.data.get("chat_id") == chat_id] 15 | await update.message.reply_text( 16 | f"Jobs: {len(jobs)} items\n\n" 17 | + "\n\n".join( 18 | [ 19 | f"Job {html.escape(job.name)} ts {html.escape(str(job.next_t)) if job.next_t else 'None'}\nContext: {html.escape(str(job.data))}" 20 | for job in jobs 21 | ] 22 | ), 23 | parse_mode=ParseMode.HTML, 24 | ) 25 | -------------------------------------------------------------------------------- /src/handlers/error_handler.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.ext import CallbackContext 3 | from telegram.constants import ParseMode 4 | import logging 5 | import traceback 6 | import os 7 | import html 8 | import json 9 | from src.logging import tg_logger 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | async def error_handler(update: Update, context: CallbackContext): 14 | """ 15 | Log the error and send a telegram message to notify the developer. 16 | Credits: https://docs.python-telegram-bot.org/en/stable/examples.errorhandlerbot.html 17 | """ 18 | try: 19 | # Safety check: ensure context and error exist 20 | if not context or not context.error: 21 | logger.error("Error handler called but context or context.error is None") 22 | return 23 | 24 | # Log the error before we do anything else, so we can see it even if something breaks. 25 | logger.error("Exception while handling an update:", exc_info=context.error) 26 | 27 | # traceback.format_exception returns the usual python message about an exception, but as a 28 | # list of strings rather than a single string, so we have to join them together. 29 | tb_list = traceback.format_exception(None, context.error, context.error.__traceback__) 30 | tb_string = "".join(tb_list) 31 | 32 | # Build the message with some markup and additional information about what happened. 33 | # You might need to add some logic to deal with messages longer than the 4096 character limit. 34 | update_str = update.to_dict() if isinstance(update, Update) and update else str(update) 35 | message = ( 36 | "An exception was raised while handling an update\n" 37 | f"
update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
38 |             "
\n\n" 39 | f"
context.chat_data = {html.escape(str(context.chat_data))}
\n\n" 40 | f"
context.user_data = {html.escape(str(context.user_data))}
\n\n" 41 | f"
{html.escape(tb_string)}
" 42 | ) 43 | 44 | # Truncate message if it's too long (Telegram has a 4096 character limit) 45 | if len(message) > 4096: 46 | message = message[:4000] + "\n\n... (message truncated)" 47 | 48 | # Finally, send the message (only if TELEGRAM_ERROR_CHAT_ID is set and bot is available) 49 | error_chat_id = os.environ.get("TELEGRAM_ERROR_CHAT_ID") 50 | if error_chat_id and context.bot: 51 | try: 52 | await context.bot.send_message( 53 | chat_id=error_chat_id, text=message, parse_mode=ParseMode.HTML 54 | ) 55 | except Exception as send_error: 56 | # If sending the error message fails, log it but don't raise 57 | logger.error(f"Failed to send error message to Telegram: {send_error}", exc_info=send_error) 58 | except Exception as handler_error: 59 | # If the error handler itself fails, log it but don't raise to avoid infinite recursion 60 | logger.critical(f"Error handler itself raised an exception: {handler_error}", exc_info=handler_error) 61 | # Fallback to simple print if logger also fails 62 | try: 63 | print(f"CRITICAL: Error handler failed: {handler_error}") 64 | print(f"Original error: {context.error if context and context.error else 'Unknown'}") 65 | except Exception: 66 | pass # Last resort - if even print fails, we're in deep trouble -------------------------------------------------------------------------------- /src/handlers/group/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module with all the telegram handlers related to group chat flow. 3 | """ 4 | 5 | from .group_handler import on_hashtag_message, on_new_chat_members 6 | from .my_chat_member_handler import my_chat_member_handler 7 | -------------------------------------------------------------------------------- /src/handlers/group/group_handler.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from telegram import Bot, Message, Update 3 | from telegram.constants import ParseMode 4 | from telegram.ext import ContextTypes 5 | from typing import Optional 6 | 7 | from sqlalchemy import select, func 8 | 9 | from src.logging import tg_logger 10 | from src import constants 11 | from src.texts import _ 12 | from src.model import Chat, User, session_scope 13 | from src.handlers.utils import setup_counter, setup_histogram 14 | 15 | 16 | new_member_counter = setup_counter("new_member.meter", "new_member_counter") 17 | whois_counter = setup_counter("new_whois.meter", "new_whois_counter") 18 | ban_counter = setup_counter("ban.meter", "ban_counter") 19 | chats_histogram = setup_histogram("chats.meter", "chats_counter") 20 | users_histogram = setup_histogram("users.meter", "users_counter") 21 | unique_users_histogram = setup_histogram( 22 | "unique_users.meter", "unique_users_counter" 23 | ) 24 | 25 | async def db_metrics_reader_helper(context: ContextTypes.DEFAULT_TYPE): 26 | async with session_scope() as sess: 27 | # Number of chats 28 | result = await sess.execute(select(func.count(Chat.id))) 29 | chat_count = result.scalar() 30 | chats_histogram.record(chat_count) 31 | # Total number of users 32 | result = await sess.execute(select(func.count()).select_from(User)) 33 | users_count = result.scalar() 34 | users_histogram.record(users_count) 35 | # Number of unique users 36 | result = await sess.execute(select(func.count(func.distinct(User.user_id)))) 37 | unique_users_count = result.scalar() 38 | unique_users_histogram.record(unique_users_count) 39 | 40 | 41 | async def on_new_chat_members( 42 | update: Update, context: ContextTypes.DEFAULT_TYPE 43 | ) -> None: 44 | """ 45 | Handle the event when a new member joins a chat. 46 | 47 | Args: 48 | update (Update): The update object that represents the incoming update. 49 | context (CallbackContext): The context object that contains information about the current state of the bot. 50 | 51 | Returns: 52 | None 53 | """ 54 | chat_id = update.message.chat_id 55 | new_member_counter.add(1, {"chat_id": chat_id}) 56 | user_ids = [ 57 | new_chat_member.id for new_chat_member in update.message.new_chat_members 58 | ] 59 | 60 | for user_id in user_ids: 61 | for job in context.job_queue.jobs(): 62 | if ( 63 | job.data 64 | and job.data.get("user_id") == user_id 65 | and job.data.get("chat_id") == chat_id 66 | ): 67 | job.schedule_removal() 68 | 69 | async with session_scope() as sess: 70 | result = await sess.execute( 71 | select(User).where(User.chat_id == chat_id, User.user_id == user_id) 72 | ) 73 | user = result.scalars().first() 74 | chat_result = await sess.execute(select(Chat).where(Chat.id == chat_id)) 75 | chat = chat_result.scalars().first() 76 | 77 | if chat is None: 78 | chat = Chat.get_new_chat(chat_id) 79 | sess.add(chat) 80 | await sess.commit() 81 | 82 | if user is not None: 83 | await _send_message_with_deletion( 84 | context, 85 | chat_id, 86 | user_id, 87 | chat.on_known_new_chat_member_message, 88 | reply_to=update.message, 89 | ) 90 | continue 91 | 92 | message = chat.on_new_chat_member_message 93 | kick_timeout = chat.kick_timeout 94 | notify_timeout = chat.notify_timeout 95 | 96 | if message == _("msg__skip_new_chat_member"): 97 | continue 98 | 99 | await _send_message_with_deletion( 100 | context, 101 | chat_id, 102 | user_id, 103 | message, 104 | # 36 hours which is considered infinity; bots can't delete messages older than 48h 105 | timeout_m=constants.default_delete_message_timeout_m * 24 * 1.5, 106 | reply_to=update.message, 107 | ) 108 | 109 | if kick_timeout != 0: 110 | job = context.job_queue.run_once( 111 | on_kick_timeout, 112 | kick_timeout * 60, 113 | chat_id=chat_id, 114 | user_id=user_id, 115 | data={ 116 | "chat_id": chat_id, 117 | "user_id": user_id, 118 | "creation_time": datetime.now().timestamp(), 119 | }, 120 | ) 121 | 122 | if notify_timeout != 0: 123 | job = context.job_queue.run_once( 124 | on_notify_timeout, 125 | notify_timeout * 60, 126 | chat_id=chat_id, 127 | user_id=user_id, 128 | data={ 129 | "chat_id": chat_id, 130 | "user_id": user_id, 131 | "creation_time": datetime.now().timestamp(), 132 | }, 133 | ) 134 | 135 | 136 | def is_whois(update, chat_id): 137 | return ( 138 | "#whois" in update.effective_message.parse_entities(types=["hashtag"]).values() 139 | and chat_id < 0 140 | ) 141 | 142 | 143 | async def remove_user_jobs_from_queue(context, user_id, chat_id): 144 | """ 145 | Remove jobs related to a specific user from the job queue. 146 | 147 | Args: 148 | context (CallbackContext): The context object containing the job queue and bot instance. 149 | user_id (int): The user ID for whom the jobs should be removed. 150 | chat_id (int): The chat ID associated with the jobs to be removed. 151 | 152 | Returns: 153 | bool: True if at least one job was removed, False otherwise. 154 | """ 155 | removed = False 156 | for job in context.job_queue.jobs(): 157 | if job.data and job.data.get("user_id") == user_id and job.data.get("chat_id") == chat_id: 158 | if "message_id" in job.data: 159 | try: 160 | await context.bot.delete_message( 161 | job.data.get("chat_id"), job.data["message_id"] 162 | ) 163 | except Exception as e: 164 | tg_logger.warning( 165 | f"can't delete {job.data['message_id']} from {job.data['chat_id']}", 166 | exc_info=e, 167 | ) 168 | job.schedule_removal() 169 | removed = True 170 | return removed 171 | 172 | 173 | async def on_hashtag_message( 174 | update: Update, context: ContextTypes.DEFAULT_TYPE 175 | ) -> None: 176 | """ 177 | Handle messages containing #whois hashtag. 178 | 179 | Args: 180 | update (Update): The update object that represents the incoming update. 181 | context (CallbackContext): The context object that contains information about the current state of the bot. 182 | 183 | Returns: 184 | None 185 | """ 186 | chat_id = update.effective_message.chat_id 187 | 188 | if is_whois(update, chat_id): 189 | user_id = update.effective_message.from_user.id 190 | whois_counter.add(1) 191 | 192 | async with session_scope() as sess: 193 | chat_result = await sess.execute(select(Chat).where(Chat.id == chat_id)) 194 | chat = chat_result.scalars().first() 195 | if chat is None: 196 | chat = Chat.get_new_chat(chat_id) 197 | sess.add(chat) 198 | await sess.commit() 199 | 200 | if len(update.effective_message.text) <= chat.whois_length: 201 | await _send_message_with_deletion( 202 | context, 203 | chat_id, 204 | user_id, 205 | # TODO move to chat DB 206 | _("msg__short_whois").format(whois_length=chat.whois_length), 207 | reply_to=update.effective_message, 208 | ) 209 | return 210 | 211 | message = chat.on_introduce_message 212 | 213 | async with session_scope() as sess: 214 | user = User( 215 | chat_id=chat_id, user_id=user_id, whois=update.effective_message.text 216 | ) 217 | await sess.merge(user) 218 | 219 | removed = False 220 | removed = await remove_user_jobs_from_queue(context, user_id, chat_id) 221 | 222 | if removed: 223 | await _send_message_with_deletion( 224 | context, 225 | chat_id, 226 | user_id, 227 | message, 228 | reply_to=update.effective_message, 229 | ) 230 | 231 | 232 | async def on_notify_timeout(context: ContextTypes.DEFAULT_TYPE): 233 | """ 234 | Send notify message, schedule its deletion. 235 | 236 | Args: 237 | context (CallbackContext): The context object containing the job details and bot instance. 238 | 239 | Returns: 240 | None 241 | """ 242 | bot, job = context.bot, context.job 243 | async with session_scope() as sess: 244 | chat_result = await sess.execute( 245 | select(Chat).filter(Chat.id == job.data["chat_id"]) 246 | ) 247 | chat = chat_result.scalar_one_or_none() 248 | 249 | await _send_message_with_deletion( 250 | context, 251 | job.data.get("chat_id"), 252 | job.data.get("user_id"), 253 | chat.notify_message, 254 | timeout_m=chat.kick_timeout - chat.notify_timeout, 255 | ) 256 | 257 | 258 | async def on_kick_timeout(context: ContextTypes.DEFAULT_TYPE) -> None: 259 | """ 260 | Kick a user from the chat after a set amount of time and send a message about it. 261 | 262 | Args: 263 | context (CallbackContext): The context object containing the job details and bot instance. 264 | 265 | Returns: 266 | None 267 | """ 268 | bot, job = context.bot, context.job 269 | 270 | try: 271 | await bot.ban_chat_member( 272 | job.data.get("chat_id"), 273 | job.data.get("user_id"), 274 | until_date=datetime.now() + timedelta(seconds=60), 275 | ) 276 | ban_counter.add(1) 277 | 278 | async with session_scope() as sess: 279 | chat_result = await sess.execute( 280 | select(Chat).where(Chat.id == job.data["chat_id"]) 281 | ) 282 | chat = chat_result.scalar_one_or_none() 283 | 284 | if chat.on_kick_message.lower() not in ["false", "0"]: 285 | await _send_message_with_deletion( 286 | context, 287 | job.data.get("chat_id"), 288 | job.data.get("user_id"), 289 | chat.on_kick_message, 290 | ) 291 | except Exception as e: 292 | tg_logger.exception( 293 | f"Failed to kick {job.data['user_id']} from {job.data['chat_id']}", 294 | exc_info=e, 295 | ) 296 | await _send_message_with_deletion( 297 | context, 298 | job.data.get("chat_id"), 299 | job.data.get("user_id"), 300 | _("msg__failed_kick_response"), 301 | ) 302 | 303 | 304 | async def delete_message(context: ContextTypes.DEFAULT_TYPE) -> None: 305 | """ 306 | Delete a message from a chat. 307 | 308 | Args: 309 | context (CallbackContext): The context object containing the job details and bot instance. 310 | 311 | Returns: 312 | None 313 | """ 314 | bot, job = context.bot, context.job 315 | try: 316 | await bot.delete_message(job.data["chat_id"], job.data["message_id"]) 317 | except Exception as e: 318 | tg_logger.warning( 319 | f"can't delete {job.data['message_id']} from {job.data['chat_id']}", 320 | exc_info=e, 321 | ) 322 | 323 | 324 | async def _mention_markdown(bot: Bot, chat_id: int, user_id: int, message: str) -> str: 325 | """ 326 | Format a message to include a markdown mention of a user. 327 | 328 | Args: 329 | bot (Bot): The Telegram bot instance. 330 | chat_id (int): The ID of the chat. 331 | user_id (int): The ID of the user to mention. 332 | message (str): The message to format. 333 | 334 | Returns: 335 | str: The formatted message with the user mention. 336 | """ 337 | chat_member = await bot.get_chat_member(chat_id, user_id) 338 | user = chat_member.user 339 | # if not user.name: 340 | # # если пользователь удален, у него пропадает имя и markdown выглядит так: (tg://user?id=666) 341 | # user_mention_markdown = "" 342 | # else: 343 | user_mention_markdown = user.mention_markdown() 344 | 345 | # \ нужен из-за формата сообщений в маркдауне 346 | tg_logger.warning(user_mention_markdown) 347 | #user_mention_markdown = user_mention_markdown.replace("/[", "[") 348 | #user_mention_markdown = user_mention_markdown.replace("]", "\]") 349 | # wtf 350 | message_mention = message.replace("%USER\\\\\\_MENTION%", user_mention_markdown) 351 | message_mention = message_mention.replace("%USER\\\\_MENTION%", user_mention_markdown) 352 | message_mention = message_mention.replace("%USER\\_MENTION%", user_mention_markdown) 353 | message_mention = message_mention.replace("%USER_MENTION%", user_mention_markdown) 354 | tg_logger.warning(message_mention) 355 | return message_mention 356 | 357 | 358 | async def _send_message_with_deletion( 359 | context: ContextTypes.DEFAULT_TYPE, 360 | chat_id: int, 361 | user_id: int, 362 | message: str, 363 | timeout_m: int = constants.default_delete_message_timeout_m, 364 | reply_to: Optional[Message] = None, 365 | ): 366 | message_markdown = await _mention_markdown(context.bot, chat_id, user_id, message) 367 | 368 | if reply_to is not None: 369 | sent_message = await reply_to.reply_text( 370 | text=message_markdown, parse_mode=ParseMode.MARKDOWN 371 | ) 372 | else: 373 | sent_message = await context.bot.send_message( 374 | chat_id, text=message_markdown, parse_mode=ParseMode.MARKDOWN 375 | ) 376 | 377 | # correctly handle negative timeouts 378 | timeout_m = max(timeout_m, constants.default_delete_message_timeout_m) 379 | 380 | context.job_queue.run_once( 381 | delete_message, 382 | timeout_m * 60, 383 | chat_id=chat_id, 384 | user_id=user_id, 385 | data={ 386 | "chat_id": chat_id, 387 | "user_id": user_id, 388 | "message_id": sent_message.message_id, 389 | }, 390 | ) 391 | -------------------------------------------------------------------------------- /src/handlers/group/my_chat_member_handler.py: -------------------------------------------------------------------------------- 1 | from telegram import ChatMember, Update 2 | from telegram.ext import ContextTypes 3 | 4 | from sqlalchemy import select 5 | 6 | from src import constants 7 | from src.model import Chat, User, session_scope 8 | from src.handlers.admin.utils import new_keyboard_layout 9 | from src.texts import _ 10 | 11 | 12 | async def my_chat_member_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): 13 | old_status, new_status = update.my_chat_member.difference().get("status", (None, None)) 14 | 15 | if old_status == ChatMember.LEFT and new_status == ChatMember.MEMBER: 16 | # which means the bot was added to the chat 17 | await context.bot.send_message( 18 | update.effective_chat.id, _("msg__add_bot_to_chat") 19 | ) 20 | return 21 | 22 | if ( 23 | old_status != ChatMember.ADMINISTRATOR 24 | and new_status == ChatMember.ADMINISTRATOR 25 | ): 26 | # which means the bot is now admin and can be used 27 | async with session_scope() as sess: 28 | result = await sess.execute( 29 | select(Chat).filter_by(id=update.effective_chat.id) 30 | ) 31 | chat = result.scalars().first() 32 | 33 | if chat is None: 34 | chat = Chat.get_new_chat(update.effective_chat.id) 35 | sess.add(chat) 36 | # hack with adding an empty #whois to prevent slow /start cmd 37 | # TODO after v1.0: rework the DB schema 38 | user = User( 39 | chat_id=update.effective_chat.id, 40 | user_id=update.effective_user.id, 41 | whois="", 42 | ) 43 | await sess.merge(user) 44 | # notify the admin about a new chat 45 | button_configs = [ 46 | [ 47 | { 48 | "text": "Приветствия", 49 | "action": constants.Actions.set_intro_settings, 50 | } 51 | ], 52 | [ 53 | { 54 | "text": "Удаление и блокировка", 55 | "action": constants.Actions.set_kick_bans_settings, 56 | } 57 | ], 58 | [{"text": "Назад", "action": constants.Actions.back_to_chats}], 59 | ] 60 | reply_markup = new_keyboard_layout( 61 | button_configs, update.effective_chat.id 62 | ) 63 | await context.bot.send_message( 64 | update.effective_user.id, 65 | _("msg__make_admin_direct").format( 66 | chat_name=update.effective_chat.title 67 | ), 68 | reply_markup=reply_markup, 69 | ) 70 | 71 | await context.bot.send_message(update.effective_chat.id, _("msg__make_admin")) 72 | return 73 | -------------------------------------------------------------------------------- /src/handlers/help_handler.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.constants import ParseMode 3 | from telegram.ext import ContextTypes 4 | 5 | from src.texts import _ 6 | 7 | 8 | async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 9 | await update.message.reply_text(_("msg__help"), parse_mode=ParseMode.MARKDOWN) 10 | -------------------------------------------------------------------------------- /src/handlers/utils.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from telegram import Update 4 | from telegram.ext import CallbackContext 5 | from opentelemetry import metrics 6 | 7 | from src.constants import DEBUG, TEAM_TELEGRAM_IDS 8 | from src.model import Chat, User, session_scope 9 | 10 | 11 | def setup_counter(meter_name, counter_name, version="2.0.0"): 12 | """ 13 | A helper function to remove duplication of code for counters creation. 14 | """ 15 | meter = metrics.get_meter(meter_name, version=version) 16 | return meter.create_counter(counter_name, unit="1") 17 | 18 | def setup_histogram(meter_name, histogram_name, version="2.0.0"): 19 | meter = metrics.get_meter(meter_name, version=version) 20 | return meter.create_histogram(name=histogram_name) 21 | 22 | 23 | def admin(func): 24 | """ 25 | A decorator to ensure that a particular function is only executed in private chats, 26 | and not in group chats. 27 | 28 | Args: 29 | func (Callable): The function to be wrapped by the decorator. 30 | 31 | Returns: 32 | Callable: The wrapper function which includes the functionality for checking the chat type. 33 | """ 34 | 35 | @wraps(func) 36 | def wrapper(update: Update, context: CallbackContext, *args, **kwargs): 37 | if update.message.chat_id < 0: 38 | return # Skip the execution of the function in case of group chat 39 | return func(update, context, *args, **kwargs) 40 | 41 | return wrapper 42 | 43 | 44 | def debug(func): 45 | """ 46 | A decorator to ensure that a particular function is only executed for debug purposes, i.e. by someone from the team. 47 | 48 | Args: 49 | func (Callable): The function to be wrapped by the decorator. 50 | 51 | Returns: 52 | Callable: The wrapper function which includes the functionality for checking the called ID. 53 | """ 54 | 55 | @wraps(func) 56 | def wrapper(update: Update, context: CallbackContext, *args, **kwargs): 57 | if DEBUG or update.message.chat_id in TEAM_TELEGRAM_IDS: 58 | return func(update, context, *args, **kwargs) 59 | 60 | return wrapper 61 | -------------------------------------------------------------------------------- /src/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import config 3 | import os 4 | 5 | import grpc 6 | from opentelemetry._logs import set_logger_provider 7 | from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( 8 | OTLPLogExporter, 9 | ) 10 | from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler 11 | from opentelemetry.sdk._logs.export import BatchLogRecordProcessor 12 | from opentelemetry.sdk.resources import Resource 13 | 14 | dsn = os.environ.get("UPTRACE_DSN") 15 | 16 | resource = Resource( 17 | attributes={"service.name": "wachter-bot", "service.version": "1.1.0", "deployment.environment": os.environ.get("DEPLOYMENT_ENVIRONMENT")} 18 | ) 19 | logger_provider = LoggerProvider(resource=resource) 20 | set_logger_provider(logger_provider) 21 | 22 | exporter = OTLPLogExporter( 23 | endpoint="otlp.uptrace.dev:4317", 24 | headers=(("uptrace-dsn", dsn),), 25 | timeout=5, 26 | compression=grpc.Compression.Gzip, 27 | ) 28 | logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) 29 | 30 | log_config = { 31 | "version": 1, 32 | "disable_existing_loggers": False, 33 | "handlers": { 34 | "wachter_telegram": { 35 | "class": "telegram_handler.TelegramHandler", 36 | "token": os.environ["TELEGRAM_TOKEN"], 37 | "chat_id": os.environ["TELEGRAM_ERROR_CHAT_ID"], 38 | }, 39 | "wachter_oltp": { 40 | "class": "opentelemetry.sdk._logs.LoggingHandler", 41 | "level": logging.INFO, 42 | "logger_provider": logger_provider, 43 | }, 44 | }, 45 | "loggers": { 46 | "wachter_telegram_logger": { 47 | "level": "INFO", 48 | "handlers": [ 49 | "wachter_telegram", 50 | "wachter_oltp", 51 | ], 52 | } 53 | }, 54 | } 55 | 56 | config.dictConfig(log_config) 57 | tg_logger = logging.getLogger("wachter_telegram_logger") 58 | -------------------------------------------------------------------------------- /src/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy import Column, Integer, Text, Boolean, BigInteger 3 | from sqlalchemy.ext.declarative import declarative_base 4 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 5 | from sqlalchemy.orm.session import sessionmaker 6 | from contextlib import asynccontextmanager 7 | 8 | from src import constants 9 | from src.texts import _ 10 | 11 | Base = declarative_base() 12 | 13 | 14 | class Chat(Base): 15 | __tablename__ = "chats" 16 | 17 | id = Column(BigInteger, primary_key=True) 18 | 19 | on_new_chat_member_message = Column( 20 | Text, 21 | nullable=False, 22 | ) 23 | on_known_new_chat_member_message = Column( 24 | Text, 25 | nullable=False, 26 | ) 27 | on_introduce_message = Column( 28 | Text, 29 | nullable=False, 30 | ) 31 | on_kick_message = Column( 32 | Text, 33 | nullable=False, 34 | ) 35 | notify_message = Column( 36 | Text, 37 | nullable=False, 38 | ) 39 | regex_filter = Column(Text, nullable=True) # keeping that in db for now, unused 40 | filter_only_new_users = Column( 41 | Boolean, nullable=False, default=False 42 | ) # keeping that in db for now, unused 43 | kick_timeout = Column( 44 | Integer, 45 | nullable=False, 46 | ) 47 | notify_timeout = Column( 48 | Integer, 49 | nullable=False, 50 | ) 51 | whois_length = Column( 52 | Integer, 53 | nullable=False, 54 | ) 55 | on_introduce_message_update = Column( 56 | Text, 57 | nullable=False, 58 | ) 59 | 60 | def __repr__(self): 61 | return f"" 62 | 63 | @classmethod 64 | def get_new_chat(cls, chat_id: int): 65 | chat = cls(id=chat_id) 66 | # write default values from texts 67 | chat.on_new_chat_member_message = _("msg__new_chat_member") 68 | chat.on_known_new_chat_member_message = _("msg__known_new_chat_member") 69 | chat.on_introduce_message = _("msg__introduce") 70 | chat.on_kick_message = _("msg__kick") 71 | chat.notify_message = _("msg__notify") 72 | chat.on_introduce_message_update = _("msg__introduce_update") 73 | 74 | chat.kick_timeout = constants.default_kick_timeout_m 75 | chat.notify_timeout = constants.default_notify_timeout_m 76 | chat.whois_length = constants.default_whois_length 77 | return chat 78 | 79 | 80 | class User(Base): 81 | __tablename__ = "users" 82 | 83 | user_id = Column(BigInteger, primary_key=True) 84 | chat_id = Column(BigInteger, primary_key=True) 85 | 86 | whois = Column(Text, nullable=False) 87 | 88 | 89 | engine = create_async_engine(constants.get_uri(), echo=False) 90 | AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) 91 | 92 | 93 | @asynccontextmanager 94 | async def session_scope(): 95 | async with AsyncSessionLocal() as session: 96 | try: 97 | yield session 98 | await session.commit() 99 | except: 100 | await session.rollback() 101 | raise 102 | finally: 103 | await session.close() 104 | 105 | 106 | def orm_to_dict(obj): 107 | return obj._asdict() 108 | -------------------------------------------------------------------------------- /src/texts.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | _texts = { 4 | "msg__set_new_message": "Обновил сообщение.", 5 | "msg__success_set_kick_timeout_response": "Обновил время до удаления.", 6 | "msg__sucess_set_notify_timeout_response": "Обновил время до напоминания.", 7 | "msg__failed_set_kick_timeout_response": "Время должно быть целым положительным числом.", 8 | "msg__failed_kick_response": "Я не справился.", 9 | "msg__start_command": "Выберите чат:", 10 | "msg__select_chat": "Выбран чат {chat_name}. Теперь выберите действие:", 11 | "msg__help": """Привет! Я - бот Вахтер. Я слежу, чтобы в твоем чате были только представившиеся пользователи. Для начала работы добавь меня в чат и сделай меня администратором. 12 | После этого для настройки бота тебе нужно написать мне в личных сообщениях /start. 13 | По умолчанию я не удаляю из чата непредставившихся, а лишь записываю все сообщения с хэштегом #whois. 14 | Если ты хочешь автоматически удалять непредставившихся, то установи время ожидания до удаления из чата в значение больше нуля (в минутах). 15 | По умолчанию за 10 минут до удаления я отправляю сообщение с напоминанием.""", 16 | "msg__add_bot_to_chat": "Привет, я Вахтёр. Я буду следить за тем, чтобы все люди в чате были представившимися. Дайте мне админские права, чтобы я мог это делать.", 17 | "msg__make_admin": "Спасибо, теперь я могу видеть сообщения. Пожалуйста, представьтесь, используя хэштег #whois.", 18 | "msg__make_admin_direct": "Есть новый чат {chat_name}", 19 | "msg__new_chat_member": "Добро пожаловать! Пожалуйста, представьтесь с использованием хэштега #whois и поздоровайтесь с сообществом.", 20 | "msg__known_new_chat_member": "Добро пожаловать снова!", 21 | "msg__introduce": "Спасибо и добро пожаловать!", 22 | "msg__kick": "%USER\_MENTION% не представился и покидает чат.", 23 | "msg__notify": "%USER\_MENTION%, пожалуйста, представьтесь с использованием хэштега #whois.", 24 | "msg__introduce_update": "%USER\_MENTION%, если вы хотите обновить существующий #whois, пожалуйста добавьте тег #update к сообщению.", 25 | "msg__no_chats_available": "У вас нет доступных чатов.", 26 | "msg__sucess_whois_length": "Обновил необходимую длину #whois.", 27 | "msg__failed_whois_response": "Длина должна быть целым положительным числом.", 28 | "msg__need_hashtag_update_response": "Сообщение должно содержать #update.", 29 | "btn__intro": "Приветствия", 30 | "btn__kicks": "Удаление и блокировка", 31 | "btn__back_to_chats": "Назад к списку чатов", 32 | "btn__current_settings": "Посмотреть текущие настройки", 33 | "btn__change_welcome_message": "Изменить сообщение при входе в чат", 34 | "btn__change_rewelcome_message": "Изменить сообщение при перезаходе в чат", 35 | "btn__change_notify_message": "Изменить сообщение напоминания", 36 | "btn__change_sucess_message": "Изменить сообщение после представления", 37 | "btn__change_notify_timeout": "Изменить время напоминания", 38 | "btn__change_whois_length": "Изменить необходимую длину #whois", 39 | "btn__change_whois_message": "Изменить сообщение для обновления #whois", 40 | "btn__back": "Назад", 41 | "btn__change_kick_timeout": "Изменить время до удаления", 42 | "btn__change_kick_message": "Изменить сообщение после удаления", 43 | "msg__set_new_welcome_message": "Отправьте новый текст сообщения при входе в чат. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 44 | "msg__set_new_kick_timout": "Отправьте новое время до удаления в минутах", 45 | "msg__set_new_rewelcome_message": "Отправьте новый текст сообщения при перезаходе в чат. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 46 | "msg__set_new_notify_message": "Отправьте новый текст сообщения напоминания. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 47 | "msg__set_new_sucess_message": "Отправьте новый текст сообщения после представления. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 48 | "msg__set_new_whois_length": "Отправьте новую необходимую длину #whois (количество символов).", 49 | "msg__set_new_kick_message": "Отправьте новый текст сообщения после удаления. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 50 | "msg__set_new_notify_timeout": "Отправьте новое время до напоминания в минутах.", 51 | "msg__set_new_whois_message": "Отправьте новый текст сообщения для обновления #whois (должно содержать хэштег #update). Используйте `%USER_MENTION%`, чтобы тегнуть адресата.", 52 | "msg__get_intro_settings": """ 53 | Выбран чат {chat_name}. 54 | --- 55 | Сообщение для нового участника чата: `{on_new_chat_member_message}` 56 | --- 57 | Сообщение при перезаходе в чат: `{on_known_new_chat_member_message}` 58 | --- 59 | Сообщение после успешного представления: `{on_introduce_message}` 60 | --- 61 | Сообщение напоминания: `{notify_message}` 62 | --- 63 | Необходимая длина представления с хэштегом #whois для новых пользователей: {whois_length} 64 | --- 65 | Время до напоминания в минутах (целое положительное число): {notify_timeout} 66 | --- 67 | Сообщение для обновления информации в #whois: `{on_introduce_message_update}` 68 | """, 69 | "msg__get_kick_settings": """ 70 | Выбран чат {chat_name}. 71 | --- 72 | Время до удаления в минутах (целое положительное число): {kick_timeout} 73 | --- 74 | Сообщение после удаления: `{on_kick_message}` 75 | """, 76 | "msg__short_whois": "%USER\_MENTION%, напишите про себя побольше, хотя бы {whois_length} символов. Спасибо!", 77 | "msg__skip_new_chat_member": "%SKIP%", 78 | } 79 | 80 | def escape_markdown(text): 81 | """ 82 | Escapes special characters in a Markdown string to prevent Markdown rendering issues, 83 | excluding text within curly brackets. 84 | 85 | Args: 86 | text (str): The input string that may contain special Markdown characters. 87 | 88 | Returns: 89 | str: A string with special Markdown characters escaped, excluding text within curly brackets. 90 | """ 91 | # Regex to find text outside curly brackets 92 | def escape_outside_braces(match): 93 | text_outside = match.group(1) 94 | if text_outside: 95 | # Escape special characters in text outside curly brackets 96 | special_characters = r"([\\`*_{}\[\]()#+\-.!|>~^])" 97 | return re.sub(special_characters, r"\\\1", text_outside) 98 | return match.group(0) 99 | 100 | # Match and process text outside curly brackets 101 | escaped_text = re.sub(r"([^{}]+(?=\{)|(?<=\})([^{}]+)|^[^{]+|[^}]+$)", escape_outside_braces, text) 102 | return escaped_text 103 | 104 | def _(text): 105 | """ 106 | Retrieve and escape a predefined message text based on a unique key. 107 | 108 | Args: 109 | text (str): A unique key representing the desired message. 110 | 111 | Returns: 112 | str: The escaped message text associated with the input key, or None if not found. 113 | """ 114 | return _texts.get(text) 115 | # if raw_message is not None: 116 | # return escape_markdown(raw_message) 117 | # return None 118 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import pytest, pytest_asyncio, json 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | from sqlalchemy import text 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | with pytest.MonkeyPatch().context() as ctx: 10 | 11 | def mock_get_uri(): 12 | return "sqlite+aiosqlite:///:memory:?cache=shared" 13 | 14 | ctx.setattr("src.constants.get_uri", mock_get_uri) 15 | from src import constants 16 | from src.model import engine, User, Chat 17 | 18 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) 19 | 20 | 21 | @pytest.fixture 22 | def mock_update(): 23 | callback_query = AsyncMock() 24 | callback_query.data = json.dumps({"action": constants.Actions.start_select_chat}) 25 | 26 | message_mock = MagicMock() 27 | message_mock.chat_id = 12345 28 | message_mock.message_id = 67890 29 | message_mock.reply_text = AsyncMock() 30 | 31 | update = AsyncMock() 32 | update.callback_query = callback_query 33 | update.effective_message = message_mock 34 | return update 35 | 36 | 37 | @pytest.fixture 38 | def mock_context(): 39 | context = AsyncMock() 40 | bot_mock = AsyncMock() 41 | bot_mock.edit_message_text = AsyncMock() 42 | context.job_queue.run_once = MagicMock() 43 | context.bot = bot_mock 44 | return context 45 | 46 | 47 | # Fixture to set up an in-memory SQLite database 48 | @pytest_asyncio.fixture(scope="function") 49 | async def async_engine(): 50 | async with engine.begin() as conn: 51 | await conn.execute(text("DROP TABLE IF EXISTS users;")) 52 | await conn.execute(text("DROP TABLE IF EXISTS chats;")) 53 | await conn.execute( 54 | text( 55 | """ 56 | CREATE TABLE chats ( 57 | id INTEGER PRIMARY KEY, 58 | on_new_chat_member_message TEXT NOT NULL, 59 | on_known_new_chat_member_message TEXT NOT NULL, 60 | on_introduce_message TEXT NOT NULL, 61 | on_kick_message TEXT NOT NULL, 62 | notify_message TEXT NOT NULL, 63 | regex_filter TEXT, 64 | filter_only_new_users BOOLEAN NOT NULL DEFAULT FALSE, 65 | kick_timeout INTEGER NOT NULL, 66 | notify_timeout INTEGER NOT NULL, 67 | whois_length INTEGER NOT NULL, 68 | on_introduce_message_update TEXT NOT NULL 69 | ); 70 | """ 71 | ) 72 | ) 73 | await conn.execute( 74 | text( 75 | """ 76 | CREATE TABLE users ( 77 | user_id INTEGER, 78 | chat_id INTEGER, 79 | whois TEXT NOT NULL, 80 | PRIMARY KEY (user_id, chat_id) 81 | ); 82 | """ 83 | ) 84 | ) 85 | return engine 86 | 87 | 88 | # Fixture for creating a new session for each test 89 | @pytest_asyncio.fixture(scope="function") 90 | async def async_session(async_engine): 91 | async_session_local = sessionmaker( 92 | async_engine, class_=AsyncSession, expire_on_commit=False 93 | ) 94 | async with async_session_local() as session: 95 | yield session 96 | 97 | 98 | @pytest_asyncio.fixture 99 | async def populate_db(async_session): 100 | # Define user data 101 | user_data = [ 102 | (1, 1, "User 1 in Chat 1"), 103 | (1, 2, "User 1 in Chat 2"), 104 | (2, 2, "User 2 in Chat 2"), 105 | (2, 3, "User 2 in Chat 3"), 106 | (3, None, "User 3, no Chats"), 107 | ] 108 | 109 | # Define chat data 110 | chat_data = [ 111 | ( 112 | 1, 113 | "Welcome to Chat 1", 114 | "Message for known members in Chat 1", 115 | "Introduce in Chat 1", 116 | "Kick message in Chat 1", 117 | "Notify message in Chat 1", 118 | None, 119 | False, 120 | 30, 121 | 60, 122 | 100, 123 | "Update Introduce in Chat 1", 124 | ), 125 | ( 126 | 2, 127 | "Welcome to Chat 2", 128 | "Message for known members in Chat 2", 129 | "Introduce in Chat 2", 130 | "Kick message in Chat 2", 131 | "Notify message in Chat 2", 132 | None, 133 | False, 134 | 30, 135 | 60, 136 | 100, 137 | "Update Introduce in Chat 2", 138 | ), 139 | ( 140 | -3, 141 | "Welcome to Chat 2", 142 | "Message for known members in Chat 2", 143 | "Introduce in Chat 2", 144 | "Kick message in Chat 2", 145 | "Notify message in Chat 2", 146 | None, 147 | False, 148 | 30, 149 | 60, 150 | 100, 151 | "Update Introduce in Chat 2", 152 | ), 153 | ] 154 | 155 | # Create objects using list comprehension 156 | users = [ 157 | User(user_id=uid, chat_id=cid, whois=whois) for uid, cid, whois in user_data 158 | ] 159 | 160 | chats = [ 161 | Chat( 162 | id=cid, 163 | on_new_chat_member_message=welcome, 164 | on_known_new_chat_member_message=known, 165 | on_introduce_message=introduce, 166 | on_kick_message=kick, 167 | notify_message=notify, 168 | regex_filter=regex, 169 | filter_only_new_users=filter_new, 170 | kick_timeout=kick_timeout, 171 | notify_timeout=notify_timeout, 172 | whois_length=whois_length, 173 | on_introduce_message_update=introduce_update, 174 | ) 175 | for cid, welcome, known, introduce, kick, notify, regex, filter_new, kick_timeout, notify_timeout, whois_length, introduce_update in chat_data 176 | ] 177 | 178 | async_session.add_all(users + chats) 179 | 180 | await async_session.commit() 181 | -------------------------------------------------------------------------------- /test/group_handler_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import select 3 | from unittest.mock import patch 4 | import os 5 | 6 | with patch.dict( 7 | "os.environ", 8 | { 9 | "TELEGRAM_TOKEN": "dummy_token", 10 | "TELEGRAM_ERROR_CHAT_ID": "dummy_chat_id", 11 | "UPTRACE_DSN": "dummy_dsn", 12 | "DEPLOYMENT_ENVIRONMENT": "testing", 13 | }, 14 | ): 15 | from src.handlers.group.group_handler import on_hashtag_message 16 | from src.model import User, Chat 17 | from src.texts import _ 18 | 19 | from telegram.constants import ParseMode 20 | 21 | 22 | async def mock_mention_markdown(bot, chat_id, user_id, message): 23 | # Replace %USER_MENTION% with a default string 24 | return message.replace("%USER_MENTION%", "@example_user") 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_on_hashtag_message_new_user( 29 | mock_update, mock_context, async_session, populate_db, mocker 30 | ): 31 | # Simulate the Incoming Message 32 | chat_id = -3 # Example group chat ID (negative for groups) 33 | user_id = 3 # Example new user ID 34 | mock_update.effective_message.chat_id = chat_id 35 | mock_update.effective_message.from_user.id = user_id 36 | mock_update.effective_message.text = "#whois I am new here dkgjldskfjglkdfjglkdfsj lgkjdsflökgjldösfjglsdfjgölkdsfjglöksdjfglöksdfjglöksdfjg" 37 | 38 | mocker.patch("src.handlers.group.group_handler.is_whois", return_value=True) 39 | mocker.patch( 40 | "src.handlers.group.group_handler._mention_markdown", 41 | return_value="@example_user", 42 | ) 43 | mocker.patch( 44 | "src.handlers.group.group_handler.remove_user_jobs_from_queue", 45 | return_value=True, 46 | ) 47 | mocker.patch( 48 | "src.handlers.group.group_handler.whois_counter.add", 49 | return_value=True, 50 | ) 51 | 52 | await on_hashtag_message(mock_update, mock_context) 53 | 54 | # Check if the reply message was sent 55 | assert mock_update.effective_message.reply_text.await_count == 1 56 | 57 | # Verify that the new user is added to the database 58 | async with async_session as session: 59 | result = await session.execute( 60 | select(User).where(User.chat_id == chat_id, User.user_id == user_id) 61 | ) 62 | user = result.scalars().first() 63 | assert user is not None 64 | assert ( 65 | user.whois 66 | == "#whois I am new here dkgjldskfjglkdfjglkdfsj lgkjdsflökgjldösfjglsdfjgölkdsfjglöksdjfglöksdfjglöksdfjg" 67 | ) 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_on_hashtag_message_short_whois( 72 | mock_update, mock_context, async_session, populate_db, mocker 73 | ): 74 | # Simulate the Incoming Message 75 | chat_id = -3 # Example group chat ID (negative for groups) 76 | user_id = 3 # Example new user ID 77 | mock_update.effective_message.chat_id = chat_id 78 | mock_update.effective_message.from_user.id = user_id 79 | mock_update.effective_message.text = "#whois I am new here" 80 | 81 | mocker.patch("src.handlers.group.group_handler.is_whois", return_value=True) 82 | mocker.patch( 83 | "src.handlers.group.group_handler._mention_markdown", 84 | side_effect=mock_mention_markdown, 85 | ) 86 | 87 | await on_hashtag_message(mock_update, mock_context) 88 | 89 | async with async_session as session: 90 | try: 91 | result = await session.execute( 92 | select(Chat.whois_length).where(Chat.id == chat_id) 93 | ) 94 | whois_length = result.scalar_one() 95 | print(whois_length) 96 | except Exception as e: 97 | print(f"Error during query execution: {e}") 98 | raise 99 | print() 100 | expected_reply = _("msg__short_whois").format(whois_length=whois_length) 101 | # Fetch the actual reply text 102 | actual_reply_call = mock_update.effective_message.reply_text.call_args 103 | actual_reply = actual_reply_call[1]["text"] if actual_reply_call else None 104 | 105 | # Improved assertion with detailed error message 106 | assert ( 107 | actual_reply == expected_reply 108 | ), f"Assertion failed: Expected reply text '{expected_reply}' but got '{actual_reply}'." 109 | -------------------------------------------------------------------------------- /test/group_onboarding_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from unittest.mock import AsyncMock, call 4 | 5 | from src.handlers.group.my_chat_member_handler import my_chat_member_handler 6 | from src.texts import _ 7 | from src import constants 8 | 9 | from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_add_bot_to_chat(mock_context): 14 | mock_update = AsyncMock() 15 | mock_update.effective_chat.id = 1000 16 | mock_update.my_chat_member.difference = lambda: { 17 | "status": (ChatMember.LEFT, ChatMember.MEMBER) 18 | } 19 | await my_chat_member_handler(mock_update, mock_context) 20 | mock_context.bot.send_message.assert_awaited_once_with( 21 | 1000, _("msg__add_bot_to_chat") 22 | ) 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_make_bot_admin(mock_context, async_session): 27 | mock_update = AsyncMock() 28 | mock_update.effective_chat.id = 1000 29 | mock_update.effective_chat.title = "title" 30 | mock_update.effective_user.id = 1001 31 | mock_update.my_chat_member.difference = lambda: { 32 | "status": (ChatMember.MEMBER, ChatMember.ADMINISTRATOR) 33 | } 34 | await my_chat_member_handler(mock_update, mock_context) 35 | mock_context.bot.send_message.assert_has_calls( 36 | [ 37 | call( 38 | 1001, 39 | _("msg__make_admin_direct").format(chat_name="title"), 40 | reply_markup=InlineKeyboardMarkup( 41 | [ 42 | [ 43 | InlineKeyboardButton( 44 | "Приветствия", 45 | callback_data=json.dumps( 46 | { 47 | "chat_id": 1000, 48 | "action": constants.Actions.set_intro_settings, 49 | } 50 | ), 51 | ) 52 | ], 53 | [ 54 | InlineKeyboardButton( 55 | "Удаление и блокировка", 56 | callback_data=json.dumps( 57 | { 58 | "chat_id": 1000, 59 | "action": constants.Actions.set_kick_bans_settings, 60 | } 61 | ), 62 | ) 63 | ], 64 | [ 65 | InlineKeyboardButton( 66 | "Назад", 67 | callback_data=json.dumps( 68 | { 69 | "chat_id": 1000, 70 | "action": constants.Actions.back_to_chats, 71 | } 72 | ), 73 | ) 74 | ], 75 | ] 76 | ), 77 | ), 78 | call(1000, _("msg__make_admin")), 79 | ], 80 | any_order=True, 81 | ) 82 | -------------------------------------------------------------------------------- /test/menu_handler_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest, pytest_asyncio, asyncio 3 | 4 | # from conftest import function_scoped_event_loop, session_scoped_event_loop 5 | 6 | from src.handlers.admin.menu_handler import button_handler 7 | from src.texts import _ 8 | from src import constants 9 | 10 | 11 | from unittest.mock import patch 12 | 13 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 14 | 15 | from telegram.constants import ParseMode 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_button_handler_no_chats(mock_update, mock_context, populate_db): 20 | mock_update.callback_query.from_user.id = 3 21 | await button_handler(mock_update, mock_context) 22 | # Assert that the expected message was sent 23 | mock_update.message.reply_text.assert_awaited_once_with( 24 | _("msg__no_chats_available") 25 | ) 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_button_handler_basic_start(mock_update, mock_context, mocker): 30 | mock_update.callback_query.from_user.id = 1 31 | mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True) 32 | mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat") 33 | await button_handler(mock_update, mock_context) 34 | mock_context.bot.edit_message_text.assert_awaited_once_with( 35 | _("msg__start_command"), 36 | reply_markup=InlineKeyboardMarkup( 37 | [ 38 | [ 39 | InlineKeyboardButton( 40 | "Chat", 41 | callback_data=json.dumps( 42 | {"chat_id": 1, "action": constants.Actions.select_chat} 43 | ), 44 | ) 45 | ], 46 | [ 47 | InlineKeyboardButton( 48 | "Chat", 49 | callback_data=json.dumps( 50 | {"chat_id": 2, "action": constants.Actions.select_chat} 51 | ), 52 | ) 53 | ], 54 | ] 55 | ), 56 | chat_id=mock_update.callback_query.message.chat_id, 57 | message_id=mock_update.callback_query.message.message_id, 58 | ) 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_select_chat_action(mock_update, mock_context, mocker): 63 | # Set the data for the select_chat action 64 | action = constants.Actions.select_chat 65 | selected_chat_id = 1 66 | chat_name = "Chat" 67 | 68 | # Mock the data being received from the callback_query 69 | mock_update.callback_query.data = json.dumps( 70 | {"action": action, "chat_id": selected_chat_id} 71 | ) 72 | mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat") 73 | 74 | expected_keyboard = InlineKeyboardMarkup( 75 | [ 76 | [ 77 | InlineKeyboardButton( 78 | text=_("btn__intro"), 79 | callback_data=json.dumps( 80 | { 81 | "chat_id": selected_chat_id, 82 | "action": constants.Actions.set_intro_settings, 83 | } 84 | ), 85 | ) 86 | ], 87 | [ 88 | InlineKeyboardButton( 89 | text=_("btn__kicks"), 90 | callback_data=json.dumps( 91 | { 92 | "chat_id": selected_chat_id, 93 | "action": constants.Actions.set_kick_bans_settings, 94 | } 95 | ), 96 | ) 97 | ], 98 | [ 99 | InlineKeyboardButton( 100 | text=_("btn__back_to_chats"), 101 | callback_data=json.dumps( 102 | { 103 | "chat_id": selected_chat_id, 104 | "action": constants.Actions.back_to_chats, 105 | } 106 | ), 107 | ) 108 | ], 109 | ] 110 | ) 111 | 112 | await button_handler(mock_update, mock_context) 113 | actual_call = mock_context.bot.edit_message_text.call_args[ 114 | 1 115 | ] # This gets the kwargs of the last call 116 | assert actual_call["reply_markup"] == expected_keyboard 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_set_kick_bans_settings(mock_update, mock_context, mocker): 121 | action = constants.Actions.set_kick_bans_settings 122 | selected_chat_id = 1 123 | mock_update.callback_query.data = json.dumps( 124 | {"action": action, "chat_id": selected_chat_id} 125 | ) 126 | await button_handler(mock_update, mock_context) 127 | 128 | expected_keyboard = InlineKeyboardMarkup( 129 | inline_keyboard=[ 130 | [ 131 | InlineKeyboardButton( 132 | text=_("btn__current_settings"), 133 | callback_data=json.dumps( 134 | { 135 | "chat_id": selected_chat_id, 136 | "action": constants.Actions.get_current_kick_settings, 137 | } 138 | ), 139 | ) 140 | ], 141 | [ 142 | InlineKeyboardButton( 143 | text=_("btn__change_kick_timeout"), 144 | callback_data=json.dumps( 145 | { 146 | "chat_id": selected_chat_id, 147 | "action": constants.Actions.set_kick_timeout, 148 | } 149 | ), 150 | ) 151 | ], 152 | [ 153 | InlineKeyboardButton( 154 | text=_("btn__change_kick_message"), 155 | callback_data=json.dumps( 156 | { 157 | "chat_id": selected_chat_id, 158 | "action": constants.Actions.set_on_kick_message, 159 | } 160 | ), 161 | ) 162 | ], 163 | [ 164 | InlineKeyboardButton( 165 | text=_("btn__back"), 166 | callback_data=json.dumps( 167 | { 168 | "chat_id": selected_chat_id, 169 | "action": constants.Actions.select_chat, 170 | } 171 | ), 172 | ) 173 | ], 174 | ] 175 | ) 176 | 177 | mock_context.bot.edit_message_reply_markup.assert_awaited_once_with( 178 | reply_markup=expected_keyboard, 179 | chat_id=mock_update.callback_query.message.chat_id, 180 | message_id=mock_update.callback_query.message.message_id, 181 | ) 182 | 183 | 184 | @pytest.mark.asyncio 185 | async def test_back_to_chats(mock_update, mock_context, mocker): 186 | mock_update.callback_query.from_user.id = 1 187 | mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True) 188 | mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat") 189 | await button_handler(mock_update, mock_context) 190 | mock_context.bot.edit_message_text.assert_awaited_once_with( 191 | _("msg__start_command"), 192 | reply_markup=InlineKeyboardMarkup( 193 | [ 194 | [ 195 | InlineKeyboardButton( 196 | "Chat", 197 | callback_data=json.dumps( 198 | {"chat_id": 1, "action": constants.Actions.select_chat} 199 | ), 200 | ) 201 | ], 202 | [ 203 | InlineKeyboardButton( 204 | "Chat", 205 | callback_data=json.dumps( 206 | {"chat_id": 2, "action": constants.Actions.select_chat} 207 | ), 208 | ) 209 | ], 210 | ] 211 | ), 212 | chat_id=mock_update.callback_query.message.chat_id, 213 | message_id=mock_update.callback_query.message.message_id, 214 | ) 215 | 216 | 217 | @pytest.mark.asyncio 218 | async def test_set_on_new_chat_member_message_response( 219 | mock_update, mock_context, mocker 220 | ): 221 | action = constants.Actions.set_on_new_chat_member_message_response 222 | selected_chat_id = 1 223 | mock_update.callback_query.data = json.dumps( 224 | {"action": action, "chat_id": selected_chat_id} 225 | ) 226 | 227 | await button_handler(mock_update, mock_context) 228 | 229 | mock_context.bot.edit_message_text.assert_awaited_once_with( 230 | text=_("msg__set_new_welcome_message"), 231 | chat_id=mock_update.callback_query.message.chat_id, 232 | message_id=mock_update.callback_query.message.message_id, 233 | parse_mode=ParseMode.MARKDOWN, 234 | ) 235 | 236 | 237 | @pytest.mark.asyncio 238 | async def test_set_kick_timeout(mock_update, mock_context, mocker): 239 | action = constants.Actions.set_kick_timeout 240 | selected_chat_id = 1 241 | mock_update.callback_query.data = json.dumps( 242 | {"action": action, "chat_id": selected_chat_id} 243 | ) 244 | 245 | await button_handler(mock_update, mock_context) 246 | 247 | mock_context.bot.edit_message_text.assert_awaited_once_with( 248 | text=_("msg__set_new_kick_timout"), 249 | chat_id=mock_update.callback_query.message.chat_id, 250 | message_id=mock_update.callback_query.message.message_id, 251 | ) 252 | 253 | 254 | @pytest.mark.asyncio 255 | async def test_set_on_known_new_chat_member_message_response(mock_update, mock_context): 256 | action = constants.Actions.set_on_known_new_chat_member_message_response 257 | selected_chat_id = 1 258 | mock_update.callback_query.data = json.dumps( 259 | {"action": action, "chat_id": selected_chat_id} 260 | ) 261 | 262 | await button_handler(mock_update, mock_context) 263 | 264 | mock_context.bot.edit_message_text.assert_awaited_once_with( 265 | text=_("msg__set_new_rewelcome_message"), 266 | chat_id=mock_update.callback_query.message.chat_id, 267 | message_id=mock_update.callback_query.message.message_id, 268 | parse_mode=ParseMode.MARKDOWN, 269 | ) 270 | 271 | 272 | @pytest.mark.asyncio 273 | async def test_set_new_notify_message(mock_update, mock_context, mocker): 274 | action = constants.Actions.set_notify_message 275 | selected_chat_id = 1 276 | mock_update.callback_query.data = json.dumps( 277 | {"action": action, "chat_id": selected_chat_id} 278 | ) 279 | 280 | await button_handler(mock_update, mock_context) 281 | 282 | mock_context.bot.edit_message_text.assert_awaited_once_with( 283 | text=_("msg__set_new_notify_message"), 284 | chat_id=mock_update.callback_query.message.chat_id, 285 | message_id=mock_update.callback_query.message.message_id, 286 | parse_mode=ParseMode.MARKDOWN, 287 | ) 288 | 289 | 290 | @pytest.mark.asyncio 291 | async def test_set_on_new_chat_member_message_response( 292 | mock_update, mock_context, mocker 293 | ): 294 | action = constants.Actions.set_on_new_chat_member_message_response 295 | selected_chat_id = 1 296 | mock_update.callback_query.data = json.dumps( 297 | {"action": action, "chat_id": selected_chat_id} 298 | ) 299 | 300 | await button_handler(mock_update, mock_context) 301 | 302 | mock_context.bot.edit_message_text.assert_awaited_once_with( 303 | text=_("msg__set_new_welcome_message"), 304 | chat_id=mock_update.callback_query.message.chat_id, 305 | message_id=mock_update.callback_query.message.message_id, 306 | parse_mode=ParseMode.MARKDOWN, 307 | ) 308 | 309 | 310 | @pytest.mark.asyncio 311 | async def test_set_on_successful_introducion_response( 312 | mock_update, mock_context, mocker 313 | ): 314 | action = constants.Actions.set_on_successful_introducion_response 315 | selected_chat_id = 1 316 | mock_update.callback_query.data = json.dumps( 317 | {"action": action, "chat_id": selected_chat_id} 318 | ) 319 | 320 | await button_handler(mock_update, mock_context) 321 | 322 | mock_context.bot.edit_message_text.assert_awaited_once_with( 323 | text=_("msg__set_new_sucess_message"), 324 | chat_id=mock_update.callback_query.message.chat_id, 325 | message_id=mock_update.callback_query.message.message_id, 326 | parse_mode=ParseMode.MARKDOWN, 327 | ) 328 | 329 | 330 | @pytest.mark.asyncio 331 | async def test_set_whois_length(mock_update, mock_context, mocker): 332 | action = constants.Actions.set_whois_length 333 | selected_chat_id = 1 334 | mock_update.callback_query.data = json.dumps( 335 | {"action": action, "chat_id": selected_chat_id} 336 | ) 337 | 338 | await button_handler(mock_update, mock_context) 339 | 340 | mock_context.bot.edit_message_text.assert_awaited_once_with( 341 | text=_("msg__set_new_whois_length"), 342 | chat_id=mock_update.callback_query.message.chat_id, 343 | message_id=mock_update.callback_query.message.message_id, 344 | ) 345 | 346 | 347 | @pytest.mark.asyncio 348 | async def test_set_on_kick_message(mock_update, mock_context, mocker): 349 | action = constants.Actions.set_on_kick_message 350 | selected_chat_id = 1 351 | mock_update.callback_query.data = json.dumps( 352 | {"action": action, "chat_id": selected_chat_id} 353 | ) 354 | 355 | await button_handler(mock_update, mock_context) 356 | 357 | mock_context.bot.edit_message_text.assert_awaited_once_with( 358 | text=_("msg__set_new_kick_message"), 359 | chat_id=mock_update.callback_query.message.chat_id, 360 | message_id=mock_update.callback_query.message.message_id, 361 | parse_mode=ParseMode.MARKDOWN, 362 | ) 363 | 364 | 365 | @pytest.mark.asyncio 366 | async def test_set_notify_timeout(mock_update, mock_context, mocker): 367 | action = constants.Actions.set_notify_timeout 368 | selected_chat_id = 1 369 | mock_update.callback_query.data = json.dumps( 370 | {"action": action, "chat_id": selected_chat_id} 371 | ) 372 | 373 | await button_handler(mock_update, mock_context) 374 | 375 | mock_context.bot.edit_message_text.assert_awaited_once_with( 376 | text=_("msg__set_new_notify_timeout"), 377 | chat_id=mock_update.callback_query.message.chat_id, 378 | message_id=mock_update.callback_query.message.message_id, 379 | ) 380 | 381 | 382 | @pytest.mark.asyncio 383 | async def test_set_on_introduce_message_update(mock_update, mock_context, mocker): 384 | action = constants.Actions.set_on_introduce_message_update 385 | selected_chat_id = 1 386 | mock_update.callback_query.data = json.dumps( 387 | {"action": action, "chat_id": selected_chat_id} 388 | ) 389 | 390 | await button_handler(mock_update, mock_context) 391 | 392 | mock_context.bot.edit_message_text.assert_awaited_once_with( 393 | text=_("msg__set_new_whois_message"), 394 | chat_id=mock_update.callback_query.message.chat_id, 395 | message_id=mock_update.callback_query.message.message_id, 396 | parse_mode=ParseMode.MARKDOWN, 397 | ) 398 | -------------------------------------------------------------------------------- /test/start_handler_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from unittest.mock import patch 4 | 5 | from src.handlers.admin.start_handler import start_handler 6 | from src import constants 7 | from src.texts import _ 8 | 9 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_start_handler_no_chats(mock_update, mock_context, populate_db): 14 | mock_update.message.chat_id = 3 15 | await start_handler(mock_update, mock_context) 16 | mock_update.message.reply_text.assert_awaited_once_with( 17 | _("msg__no_chats_available") 18 | ) 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_start_handler_basic(mock_update, mock_context, mocker): 23 | mock_update.message.chat_id = 1 24 | mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True) 25 | mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat") 26 | await start_handler(mock_update, mock_context) 27 | mock_update.message.reply_text.assert_awaited_once_with( 28 | _("msg__start_command"), 29 | reply_markup=InlineKeyboardMarkup( 30 | inline_keyboard=( 31 | ( 32 | InlineKeyboardButton( 33 | callback_data='{"chat_id": 1, "action": 2}', text="Chat" 34 | ), 35 | ), 36 | ( 37 | InlineKeyboardButton( 38 | callback_data='{"chat_id": 2, "action": 2}', text="Chat" 39 | ), 40 | ), 41 | ) 42 | ), 43 | ) 44 | --------------------------------------------------------------------------------