├── .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 | [](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_production.yml) [](https://github.com/alexeyqu/wachter_bot/actions/workflows/publish_testing.yml)
4 |
5 | [Вахтёр Бот](https://t.me/wachter_bot)
6 |
7 |
8 | 
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"