From 50b75946c09488cf0ef9ba65763513fc2aa3b1cc Mon Sep 17 00:00:00 2001 From: NimaQu Date: Tue, 14 Jul 2026 01:24:01 -0500 Subject: [PATCH 1/2] refactor code --- .github/workflows/quality.yml | 42 + .gitignore | 10 +- Dockerfile | 13 - README.md | 165 ++- Timer/__init__.py | 26 - ai.py | 91 -- alembic.ini | 1 + alembic/env.py | 17 +- .../versions/13a9421b19d9_add_challenge_id.py | 89 +- .../7f4c2a9d5e10_async_schema_cleanup.py | 64 + alembic/versions/b841fd90a794_remove.py | 17 +- .../versions/c945fe00daeb_change_ip_length.py | 41 +- challenge/autokickcache.py | 2 - challenge/math.py | 108 -- challenge/recaptcha.py | 51 - challengedata.py | 77 -- config.example.ini | 25 - config.example.json | 42 - config.example.toml | 65 + db.py | 87 -- dbhelper.py | 212 ---- docker-run.sh | 33 - example.service | 19 + exapmle.service | 17 - main.py | 1114 +---------------- model.py | 116 -- pyproject.toml | 68 +- requirements.txt | Bin 362 -> 0 bytes src/captcha_bot/__init__.py | 3 + src/captcha_bot/__main__.py | 4 + src/captcha_bot/app.py | 85 ++ src/captcha_bot/challenges.py | 190 +++ src/captcha_bot/cli.py | 97 ++ src/captcha_bot/config.py | 342 +++++ src/captcha_bot/context.py | 23 + src/captcha_bot/db/__init__.py | 4 + src/captcha_bot/db/importer.py | 53 + src/captcha_bot/db/models.py | 78 ++ src/captcha_bot/db/repository.py | 267 ++++ src/captcha_bot/handlers/__init__.py | 4 + src/captcha_bot/handlers/admin.py | 283 +++++ src/captcha_bot/handlers/challenge.py | 488 ++++++++ src/captcha_bot/handlers/common.py | 111 ++ src/captcha_bot/services.py | 72 ++ src/captcha_bot/web.py | 171 +++ templates/index.html | 2 +- templates/recaptcha.html | 16 +- templates/result.html | 20 +- test.py | 47 - tests/conftest.py | 12 + tests/test_challenges.py | 68 + tests/test_cli_app.py | 83 ++ tests/test_common.py | 12 + tests/test_config.py | 154 +++ tests/test_database_integration.py | 139 ++ tests/test_handlers.py | 391 ++++++ tests/test_importer.py | 47 + tests/test_repository_unit.py | 128 ++ tests/test_services.py | 51 + tests/test_web.py | 140 +++ uv.lock | 878 +++++++++---- web.py | 162 --- 62 files changed, 4622 insertions(+), 2615 deletions(-) create mode 100644 .github/workflows/quality.yml delete mode 100644 Dockerfile delete mode 100644 Timer/__init__.py delete mode 100644 ai.py create mode 100644 alembic/versions/7f4c2a9d5e10_async_schema_cleanup.py delete mode 100644 challenge/autokickcache.py delete mode 100644 challenge/math.py delete mode 100644 challenge/recaptcha.py delete mode 100644 challengedata.py delete mode 100644 config.example.ini delete mode 100644 config.example.json create mode 100644 config.example.toml delete mode 100644 db.py delete mode 100644 dbhelper.py delete mode 100644 docker-run.sh create mode 100644 example.service delete mode 100644 exapmle.service delete mode 100644 model.py delete mode 100644 requirements.txt create mode 100644 src/captcha_bot/__init__.py create mode 100644 src/captcha_bot/__main__.py create mode 100644 src/captcha_bot/app.py create mode 100644 src/captcha_bot/challenges.py create mode 100644 src/captcha_bot/cli.py create mode 100644 src/captcha_bot/config.py create mode 100644 src/captcha_bot/context.py create mode 100644 src/captcha_bot/db/__init__.py create mode 100644 src/captcha_bot/db/importer.py create mode 100644 src/captcha_bot/db/models.py create mode 100644 src/captcha_bot/db/repository.py create mode 100644 src/captcha_bot/handlers/__init__.py create mode 100644 src/captcha_bot/handlers/admin.py create mode 100644 src/captcha_bot/handlers/challenge.py create mode 100644 src/captcha_bot/handlers/common.py create mode 100644 src/captcha_bot/services.py create mode 100644 src/captcha_bot/web.py delete mode 100644 test.py create mode 100644 tests/conftest.py create mode 100644 tests/test_challenges.py create mode 100644 tests/test_cli_app.py create mode 100644 tests/test_common.py create mode 100644 tests/test_config.py create mode 100644 tests/test_database_integration.py create mode 100644 tests/test_handlers.py create mode 100644 tests/test_importer.py create mode 100644 tests/test_repository_unit.py create mode 100644 tests/test_services.py create mode 100644 tests/test_web.py delete mode 100644 web.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..ea33871 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,42 @@ +name: quality + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: captcha + POSTGRES_PASSWORD: replace-me + POSTGRES_DB: captcha + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U captcha -d captcha" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + CAPTCHA_BOT_CONFIG: config.example.toml + TEST_DATABASE_URL: postgresql+psycopg://captcha:replace-me@127.0.0.1:5432/captcha + + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8 + with: + enable-cache: true + python-version: "3.14" + - run: uv sync --locked --all-groups + - run: uv run ruff format --check . + - run: uv run ruff check . + - run: uv run ty check src main.py tests + - run: uv run pytest + - run: uv run alembic upgrade head + - run: uv run alembic current + - run: uv run alembic check + - run: uv run pip-audit diff --git a/.gitignore b/.gitignore index 4672528..7f75acc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # config files config.json config.ini +config.toml # Byte-compiled / optimized / DLL files __pycache__/ @@ -18,4 +19,11 @@ __pycache__/ data.sqlite #venv -venv \ No newline at end of file +venv +.venv + +# Tool caches +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index bf45e8a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3-alpine -RUN apk add \ - git \ - gcc \ - musl-dev -RUN mkdir -p /tg-captcha/workdir -RUN git clone -b master https://github.com/Tooruchan/Telegram-CAPTCHA-bot-pyrogram.git /tg-captcha/workdir -WORKDIR /tg-captcha/workdir -RUN python -m pip install virtualenv -RUN python -m virtualenv venv -RUN source venv/bin/activate; /tg-captcha/workdir/venv/bin/python -m pip install --upgrade pip; pip install --upgrade tgcrypto configparser pyrogram -RUN ["chmod", "+x", "/tg-captcha/workdir/docker-run.sh"] -CMD ["sh","docker-run.sh"] diff --git a/README.md b/README.md index 034b237..19a6ed0 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,143 @@ -# Telegram-CAPTCHA-bot +# Telegram CAPTCHA Bot -一个用于验证新成员是不是真人的bot。 +基于 Kurigram、FastAPI 和 PostgreSQL 的异步 Telegram 入群验证机器人。支持普通入群与申请入群、数学题与 Cloudflare Turnstile、人工通过/拒绝、超时处理、全局黑名单及验证日志/IP 管理。 -[![GNU Public License Affero 3.0](https://img.shields.io/badge/license-AGPL3.0-%23373737.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html) [![Python 3.11](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org) [![Pyrogram](https://img.shields.io/badge/Pyrogram-asyncio-green.svg)](https://github.com/pyrogram/pyrogram/) +本版本固定使用 **Python 3.14**,依赖只由 `pyproject.toml` 与 `uv.lock` 管理。配置只读取 TOML;旧 INI/JSON 配置需要先通过迁移命令转换。 -A bot running on Telegram which will send CAPTCHA to verify if the new member is a human. +## 准备 -基于[原始项目](https://github.com/Tooruchan/Telegram-CAPTCHA-bot-pyrogram)修改 +需要: -Forked based on [Original Repository](https://github.com/TooruchanTelegram-CAPTCHA-bot-pyrogram) +- Python 3.14 +- [uv](https://docs.astral.sh/uv/) +- PostgreSQL +- Telegram Bot Token、API ID 与 API Hash +- Cloudflare Turnstile site key 与 secret key -修改者:[@kkren](https://www.kkren.me/)(新 API 支持) +安装锁定依赖: -Bot实例: [@FubukiAntiSpamBot](https://t.me/FubukiAntiSpamBot) -日志频道:[@FAS_bot_log](https://t.me/FAS_bot_log) +```bash +uv sync --locked +cp config.example.toml config.toml +chmod 600 config.toml +``` -## 一些简单的Q&A +编辑 `config.toml` 中的 `[bot]`、`[database]`、`[web]`、`[proxy]`、`[turnstile]`、`[defaults]` 和 `[messages]`。数据库 URL 必须使用 PostgreSQL,例如: + +```toml +[database] +url = "postgresql+psycopg://captcha:password@127.0.0.1/captcha" +``` -Q: 被误加入黑名单了,怎么解除? +配置会在启动前严格校验:ID 和端口类型、正数范围、动作/验证类型枚举、HTTP URL、可信代理 CIDR 及消息占位符均不合法时拒绝启动。Token、API Hash、数据库 URL 和 Turnstile secret 在模型日志中会被隐藏。 -A: 当前此 bot 实例只会进行踢出群操作,只需要再次加入你想加入的群,如果被自动踢出,在1分钟之后10分钟之内再次加入即可进入。 -## 原理 +## 从旧配置迁移 -本 Bot 通过读取 Telegram API 中返回的入群消息来识别新用户入群,并生成一道随机问题对用户进行验证,非严格模式只要有回答问题就通过;严格模式下回答错误将会被移除或者封禁,这个验证的效果目前无法绕过具有人工操作的广告机器人,但是可以对外语(如阿拉伯语和波斯语)类骚扰用户起到一定的拦截作用。再此基础上增加了全局黑名单功能,只有验证超时的广告机器人会自动加入到[黑名单](https://ca.oracle.db.nimaqu.com/view/phpliteadmin.php?database=.%2Fdb%2Fdata.sqlite&table=user&fulltexts=0&numRows=30&action=row_view),之后在任意有此 bot 的群组加群将会被自动踢出,对误封禁的影响造成最低。 +```bash +uv run captcha-bot migrate-config \ + --ini config.ini \ + --json config.json \ + --output config.toml +``` -Telegram Bot API 使用了基于 MTProto 框架的 pyrogram,多线程使用了 asyncio。 +迁移命令不会覆盖已有输出文件,不会输出秘密,并会报告已删除的遗留字段。POSIX 系统上的新文件权限会设置为 `0600`。运行时不再读取 INI 或 JSON。 -## 安装与使用 +## 数据库升级与启动 -此仓库仅为遵守 AGPL 协议进行代码展示,并未对用户部署使用进行优化,普通用户建议直接使用[原始项目](https://github.com/Tooruchan/Telegram-CAPTCHA-bot-pyrogram) 如果你仍想使用此 Bot,请参照下面说明进行部署。 +首次部署或每次更新代码后,必须先显式升级数据库;应用不会自动修改生产库: -Python 要求: **最低 python 版本 3.9 ,建议 python 版本: 3.11** -1. 请先向 [@BotFather](https://t.me/botfather) 申请一个 Bot API Token -> 你申请到的机器人会在你的 Telegram 账号所在数据中心上运行(即申请机器人的账号A位于 DC 5 (新加坡),则 A 申请到的机器人也将会在 DC5 上运行) -2. 在 [Obtaining Telegram API ID](https://core.telegram.org/api/obtaining_api_id) 申请 API ID 与 API Hash -3. 使用 Python Virtual Environment 部署: +```bash +CAPTCHA_BOT_CONFIG=config.toml uv run alembic upgrade head +CAPTCHA_BOT_CONFIG=config.toml uv run alembic current +uv run captcha-bot run --config config.toml ``` -# 若未安装pip3,请先安装 python3-pip -apt update && apt install -y python3-pip python3 -git clone https://github.com/NimaQu/Telegram-CAPTCHA-bot-pyrogram.git -cd Telegram-CAPTCHA-bot-pyrogram -python3 -m venv venv -venv/bin/pip install wheel -venv/bin/pip install -r requirements.txt + +兼容入口仍然可用: + +```bash +uv run python main.py +``` + +Web 服务保留以下接口: + +- `GET /`:状态页,缓存五分钟 +- `GET|POST /recaptcha?challenge=...`:Turnstile 验证页,禁止缓存 +- `GET /healthz`:数据库和 Telegram client 健康状态 + +只有直接连接来源属于 `[web].trusted_proxy_cidrs` 时,服务才接受 `CF-Connecting-IP`。默认只信任本机反向代理;直接暴露 Uvicorn 时应按实际网络结构调整,不要信任任意公网 CIDR。 + +## 配置热重载 + +Telegram 中的 `/reload` 会先完整校验 TOML。仅 `[defaults]` 与 `[messages]` 可以热更新;Bot 身份、数据库、Web、代理或 Turnstile 等启动级配置变化时会拒绝重载并提示重启,旧配置继续生效。 + +群管理员可继续使用原语法设置群策略: + +```text +/faset challenge_timeout 120 +/faset challenge_type recaptcha +/faset challenge_failed_action kick +/faset challenge_timeout_action ban +/faset enable_global_blacklist true ``` -4. 将项目文件夹中 auth.ini 里的 token 字段(与等号间存在一个空格)修改为你在 [@BotFather](https://t.me/botfather) 获取到的 API Token,api_hash 和 api_id 修改为你在步骤2中获得的两串内容,其中 API ID 为数字,而 API Hash 为一组字符,你也可以对 config.json 里的内容酌情修改。 +首次设置由 PostgreSQL upsert 原子写入并立即生效。 + +## 旧 CSV 导入 -有关填写字段说明: +目标库必须已经升级到当前 Alembic head: -`channel`: Bot 日志记录频道 Chat ID (-100 开头),未填写将会导致无法正常工作(这是一个 bug,等待修复)。 +```bash +uv run captcha-bot import-csv group_config.csv --table group-config --config config.toml +uv run captcha-bot import-csv blacklist_user.csv --table blacklist-user --config config.toml +``` -`admin`: 管理用户 User ID,不填写则`/leave`和`/reload`指令无效。 +## systemd -5. 使用 `venv/bin/python` 直接运行这个 bot,或者在 `/etc/systemd/system/ `下新建一个 .service 文件,使用 systemd 控制这个bot的运行,配置文件示例请参考本项目目录下的 `example.service` 文件进行修改。 +复制并修改仓库中的 `example.service`: ```bash -cp example.service /etc/systemd/system/captchabot.service -nano captchabot.service -#编辑参数 -systemctl start captchabot -#启动 -systemctl enable captchabot -#开机自启 +sudo cp example.service /etc/systemd/system/captchabot.service +sudo systemctl daemon-reload +sudo systemctl enable --now captchabot.service +sudo journalctl -f -u captchabot.service ``` -6. 将本 bot 加入一个群组,并给予封禁用户的权限,即可开始使用 +服务使用 `Restart=on-failure`。正常停止时,应用依次取消 challenge timeout、停止 Telegram client、关闭 HTTP client 并释放数据库连接池。 -## 在多个群组(10个以上等)部署本Bot的提示 +## 开发与质量门禁 -~~由于一个已知无解的严重 Bug, Bot 在运行一周至13天左右的时间可能会由于线程冲突导致整个 Bot 死掉,如果需要在多个(10个以上)的群组内部署本 Bot 请考虑在crontab等地方设置定期重启。~~ +```bash +uv sync --locked --all-groups +uv run ruff format --check . +uv run ruff check . +uv run ty check src main.py tests +uv run pytest +uv run pip-audit +``` -现在的分支加入了一遇到异常就会自动重启的设定,Bot 在正常运行情况下应该是不会卡死了。 +PostgreSQL 集成测试只对显式指定的一次性测试库运行,测试过程会清空并重建该数据库: -## 日志 -在安装了 systemd ,且已经在 /etc/systemd/system 下部署了服务的 Linux 操作系统环境下,请使用命令: ```bash -journalctl -u captchabot.service -#查看从启动时的日志 -journalctl -f -u captchabot.service -#查看实时日志 -# 这里的 captchabot.service 请自行更名为你在服务器上部署的服务名 +TEST_DATABASE_URL=postgresql+psycopg://captcha:password@127.0.0.1/captcha_test uv run pytest -m integration ``` -## 开源协议 -本项目使用 GNU Affero 通用公共许可证 3.0 开源 + +不要把生产数据库 URL 传给 `TEST_DATABASE_URL`。GitHub Actions 使用 Python 3.14 与独立 PostgreSQL service 执行格式、静态检查、类型检查、测试、覆盖率、Alembic check 和依赖审计。 + +## 项目结构 + +```text +src/captcha_bot/ +├── app.py # FastAPI lifespan 与资源所有权 +├── challenges.py # 验证状态及并发安全 Registry +├── config.py # TOML 模型、热重载及旧配置迁移 +├── handlers/ # Telegram 管理、入群、回调处理 +├── db/ # SQLAlchemy 2.0 async repository 与导入 +├── services.py # 群策略和 Turnstile 服务 +└── web.py # Web/健康检查路由 +``` + +`main.py` 只保留兼容启动包装。未完成的 `/report`、OpenAI、第三方黑名单、`mute` 和无效消息项已移除。 + +## 许可证 + +[GNU Affero General Public License v3.0](LICENSE) diff --git a/Timer/__init__.py b/Timer/__init__.py deleted file mode 100644 index e5bac0b..0000000 --- a/Timer/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -import asyncio -import logging - - -class Timer: - def __init__(self, callback, timeout): - logging.info("Created a schedule interval as " + str(timeout) + " seconds.") - loop = asyncio.get_event_loop() - self.callback = callback - self.timeout = timeout - self.task = loop.create_task(self.wait()) - - def __str__(self): - return "timeout: " + str(self.timeout) - - async def wait(self): - await asyncio.sleep(self.timeout) - logging.info("Successfully executed a timer schedule.") - await self.callback - - def stop(self): - try: - self.task.cancel() - self.callback.close() - except asyncio.CancelledError: - pass diff --git a/ai.py b/ai.py deleted file mode 100644 index 6d49a2d..0000000 --- a/ai.py +++ /dev/null @@ -1,91 +0,0 @@ -import json -from openai import AsyncOpenAI -from pyrogram.types import Message -import asyncio - - -class AIResponseError(Exception): - pass - - -class AIResponseResult: - def __init__(self, possibility: int, token_used: int): - self.possibility = possibility - self.token_used = token_used - - def __str__(self): - return f"possibility: {self.possibility}, token_used: {self.token_used}" - - -async def chk_message(message: Message, api_key, max_token, image_url: "") -> AIResponseResult: - prompt = """You are an advanced spam detection system for a Telegram chat. Your task is to analyze the user's `username`, `bio`, and `message` to determine the likelihood that the user is a spammer. You should output a number from 0 to 100, where 0 indicates the user is definitely not a spammer, and 100 indicates the user is definitely a spammer. - -Consider the following factors: - -- The inclusion of suspicious links. - -- The overall tone and context of the message (e.g., overly promotional, using excessive emojis or symbols). - -- The presence of pornographic or inappropriate content in the message or images. - -Based on these factors, provide a spam likelihood score for the given user information. - -**Your response must be a single number between 0 and 100. Do not include any additional text or explanation.** - -Example input: { "username": "安全洗U赚差价1U换1.5U", "bio": "公群交易,洗U赚差价,黑U承兑,已上押《8888U》VIP群上押《20000U》公群已平稳运行半年多,欢迎进公群了解 https://t.m", "message": "我来了😂😂" } Example output: 100""" - client = AsyncOpenAI(api_key=api_key) - if message.from_user is None: - user_name = str(message.chat.title) - bio = str(message.chat.description) - else: - user_name = str(message.from_user.first_name) + " " + str(message.from_user.last_name) - bio = str(message.chat.bio) - # bio = "" # bio 需要通过 get_chat 获取, 暂时不使用 - text = { - "username": user_name, - "bio": bio, - "message": message.text - } - print(json.dumps(text, ensure_ascii=False)) - content = [] - if text != "": - content.append({ - "type": "text", - "text": json.dumps(text) - }) - if image_url != "": - content.append({ - "type": "image_url", - "image_url": { - "url": image_url - } - }) - chat_completion = await client.chat.completions.create( - model="gpt-4o", - messages=[ - { - "role": "system", - "content": [ - { - "type": "text", - "text": prompt - } - ] - }, - { - "role": "user", - "content": content - } - ], - temperature=0, - max_tokens=512, - top_p=1, - frequency_penalty=0, - presence_penalty=0 - ) - token_used = chat_completion.usage.total_tokens - result = chat_completion.choices[0].message.content - try: - return AIResponseResult(possibility=int(result), token_used=token_used) - except Exception as e: - raise AIResponseError(f"Invalid response from AI: {result}") from e diff --git a/alembic.ini b/alembic.ini index 1e8c258..023243a 100644 --- a/alembic.ini +++ b/alembic.ini @@ -13,6 +13,7 @@ script_location = alembic # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. prepend_sys_path = . +path_separator = os # timezone to use when rendering the date within the migration file # as well as the filename. diff --git a/alembic/env.py b/alembic/env.py index 8e6dcb0..8b5b8a5 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,13 +1,20 @@ +import os from logging.config import fileConfig -from model import Base -from sqlalchemy import engine_from_config -from sqlalchemy import pool + +from sqlalchemy import engine_from_config, pool from alembic import context +from captcha_bot.config import load_settings +from captcha_bot.db.models import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config +settings = load_settings(os.environ.get("CAPTCHA_BOT_CONFIG", "config.toml")) +database_url = settings.database.url.get_secret_value() +if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1) +config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) # Interpret the config file for Python logging. # This line sets up loggers basically. @@ -64,9 +71,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/13a9421b19d9_add_challenge_id.py b/alembic/versions/13a9421b19d9_add_challenge_id.py index 571ff07..5f96690 100644 --- a/alembic/versions/13a9421b19d9_add_challenge_id.py +++ b/alembic/versions/13a9421b19d9_add_challenge_id.py @@ -1,65 +1,72 @@ """add challenge_id Revision ID: 13a9421b19d9 -Revises: +Revises: Create Date: 2024-05-04 02:03:32.317765 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '13a9421b19d9' -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "13a9421b19d9" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('blacklist_user', - sa.Column('user_id', sa.BigInteger(), nullable=False), - sa.Column('last_attempt', sa.DateTime(), nullable=False), - sa.Column('attempt_count', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('user_id'), - sa.UniqueConstraint('user_id') + op.create_table( + "blacklist_user", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("last_attempt", sa.DateTime(), nullable=False), + sa.Column("attempt_count", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("user_id"), + sa.UniqueConstraint("user_id"), ) - op.create_table('group_config', - sa.Column('chat_id', sa.BigInteger(), nullable=False), - sa.Column('timeout', sa.Integer(), nullable=False), - sa.Column('challenge_type', sa.String(length=10), nullable=False), - sa.Column('failed_action', sa.String(length=10), nullable=False), - sa.Column('timeout_action', sa.String(length=10), nullable=False), - sa.Column('third_party_blacklist', sa.Boolean(), nullable=False), - sa.Column('global_blacklist', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('chat_id'), - sa.UniqueConstraint('chat_id') + op.create_table( + "group_config", + sa.Column("chat_id", sa.BigInteger(), nullable=False), + sa.Column("timeout", sa.Integer(), nullable=False), + sa.Column("challenge_type", sa.String(length=10), nullable=False), + sa.Column("failed_action", sa.String(length=10), nullable=False), + sa.Column("timeout_action", sa.String(length=10), nullable=False), + sa.Column("third_party_blacklist", sa.Boolean(), nullable=False), + sa.Column("global_blacklist", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("chat_id"), + sa.UniqueConstraint("chat_id"), ) - op.create_table('recaptcha_log', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('group_id', sa.BigInteger(), nullable=False), - sa.Column('user_id', sa.BigInteger(), nullable=False), - sa.Column('challenge_id', sa.String(length=64), nullable=False), - sa.Column('ip_addr', sa.String(length=15), nullable=False), - sa.Column('user_agent', sa.Text(), nullable=False), - sa.Column('action', sa.String(length=20), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['group_id'], ['group_config.chat_id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('id') + op.create_table( + "recaptcha_log", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("group_id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("challenge_id", sa.String(length=64), nullable=False), + sa.Column("ip_addr", sa.String(length=15), nullable=False), + sa.Column("user_agent", sa.Text(), nullable=False), + sa.Column("action", sa.String(length=20), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["group_id"], + ["group_config.chat_id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('recaptcha_log') - op.drop_table('group_config') - op.drop_table('blacklist_user') + op.drop_table("recaptcha_log") + op.drop_table("group_config") + op.drop_table("blacklist_user") # ### end Alembic commands ### diff --git a/alembic/versions/7f4c2a9d5e10_async_schema_cleanup.py b/alembic/versions/7f4c2a9d5e10_async_schema_cleanup.py new file mode 100644 index 0000000..2a175d8 --- /dev/null +++ b/alembic/versions/7f4c2a9d5e10_async_schema_cleanup.py @@ -0,0 +1,64 @@ +"""async schema cleanup + +Revision ID: 7f4c2a9d5e10 +Revises: b841fd90a794 +Create Date: 2026-07-14 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "7f4c2a9d5e10" +down_revision: str | None = "b841fd90a794" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_constraint("blacklist_user_user_id_key", "blacklist_user", type_="unique", if_exists=True) + op.drop_constraint("group_config_chat_id_key", "group_config", type_="unique", if_exists=True) + op.drop_constraint("recaptcha_log_id_key", "recaptcha_log", type_="unique", if_exists=True) + op.drop_column("group_config", "third_party_blacklist") + + op.execute("UPDATE group_config SET timeout = 300 WHERE timeout <= 0") + op.execute("UPDATE group_config SET challenge_type = 'math' WHERE challenge_type NOT IN ('math', 'recaptcha')") + op.execute("UPDATE group_config SET failed_action = 'kick' WHERE failed_action NOT IN ('ban', 'kick')") + op.execute("UPDATE group_config SET timeout_action = 'kick' WHERE timeout_action NOT IN ('ban', 'kick')") + op.create_check_constraint("ck_group_config_timeout_positive", "group_config", "timeout > 0") + op.create_check_constraint( + "ck_group_config_challenge_type", "group_config", "challenge_type IN ('math', 'recaptcha')" + ) + op.create_check_constraint("ck_group_config_failed_action", "group_config", "failed_action IN ('ban', 'kick')") + op.create_check_constraint("ck_group_config_timeout_action", "group_config", "timeout_action IN ('ban', 'kick')") + op.create_unique_constraint( + "uq_recaptcha_log_event", + "recaptcha_log", + ["challenge_id", "user_id", "group_id", "ip_addr", "action"], + ) + op.create_index("ix_recaptcha_log_challenge_created", "recaptcha_log", ["challenge_id", "created_at"]) + op.create_index("ix_recaptcha_log_user_created", "recaptcha_log", ["user_id", "created_at"]) + op.create_index( + "ix_recaptcha_log_ip_action_group_user_created", + "recaptcha_log", + ["ip_addr", "action", "group_id", "user_id", "created_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_recaptcha_log_ip_action_group_user_created", table_name="recaptcha_log") + op.drop_index("ix_recaptcha_log_user_created", table_name="recaptcha_log") + op.drop_index("ix_recaptcha_log_challenge_created", table_name="recaptcha_log") + op.drop_constraint("uq_recaptcha_log_event", "recaptcha_log", type_="unique") + op.drop_constraint("ck_group_config_timeout_action", "group_config", type_="check") + op.drop_constraint("ck_group_config_failed_action", "group_config", type_="check") + op.drop_constraint("ck_group_config_challenge_type", "group_config", type_="check") + op.drop_constraint("ck_group_config_timeout_positive", "group_config", type_="check") + op.add_column( + "group_config", sa.Column("third_party_blacklist", sa.Boolean(), nullable=False, server_default=sa.false()) + ) + op.create_unique_constraint("recaptcha_log_id_key", "recaptcha_log", ["id"]) + op.create_unique_constraint("group_config_chat_id_key", "group_config", ["chat_id"]) + op.create_unique_constraint("blacklist_user_user_id_key", "blacklist_user", ["user_id"]) diff --git a/alembic/versions/b841fd90a794_remove.py b/alembic/versions/b841fd90a794_remove.py index 71d9d40..bc14808 100644 --- a/alembic/versions/b841fd90a794_remove.py +++ b/alembic/versions/b841fd90a794_remove.py @@ -5,26 +5,25 @@ Create Date: 2024-05-08 02:19:02.253873 """ -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa +from collections.abc import Sequence +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'b841fd90a794' -down_revision: Union[str, None] = 'c945fe00daeb' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "b841fd90a794" +down_revision: str | None = "c945fe00daeb" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint('recaptcha_log_group_id_fkey', 'recaptcha_log', type_='foreignkey') + op.drop_constraint("recaptcha_log_group_id_fkey", "recaptcha_log", type_="foreignkey") # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_foreign_key('recaptcha_log_group_id_fkey', 'recaptcha_log', 'group_config', ['group_id'], ['chat_id']) + op.create_foreign_key("recaptcha_log_group_id_fkey", "recaptcha_log", "group_config", ["group_id"], ["chat_id"]) # ### end Alembic commands ### diff --git a/alembic/versions/c945fe00daeb_change_ip_length.py b/alembic/versions/c945fe00daeb_change_ip_length.py index face82d..bdef809 100644 --- a/alembic/versions/c945fe00daeb_change_ip_length.py +++ b/alembic/versions/c945fe00daeb_change_ip_length.py @@ -5,38 +5,39 @@ Create Date: 2024-05-04 03:30:17.311752 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'c945fe00daeb' -down_revision: Union[str, None] = '13a9421b19d9' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "c945fe00daeb" +down_revision: str | None = "13a9421b19d9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_unique_constraint(None, 'blacklist_user', ['user_id']) - op.create_unique_constraint(None, 'group_config', ['chat_id']) - op.alter_column('recaptcha_log', 'ip_addr', - existing_type=sa.VARCHAR(length=15), - type_=sa.String(length=64), - existing_nullable=False) - op.create_unique_constraint(None, 'recaptcha_log', ['id']) + op.alter_column( + "recaptcha_log", + "ip_addr", + existing_type=sa.VARCHAR(length=15), + type_=sa.String(length=64), + existing_nullable=False, + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'recaptcha_log', type_='unique') - op.alter_column('recaptcha_log', 'ip_addr', - existing_type=sa.String(length=64), - type_=sa.VARCHAR(length=15), - existing_nullable=False) - op.drop_constraint(None, 'group_config', type_='unique') - op.drop_constraint(None, 'blacklist_user', type_='unique') + op.alter_column( + "recaptcha_log", + "ip_addr", + existing_type=sa.String(length=64), + type_=sa.VARCHAR(length=15), + existing_nullable=False, + ) # ### end Alembic commands ### diff --git a/challenge/autokickcache.py b/challenge/autokickcache.py deleted file mode 100644 index 115a341..0000000 --- a/challenge/autokickcache.py +++ /dev/null @@ -1,2 +0,0 @@ -class AutoKickCache: - ... diff --git a/challenge/math.py b/challenge/math.py deleted file mode 100644 index 1770c14..0000000 --- a/challenge/math.py +++ /dev/null @@ -1,108 +0,0 @@ -import json -import random - -from pyrogram.types import (InlineKeyboardButton, Message) - - -class Math: - """ - 如果你是 Python 高手,请继续;如果你根本不会Python或者语法乱七八糟,我建议你还是先去 https://docs.python.org 学习一个。 - 一个简单的验证问题变量说明 - 请注意,如果要使用中文写出各个选项,建议把main.py里344-346行修改为如下形式: - [InlineKeyboardButton(str(c), - callback_data=bytes( - str(c), encoding="utf-8"))]) - 这么做的目的是能让问题的选项每个一行,否则一行四个答案可能会显示不下 - __str__ 方法返回问题的具体内容, - qus 返回上面的 __str__ 方法 - ans 返回 Challenge 的答案 - choices 返回 Challenge 的选项们 - _ans 属性是问题, - _choices 是问题选项 - 比如说你可以这么改写 Challenge 类 - def __init(self): - self._ans= '' - self._choices = [] - self.new() - def __str__(self): - return "下面哪一个不是路由器的架构?" - def new(self): - self._choices['MIPS','ARM','8051','x86'] - self._answer = '8051' - - qus, ans,choices 这三个函数可以放着不动 - """ - - def __init__(self): - self._a = 0 - self._b = 0 - # 所以为啥要把a,b两个属性丢这里?我不是太懂。。。 - self._op = "加上(plus)" - self._ans = 0 - self._choices = [] - self.new() - - def __str__(self): - return "{a} {op} {b} 的结果是多少?\nWhat is the result of {a} {op} {b}".format(a=self._a, b=self._b, op=self._op) - - def new(self): - operation = random.choice(["加上(plus)", "减去(minus)", "乘以(time)", "除以(divide)"]) - a, b, ans = 0, 0, 0 - if operation in ["加上(plus)", "减去(minus)"]: - a, b = random.randint(0, 50), random.randint(0, 50) - a, b = max(a, b), min(a, b) - ans = a + b if operation == "加上(plus)" else a - b - elif operation == "乘以(time)": - a, b = random.randint(0, 9), random.randint(0, 9) - ans = a * b - elif operation == "除以(divide)": - a, b = random.randint(0, 9), random.randint(1, 9) - ans = a - a = a * b - - cases = random.randint(3, 5) - choices = random.sample(range(100), cases) - if ans not in choices: - choices[0] = ans - random.shuffle(choices) - - self._a, self._b = a, b - self._op = operation - self._ans = ans - self._choices = choices - - def generate_button(self, group_config): - choices = [] - answers = [] - for c in self.choices(): - answers.append( - InlineKeyboardButton(str(c), - callback_data=bytes( - str(c), encoding="utf-8"))) - choices.append(answers) - return choices + [[ - InlineKeyboardButton(group_config["msg_approve_manually"], - callback_data=b"+"), - InlineKeyboardButton(group_config["msg_refuse_manually"], - callback_data=b"-"), - ]] - - def generate_join_request_button(self, chat_id): - choices = [] - answers = [] - for c in self.choices(): - answers.append( - InlineKeyboardButton(str(c), - callback_data=bytes( - str(c) + f"|{chat_id}", encoding="utf-8"))) - choices.append(answers) - return choices - - def qus(self): - return self.__str__() - - def ans(self): - return self._ans - - def choices(self): - return self._choices diff --git a/challenge/recaptcha.py b/challenge/recaptcha.py deleted file mode 100644 index 4b05069..0000000 --- a/challenge/recaptcha.py +++ /dev/null @@ -1,51 +0,0 @@ -from configparser import ConfigParser -from pyrogram.types import (InlineKeyboardButton, Message) -import requests -import json -import uuid - -cf = ConfigParser() -cf.read("config.ini", encoding="utf-8") - - -class ReCAPTCHA: - def __init__(self): - self.site_key = cf.get("reCAPTCHA", "site_key") - self.secret_key = cf.get("reCAPTCHA", "secret_key") - self.recaptcha_id = str(uuid.uuid4().hex) # 在用户启动 bot 后随机生成的一个 id, 用于 recaptcha 链接 - self.auth_url = cf.get("reCAPTCHA", "base_url") + "/recaptcha?challenge=" + str(self.recaptcha_id) - self.bot_url = 'https://t.me/' + cf.get("bot", "username") + '?start=' - self.message = None - - def __str__(self): - return "recaptcha id: " + self.recaptcha_id - - def get_site_key(self): - return self.site_key - - def get_secret_key(self): - return self.secret_key - - def verify(self, captcha_response, user_ip: str): - """ Validating recaptcha response from google server - Returns True captcha test passed for submitted form else returns False. - https://techmonger.github.io/5/python-flask-recaptcha/ - """ - secret = self.secret_key - payload = {'response': captcha_response, 'secret': secret, 'remoteip': user_ip} - response = requests.post("https://challenges.cloudflare.com/turnstile/v0/siteverify", payload) - response_text = json.loads(response.text) - return response_text['success'] - - def generate_button(self, group_config, chat_id): - link_to_bot = [[InlineKeyboardButton(text="开始验证", url=self.bot_url + str(chat_id))]] - return link_to_bot + [[ - InlineKeyboardButton(group_config["msg_approve_manually"], - callback_data=b"+"), - InlineKeyboardButton(group_config["msg_refuse_manually"], - callback_data=b"-"), - ]] - - def generate_auth_button(self): - return [[InlineKeyboardButton(text="开始验证", - url=self.auth_url)]] diff --git a/challengedata.py b/challengedata.py deleted file mode 100644 index 647502d..0000000 --- a/challengedata.py +++ /dev/null @@ -1,77 +0,0 @@ -import threading - -from challenge.math import Math -from challenge.recaptcha import ReCAPTCHA - - -class ChallengeData: - def __init__(self): - self.data = dict() - self.t_lock = threading.Lock() - - def __setitem__(self, key, value): - with self.t_lock: - self.data[key] = value - - def __getitem__(self, item: str): - with self.t_lock: - value = self.data.get(item) - return value - - def __str__(self): - output = [] - with self.t_lock: - count = len(self.data) - output.append("ChallengeCount: " + str(count)) - for ch_id, challenge_data in self.data.items(): - output.append("验证 ID:{}".format(ch_id)) - output.append("验证数据:{}".format(str(challenge_data)) + "\n") - return str(output) - - def delete(self, data): - with self.t_lock: - deleted = self.data.pop(data, None) - return deleted - - def get(self, ch_id): - with self.t_lock: - challenge_data = self.data.get(ch_id) - return challenge_data - - def get_by_user_and_chat_id(self, user_id, chat_id): - with self.t_lock: - for ch_id, challenge_data in self.data.items(): - challenge_chat_id = int(ch_id.split("|")[0]) - if user_id == challenge_data[1] and chat_id == challenge_chat_id: - return ch_id, challenge_data - return None, None - - def get_by_challenge_id(self, challenge_id: str): - """ - Get all data by challenge id - :param challenge_id: recaptcha challengeid - :return: tuple(key, value) - """ - with self.t_lock: - for ch_id, challenge_data in self.data.items(): - if isinstance(challenge_data[0], ReCAPTCHA): - if challenge_id == challenge_data[0].recaptcha_id: - break - else: - return None - challenge_data = self.data.get(ch_id) - return ch_id, challenge_data - - def is_duplicate(self, user_id: int, chat_id: int): - """ - 检查当前用户在当前群是否有未完成的订阅,用来防止奇怪的原因重复发送多次验证 - :param user_id 目标用户 id - :param chat_id 目标群组 id - :return: True 重复 / False 不重复 - """ - with self.t_lock: - for ch_id, v in self.data.items(): - challenge_chat_id = int(ch_id.split("|")[0]) - if challenge_chat_id == chat_id and user_id == v[1]: - return True - return False diff --git a/config.example.ini b/config.example.ini deleted file mode 100644 index 156c355..0000000 --- a/config.example.ini +++ /dev/null @@ -1,25 +0,0 @@ -[db] -conn_str = postgresql://postgres:nimaqu114514@localhost/FubukiAntiSpam - -[bot] -; username 不要加那个 @ -username = bot -token = 114514:1919810 -api_id = 114514 -api_hash = 1919810 -admin = 1145141919 -channel = -1145141919 - -[reCAPTCHA] -site_key = 1145141919 -secret_key = 810893 -;发送给用户的验证地址 -base_url = https://example.com - -[web] -;flask 加密密钥,自己生成一个 -flask_secret_key = 1145141919810 -flask_port = 5000 -flask_host = 127.0.0.1 -; 是否启用开发模式,启用后会在日志中输出更多信息,关闭后由 waitress 进行部署 -development = true diff --git a/config.example.json b/config.example.json deleted file mode 100644 index e1ddf67..0000000 --- a/config.example.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "proxy_addr":"", - "proxy_port":"", - "msg_start_message": "喵?", - "msg_passed_answer": "#PASS\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件: 通过验证.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_passed_mercy": "#PASS_WRONG_ANSWER\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件: 回答错误,通过验证.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_passed_admin": "#PASS_MANUALLY\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件: 管理员人工通过验证.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_failed_answer": "#FAIL_WRONG_ANSWER\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件: 验证未能通过.\n原因: 回答错误\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_failed_timeout": "#FAIL_TLO\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n用户名: @{targetusername} \n用户 Firstname: `{targetfirstname}` \n用户 Lastname: `{targetlastname}` \n事件: 验证未能通过.\n原因: 超时.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_failed_admin": "#FAIL_MANUALLY\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件:验证未能通过 \n原因: 管理员人工拒绝.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_failed_auto_kick": "#AUTO_KICKED\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n用户名: @{targetusername} \n用户 Firstname: `{targetfirstname}` \n用户 Lastname: `{targetlastname}` \n事件: 验证未能通过.\n原因: 黑名单自动踢出.\n群组 ID: `{groupid}`\n群组标题: `{grouptitle}`\n上一次尝试加群在: {lastattempt}\n距离上一次尝试时间:{sincelastattempt}\n总尝试次数: {trycount}", - "msg_message_deleted": "#MESSAGE_DELETED\n[用户](tg://user?id={targetuserid}) ID: `{targetuserid}` \n事件: 消息已被删除.\n原因: 用户未完成验证\n消息 ID: `{messageid}\n`群组 ID: `{groupid}`\n群组标题: `{grouptitle}`", - "msg_into_group": "#ADDBOT\n已被加入群组 ID: `{groupid}`\n群组标题: `{grouptitle}` 中", - "msg_leave_group": "基于Term of Service, Bot 离开了群组: `{groupid}`", - "msg_leave_msg": "根据 Term of Service,本 Bot 无法对该群组提供服务,需要了解更多,请查看项目使用协议。", - "*": { - "msg_self_introduction": "Tooruchan's Captcha Bot\n Powered by [Tooruchan](https://github.com/Tooruchan)\n[Project Repo](https://github.com/Tooruchan/Telegram-CAPTCHA-Bot)\nFramework:\n[pyrogram async](https://github.com/pyrogram/pyrogram)", - "msg_challenge_not_for_you": "这次验证并不针对您", - "msg_challenge_math": "对于用户 [{target_id}](tg://user?id={target_id}) :\n您好,本群开启了验证功能,请在 {timeout} 秒内点击下面的按钮回答这个问题:\n{challenge}", - "msg_challenge_math_private": "请在 {timeout} 秒内回答这个问题:\n{challenge}", - "msg_challenge_recaptcha": "对于用户 [{target_id}](tg://user?id={target_id}) :\n您好,本群开启了验证功能,请在 {timeout} 秒内点击下面的按钮前往机器人完成验证:", - "msg_challenge_passed": "您已通过验证,欢迎加入本群!\n如果仍然无法发言,请重启 Telegram 客户端。", - "msg_challenge_failed": "抱歉,您没有通过验证,如需解封请私聊本群群管。", - "msg_approve_manually": "人工通过\nPASS", - "msg_refuse_manually": "人工拒绝\nDENY", - "msg_permission_denied": "您的权限不足", - "msg_bot_no_permission": "操作失败,可能是群管并未赋予机器人管理员权限。", - "msg_approved": "您已被管理员 {user} 人工通过,欢迎加入本群!", - "msg_refused": "该用户已被管理员 {user} 移除出群。", - "challenge_timeout": 60, - "challenge_timeout_action": "kick", - "challenge_failed_action": "kick", - "challenge_type": "math", - "delete_passed_challenge": true, - "delete_passed_challenge_interval": 15, - "delete_failed_challenge": true, - "delete_failed_challenge_interval": 15, - "enable_third_party_blacklist": false, - "enable_global_blacklist": true, - "global_timeout_user_blacklist_remove": 600 - } -} \ No newline at end of file diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..a325dad --- /dev/null +++ b/config.example.toml @@ -0,0 +1,65 @@ +[bot] +username = "bot_username" +token = "123456:replace-me" +api_id = 123456 +api_hash = "replace-me" +admin_ids = [123456789] +log_channel_id = -1001234567890 + +[database] +url = "postgresql+psycopg://captcha:replace-me@127.0.0.1/captcha" +pool_size = 5 +max_overflow = 10 + +[web] +host = "127.0.0.1" +port = 5000 +debug = false +trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"] + +[proxy] + +[turnstile] +site_key = "replace-me" +secret_key = "replace-me" +base_url = "https://example.com" +verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +timeout_seconds = 10.0 + +[defaults] +challenge_timeout = 60 +challenge_timeout_action = "kick" +challenge_failed_action = "kick" +challenge_type = "math" +delete_passed_challenge = true +delete_passed_challenge_interval = 15 +delete_failed_challenge = true +delete_failed_challenge_interval = 15 +enable_global_blacklist = true +global_timeout_user_blacklist_remove = 600 + +[messages] +start_message = "喵?" +passed_answer = "#PASS\n用户: {targetuserid}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +passed_admin = "#PASS_MANUALLY\n用户: {targetuserid}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +failed_answer = "#FAIL_WRONG_ANSWER\n用户: {targetuserid}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +failed_timeout = "#FAIL_TLO\n用户: {targetuserid}\n用户名: {targetusername}\nFirstname: {targetfirstname}\nLastname: {targetlastname}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +failed_admin = "#FAIL_MANUALLY\n用户: {targetuserid}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +failed_auto_kick = "#AUTO_KICKED\n用户: {targetuserid}\n用户名: {targetusername}\nFirstname: {targetfirstname}\nLastname: {targetlastname}\n群组 ID: {groupid}\n群组标题: {grouptitle}\n上次尝试: {lastattempt}\n间隔: {sincelastattempt}\n次数: {trycount}" +message_deleted = "#MESSAGE_DELETED\n用户: {targetuserid}\n消息: {messageid}\n群组 ID: {groupid}\n群组标题: {grouptitle}" +into_group = "#ADDBOT\n群组 ID: {groupid}\n群组标题: {grouptitle}" +leave_group = "Bot 离开了群组: {groupid}" +leave_message = "根据 Term of Service,本 Bot 无法对该群组提供服务。" +self_introduction = "Telegram CAPTCHA Bot\nPowered by Kurigram" +challenge_not_for_you = "这次验证并不针对您" +challenge_math = "用户 {target_id},请在 {timeout} 秒内回答:\n{challenge}" +challenge_math_private = "请在 {timeout} 秒内回答:\n{challenge}" +challenge_recaptcha = "用户 {target_id},请在 {timeout} 秒内完成验证:" +challenge_passed = "您已通过验证,欢迎加入本群!" +challenge_failed = "抱歉,您没有通过验证,如需解封请私聊本群群管。" +approve_manually = "人工通过 PASS" +refuse_manually = "人工拒绝 DENY" +permission_denied = "您的权限不足" +bot_no_permission = "操作失败,可能是群管并未赋予机器人管理员权限。" +approved = "您已被管理员 {user} 人工通过,欢迎加入本群!" +refused = "该用户已被管理员 {user} 移除出群。" diff --git a/db.py b/db.py deleted file mode 100644 index fb4b7c6..0000000 --- a/db.py +++ /dev/null @@ -1,87 +0,0 @@ -from configparser import ConfigParser - -from alembic.config import Config -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from alembic import command -from model import GroupConfig, BlacklistUser - -config = ConfigParser() -config.read('config.ini', encoding='utf-8') -db_config = config['db'] - -engine = create_engine(db_config['conn_str']) -SessionFactory = sessionmaker(bind=engine) - - -def upgrade_db(): - alembic_cfg = Config("alembic.ini") - alembic_cfg.set_main_option("sqlalchemy.url", db_config['conn_str']) - command.upgrade(alembic_cfg, "head") - - -def new_migration(message: str): - alembic_cfg = Config("alembic.ini") - alembic_cfg.set_main_option("sqlalchemy.url", db_config['conn_str']) - command.revision(alembic_cfg, message=message, autogenerate=True) - - -def _import_from_csv(file_path, table): - import csv - from datetime import datetime - with open(file_path, 'r', encoding='utf-8') as f: - reader = csv.reader(f) - with SessionFactory() as session: - if table == 'group_config': - obj = [] - for row in reader: - if row[1] == "": - row[1] = 'kick' - if row[2] == "": - row[2] = 'kick' - if row[4] == "": - row[4] = 'recaptcha' - if row[5] == "": - row[5] = True - elif row[5] == "0": - row[5] = False - else: - row[5] = True - if row[6] == "": - row[6] = False - elif row[6] == "0": - row[6] = False - else: - row[6] = True - if row[3] == "": - row[3] = 180 - obj.append(GroupConfig(chat_id=row[0], timeout=row[3], challenge_type=row[4].lower(), failed_action=row[1], - timeout_action=row[2], third_party_blacklist=row[6], global_blacklist=row[5])) - if table == 'blacklist_user': - obj = [] - for row in reader: - if row[2] == "0": - continue - if row[3] == "0": - row[3] = "1" - linux_timestamp = int(row[1]) - # 将时间戳转换为datetime对象 - last_attempt = datetime.fromtimestamp(linux_timestamp) - obj.append(BlacklistUser(user_id=row[0], last_attempt=last_attempt, attempt_count=row[3])) - session.add_all(obj) - session.commit() - - -if __name__ == '__main__': - # 根据传入的命令行参数执行对应的操作 - # 例如: python db.py update - import sys - - if len(sys.argv) > 1: - if sys.argv[1] == 'update': - upgrade_db() - elif sys.argv[1] == 'new_migration': - new_migration(sys.argv[2]) - elif sys.argv[1] == 'import': - _import_from_csv(sys.argv[2], sys.argv[3]) diff --git a/dbhelper.py b/dbhelper.py deleted file mode 100644 index 514f77e..0000000 --- a/dbhelper.py +++ /dev/null @@ -1,212 +0,0 @@ -import datetime -import logging - -from sqlalchemy import func - -from db import SessionFactory -from model import GroupConfig, BlacklistUser, RecaptchaLog, RecaptchaLogAction - - -def get_user(user_id): - with SessionFactory() as session: - user = session.query(BlacklistUser).filter_by(user_id=user_id).first() - if user is None: - return None - else: - return user - - -def get_user_status(user_id): - with SessionFactory() as session: - user = session.query(BlacklistUser).filter_by(user_id=user_id).first() - if user is None: - return 0 - else: - return 1 - - -def update_last_try(time, user_id): - with SessionFactory() as session: - user = session.query(BlacklistUser).filter_by(user_id=user_id).first() - if user is None: - logging.error('User not found') - else: - user.last_attempt = time - session.commit() - - -def try_count_plus_one(user_id): - with SessionFactory() as session: - user = session.query(BlacklistUser).filter_by(user_id=user_id).first() - if user is None: - logging.error('User not found') - else: - user.attempt_count += 1 - session.commit() - - -def new_blacklist(time, user_id): - # tested - with SessionFactory() as session: - user = BlacklistUser(user_id=user_id, last_attempt=time) - session.add(user) - session.commit() - - -def get_try_count(user_id): - with SessionFactory() as session: - user = session.query(BlacklistUser).filter_by(user_id=user_id).first() - if user is None: - return 0 - else: - return user.attempt_count - - -def get_all_user_ids(): - with SessionFactory() as session: - user = session.query(BlacklistUser).all() - if user is None: - return 0 - else: - return [i.user_id for i in user] - - -def delete_users_by_id(user_id_list: list | int): - with SessionFactory() as session: - if isinstance(user_id_list, list): - session.query(BlacklistUser).filter(BlacklistUser.user_id.in_(user_id_list)).delete(synchronize_session=False) - else: - session.query(BlacklistUser).filter_by(user_id=user_id_list).delete(synchronize_session=False) - session.commit() - - -def get_group_config(group_id, field: str = 'all'): - with SessionFactory() as session: - group = session.query(GroupConfig).filter_by(chat_id=group_id).first() - if group is None: - return None - else: - if field == 'challenge_failed_action': - return group.failed_action - elif field == 'challenge_timeout_action': - return group.timeout_action - elif field == 'challenge_timeout': - return group.timeout - elif field == 'challenge_type': - return group.challenge_type - elif field == 'enable_global_blacklist': - return group.global_blacklist - elif field == 'enable_third_party_blacklist': - return group.third_party_blacklist - elif field == 'all': - return {'challenge_failed_action': group.failed_action, - 'challenge_timeout_action': group.timeout_action, - 'challenge_timeout': group.timeout, 'challenge_type': group.challenge_type, - 'enable_global_blacklist': group.global_blacklist, - 'enable_third_party_blacklist': group.third_party_blacklist} - else: - return None - - -def new_group_config(group_id): - with SessionFactory() as session: - group = GroupConfig(chat_id=group_id) - session.add(group) - session.commit() - - -def set_group_config(group_id, key, value): - key = key.lower() - value = value.lower() - # tested 80% passed - with SessionFactory() as session: - group = session.query(GroupConfig).filter_by(chat_id=group_id).first() - if group is None: - new_group_config(group_id) - else: - if key == 'challenge_failed_action': - group.failed_action = value - elif key == 'challenge_timeout_action': - group.timeout_action = value - elif key == 'challenge_timeout': - group.timeout = value - elif key == 'challenge_type': - group.challenge_type = value - elif key == 'enable_global_blacklist': - group.global_blacklist = value - elif key == 'enable_third_party_blacklist': - group.third_party_blacklist = value - else: - return False - session.commit() - return True - - -def log_recaptcha(challenge_id, user_id, chat_id, ip_addr, user_agent, action: RecaptchaLogAction): - # 先根据 challenge_id, user_id, chat_id, ip_addr, action 查询是否已经存在记录 如果存在则什么都不做 - # 如果不存在则插入记录 - with SessionFactory() as session: - log = session.query(RecaptchaLog).filter_by(challenge_id=challenge_id, user_id=user_id, group_id=chat_id, - ip_addr=ip_addr, action=action).first() - if log is None: - log = RecaptchaLog(challenge_id=challenge_id, user_id=user_id, group_id=chat_id, ip_addr=ip_addr, - user_agent=user_agent, action=action) - session.add(log) - session.commit() - - -def get_logs_by_challenge_id(challenge_id): - with SessionFactory() as session: - # limit to last 5 logs - logs = session.query(RecaptchaLog).filter_by(challenge_id=challenge_id).order_by( - RecaptchaLog.created_at.desc()).limit(10).all() - return logs - - -def get_logs_by_user_id(user_id): - with SessionFactory() as session: - # limit to last 5 logs - logs = session.query(RecaptchaLog).filter_by(user_id=user_id).order_by( - RecaptchaLog.created_at.desc()).limit(10).all() - return logs - - -def get_logs_by_ip(ip_addr): - with SessionFactory() as session: - # limit to last 5 logs - logs = session.query(RecaptchaLog).filter_by(ip_addr=ip_addr).order_by( - RecaptchaLog.created_at.desc()).limit(10).all() - return logs - - -def get_passed_users_by_ip(ip_addr): - with SessionFactory() as session: - last_passed_at = func.max(RecaptchaLog.created_at).label("last_passed_at") - logs = session.query( - RecaptchaLog.group_id, - RecaptchaLog.user_id, - last_passed_at - ).filter_by( - ip_addr=ip_addr, - action=RecaptchaLogAction.Passed - ).group_by( - RecaptchaLog.group_id, - RecaptchaLog.user_id - ).order_by( - last_passed_at.desc() - ).all() - return [(log.group_id, log.user_id, log.last_passed_at) for log in logs] - - -def get_passed_user_ids_by_ip_and_group(ip_addr, group_id): - with SessionFactory() as session: - user_ids = session.query(RecaptchaLog.user_id).filter_by( - ip_addr=ip_addr, - group_id=group_id, - action=RecaptchaLogAction.Passed - ).distinct().all() - return [user_id for user_id, in user_ids] - - -if __name__ == '__main__': - ... diff --git a/docker-run.sh b/docker-run.sh deleted file mode 100644 index 6c7ca5b..0000000 --- a/docker-run.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -check_dep() { - if [ $(python -m pip check) ]; then - source venv/bin/activate - pip freeze > requirements.txt - pip install -r requirements.txt --upgrade - pip install -U pyrogram-asyncio.zip - pip install --upgrade tgcrypto configparser - fi - if [ $(pip list --disable-pip-version-check | grep -E "^tgcrypto") ]; then - source venv/bin/activate; pip install --upgrade tgcrypto - fi - if [ $(pip list --disable-pip-version-check | grep -E "^configparser") ]; then - source venv/bin/activate; pip install --upgrade configparser - fi - if [ $(pip list --disable-pip-version-check | grep -E "^pyrogram") ]; then - source venv/bin/activate; pip install --upgrade pyrogram - fi -} - -main() { - cd /tg-captcha/workdir - check_dep - if [[ ! -f config.ini ]] || [[ ! -f config.json ]]; then - echo "" - exit 1 - fi - git pull - /tg-captcha/workdir/venv/bin/python /tg-captcha/workdir/main.py -} - -main diff --git a/example.service b/example.service new file mode 100644 index 0000000..31f2322 --- /dev/null +++ b/example.service @@ -0,0 +1,19 @@ +[Unit] +Description=Telegram CAPTCHA Bot +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/telegram-captcha-bot +ExecStart=/opt/telegram-captcha-bot/.venv/bin/captcha-bot run --config /opt/telegram-captcha-bot/config.toml +Restart=on-failure +RestartSec=5s +PrivateTmp=true +NoNewPrivileges=true +KillSignal=SIGINT +TimeoutStopSec=30s +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/exapmle.service b/exapmle.service deleted file mode 100644 index a7465e0..0000000 --- a/exapmle.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=此处填写您的服务名 -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=此处填写你在服务器上存放Bot的目录 -ExecStart=/项目目录/venv/bin/python /你/放/Bot/的/目录/main.py -Restart=always -PrivateTmp=True -KillSignal=SIGINT -TimeoutStopSec=10s -StartLimitInterval=400 - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/main.py b/main.py index b76bbb4..3b4071b 100644 --- a/main.py +++ b/main.py @@ -1,1114 +1,6 @@ -import asyncio -import json -import logging -import threading -from datetime import datetime, timedelta -from configparser import ConfigParser -from ipaddress import ip_address - -from pyrogram import Client, filters -from pyrogram.enums import ChatMemberStatus -from pyrogram.errors import ChatAdminRequired, ChannelPrivate, MessageNotModified, RPCError, BadRequest, \ - MessageDeleteForbidden, UserNotParticipant, UserIsBlocked -from pyrogram.enums.chat_members_filter import ChatMembersFilter -from pyrogram.enums.message_service_type import MessageServiceType -from pyrogram.enums.chat_type import ChatType -from pyrogram.types import (InlineKeyboardMarkup, User, Message, ChatPermissions, CallbackQuery, - ChatMemberUpdated, ChatMember, Chat, ChatJoinRequest) -from Timer import Timer -from challenge.math import Math -from challenge.recaptcha import ReCAPTCHA -from challenge.autokickcache import AutoKickCache -import dbhelper as db -from challengedata import ChallengeData -from waitress import serve -from urllib.parse import urlparse -from model import ChallengeType, FailedAction -from ai import chk_message -import base64 - -# db = DBHelper() - -_start_message = "YABE!" -# _challenge_scheduler = sched.scheduler(time, sleep) -_current_challenges = ChallengeData() -_cch_lock = threading.Lock() -_config = dict() -admin_operate_filter = filters.create(lambda _, __, query: query.data.split(" ")[0] in ["+", "-"]) -private_math_challenge_filter = filters.create(lambda _, __, query: "|" in query.data) - -''' -读 只 读 配 置 -''' -cf = ConfigParser() # 启用ConfigParser读取那些启动后即不会再被更改的数据,如BotToken等 -cf.read("config.ini", encoding="utf-8") - -_admin_config = cf.get("bot", "admin") -if ',' in _admin_config: - # 多个管理员ID,用逗号分隔 - _admin_users = [int(admin_id.strip()) for admin_id in _admin_config.split(',')] -else: - # 单个管理员ID - _admin_users = [int(_admin_config)] - -_token = cf.get("bot", "token") -_api_id = cf.getint("bot", "api_id") -_api_hash = cf.get("bot", "api_hash") -_channel = cf.getint("bot", "channel") -logging.basicConfig(level=logging.INFO) - - -def is_admin(user_id: int) -> bool: - """检查用户是否为管理员""" - return user_id in _admin_users - - -# 设置一下日志记录,能够在诸如 systemctl status captchabot 这样的地方获得详细输出。 - -def start_web(client: Client): - import web - port = cf.getint('web', 'flask_port') - host = cf.get('web', 'flask_host') - web.app.secret_key = cf.get('web', 'flask_secret_key') - web.client = client - web._current_challenges = _current_challenges - web._config = _config - web._channel = _channel - if cf.getboolean('web', 'development'): - web.app.env = 'development ' - web.app.run(host=host, port=port) - else: - serve(web.app, host=host, port=port) - - -def load_config(): - global _config - with open("config.json", encoding="utf-8") as f: - _config = json.load(f) - - -def save_config(): - with open("config.json", "w", encoding='utf8') as f: - json.dump(_config, f, indent=4, ensure_ascii=False) - - -def get_group_config(chat_id): - try: - int(chat_id) - except ValueError: - return None - file_config = _config.get(chat_id, _config["*"]) - db_config = db.get_group_config(chat_id, 'all') - if db_config is None: - return file_config - else: - final_config = {**file_config, **db_config} - return final_config - - -def extract_ids(url: str) -> (int | str, int): - # 解析 URL - parsed = urlparse(url) - # 提取路径部分 - path = parsed.path - - if '/c/' in path: - # 私有群组链接 - path_parts = path.split('/') - chat_id = int(path_parts[-2]) - message_id = int(path_parts[-1]) - # chat_id 需要加上 -100 前缀 - return str(-100) + str(chat_id), message_id - else: - # 公共群组链接,chat_id 为用户名,message_id 为最后一部分 - chat_id = path.split('/')[-2] - message_id = int(path.split('/')[-1]) - return chat_id, message_id - - -def is_valid_ip_addr(value: str) -> bool: - try: - ip_address(value) - return True - except ValueError: - return False - - -def split_long_message(text: str, max_length: int = 3900) -> list[str]: - chunks = [] - current = "" - for line in text.splitlines(keepends=True): - if len(current) + len(line) > max_length: - if current: - chunks.append(current) - current = "" - while len(line) > max_length: - chunks.append(line[:max_length]) - line = line[max_length:] - current += line - if current: - chunks.append(current) - return chunks - -def _update(app): - @app.on_message(filters.command("reload") & filters.private) - async def reload_cfg(client: Client, message: Message): - _me: User = await client.get_me() - logging.info(message.text) - if is_admin(message.from_user.id): - save_config() - load_config() - await message.reply("配置已成功重载。") - else: - logging.info("Permission denied, admin users in config are:" + str(_admin_users)) - pass - - @app.on_message(filters.command("help") & filters.group) - async def helping_cmd(client: Client, message: Message): - _me: User = await client.get_me() - logging.info(message.text) - await message.reply(_config["*"]["msg_self_introduction"], - disable_web_page_preview=True) - - @app.on_message(filters.command("ping") & filters.private) - async def ping_command(client: Client, message: Message): - await message.reply("poi~") - - @app.on_message(filters.command("start") & filters.private) - async def start_command(client: Client, message: Message): - user_id = message.from_user.id - command = message.command - if not command or len(command) != 2: - await message.reply(_start_message) - return - try: - from_chat_id = int(command[1]) - except ValueError: - await message.reply(_start_message) - return - - if from_chat_id >= 0: - await message.reply(_start_message) - return - ch_id, challenge_data = _current_challenges.get_by_user_and_chat_id(user_id, from_chat_id) - - if challenge_data is None or ch_id is None: - await message.reply("这不是你的验证数据,请确认是否点击了正确的按钮") - return - else: - challenge, target_id, timeout_event = challenge_data - - await message.reply("点击下方按钮完成验证,您需要使用浏览器来完成,如果您在访问页面时出现问题,请尝试关闭的匿名代理\n\n", - reply_markup=InlineKeyboardMarkup(challenge.generate_auth_button())) - - @app.on_message(filters.command("admin", prefixes="@") & filters.group) - async def call_admin(client: Client, message: Message): - administrators: list[ChatMember] = [] - me: User = await client.get_me() - async for m in app.get_chat_members(message.chat.id, filter=ChatMembersFilter.ADMINISTRATORS): - administrators.append(m) - text = "" - for admin in administrators: - if admin.user.id == me.id: - continue - # 检查是否有删除消息的权限 - if not admin.privileges.can_restrict_members or not admin.privileges.can_delete_messages: - continue - if admin.user.is_bot: - continue - text += f"{admin.user.mention('admin')} " - if text == "": - await message.reply("没有找到可用的管理员") - return - msg = await message.reply(text) - Timer(msg.delete(), 300) - - @app.on_message(filters.command("leave") & filters.private) - async def leave_command(client: Client, message: Message): - chat_id = message.text.split()[-1] - if is_admin(message.from_user.id): - try: - await client.send_message(int(chat_id), - _config["msg_leave_msg"]) - await client.leave_chat(int(chat_id), True) - except RPCError: - await message.reply("指令出错了!可能是bot不在参数所在群里。") - else: - await message.reply("已离开群组: `" + chat_id + "`", ) - _me: User = await client.get_me() - try: - await client.send_message( - int(_channel), - _config["msg_leave_group"].format( - groupid=chat_id, - )) - except Exception as e: - logging.error(str(e)) - else: - pass - - @app.on_message(filters.command("clean") & filters.private) - async def clean_database(client: Client, message: Message): - if is_admin(message.from_user.id): - failed_count = success_count = 0 - deleted_users = [] - user_id_list = db.get_all_user_ids() - estimated_time = timedelta(seconds=int(len(user_id_list) / 4)) - await message.reply("开始整理数据库,请稍等...\n预计需要时间:{}".format(estimated_time)) - for x in user_id_list: - try: - user = await client.get_users(x) - except (BadRequest, IndexError): - logging.warning("Failed to get user info for %s", x) - deleted_users.append(x) - failed_count += 1 - continue - if user.is_deleted: - deleted_users.append(x) - # 因为 db 用的是 executemany ,得传一个 tuple 进去,所以必须得这么写,不知道有没有更好的方法 - success_count += 1 - db.delete_users_by_id(deleted_users) - await message.reply( - "已成功清除{}个已经删号的用户,共有{}个用户信息获取失败。".format(success_count, failed_count)) - else: - logging.info("Permission denied, admin users in config are:" + str(_admin_users)) - return - - @app.on_message(filters.command("faset") & filters.group) - async def set_config(client: Client, message: Message): - if message.from_user is None: - await message.reply("请从个人账号发送指令。") - return - chat_id = message.chat.id - group_config = get_group_config(chat_id) - user_id = message.from_user.id - admins = [] - async for m in client.get_chat_members(chat_id, filter=ChatMembersFilter.ADMINISTRATORS): - admins.append(m) - help_message = "使用方法:\n" \ - "/faset [配置项] [值]\n\n" \ - "配置项:\n" \ - "`challenge_failed_action`: 验证失败的动作,值为 `ban` 封禁或 `kick` 踢出\n" \ - "`challenge_timeout_action`: 验证超时的动作,值为 `ban` 封禁或 `kick` 踢出\n" \ - "`challenge_timeout`: 验证超时时间,单位为秒\n" \ - "`challenge_type`: 验证方法,当前可用为数学题 `math` 或 `reCAPTCHA` 谷歌验证码\n" \ - "`enable_global_blacklist`: 是否启用全局黑名单,值为 `1` 启用或 `0` 禁用\n" \ - "例如: \n" \ - "`/faset challenge_type reCAPTCHA`" - if not any([ - admin.user.id == user_id and - (admin.status == "creator" or admin.privileges.can_restrict_members) - for admin in admins - ]): - msg = await message.reply(group_config["msg_permission_denied"]) - Timer(msg.delete(), 5) - Timer(message.delete(), 5) - return - - args = message.text.split(" ", maxsplit=3) - if len(args) != 3: - await message.reply(help_message) - return - key = args[1] - value = args[2] - try: - db.set_group_config(chat_id, key, value) - await message.reply("配置项设置成功") - except ValueError as e: - await message.reply(str(e)) - - @app.on_message(filters.command("report") & filters.group) - async def report_message(client: Client, message: Message): - if message.reply_to_message is None: - await message.reply("请回复一条消息") - return - reply_message = message.reply_to_message - image_url = "" - if reply_message.from_user is not None: - reply_message.chat = await client.get_chat(reply_message.from_user.id) - else: - reply_message.chat = await client.get_chat(reply_message.sender_chat.id) - if reply_message.photo: - file = await client.download_media(reply_message.photo.file_id, in_memory=True) - file_ext = file.name.split('.')[-1] - file_bytes = bytes(file.getbuffer()) - image_url = f"data:image/{file_ext};base64,{base64.b64encode(file_bytes).decode('utf-8')}" - print(image_url) - result = await chk_message(api_key="sk-", message=reply_message, image_url=image_url, max_token=512) - if result.possibility > 85: - logging.info(f"AI 判断为垃圾消息,概率为 {result.possibility}%") - await client.ban_chat_member(message.chat.id, reply_message.chat.id if reply_message.from_user is None else reply_message.from_user.id) - - @app.on_message(filters.private & filters.command("sender")) - async def get_message_info(client: Client, message: Message): - # 从参数给出的链接中获取对应的消息例如:https://t.me/SSUnion/1918564 - if len(message.command) != 2: - await message.reply("使用方法: /msginfo [message link]") - return - link = message.command[1] - if not link.startswith("https://t.me/"): - await message.reply("链接格式错误") - return - try: - chat_id, message_id = extract_ids(link) - msg = await client.get_messages(chat_id, message_id) - except BadRequest as e: - await message.reply("Bot 不在该群组中") - return - except IndexError: - await message.reply("链接格式错误") - return - except RPCError as e: - logging.exception(e) - await message.reply("获取消息失败: " + str(e)) - return - if msg.from_user is None: - await message.reply("未找到消息发送者") - return - else: - fuser = msg.from_user - await message.reply(f"{fuser.mention(str(fuser.id))}\n") - - @app.on_message(filters.private & filters.command("getlog")) - async def get_log(client: Client, message: Message): - command = message.command - if not command or len(command) != 2: - await message.reply("使用方法: /getlog [challenge_id]") - return - if not is_admin(message.from_user.id): - return - logs = db.get_logs_by_challenge_id(command[1]) - if len(logs) == 0: - await message.reply("没有找到相关记录") - return - text = "" - for log in logs: - text += str(log) - text += "\n" - await message.reply(text) - - @app.on_message(filters.private & filters.command("getuserbyip")) - async def get_user_by_ip(client: Client, message: Message): - if message.from_user is None or not is_admin(message.from_user.id): - return - - command = message.command - if not command or len(command) != 2: - await message.reply("使用方法: /getuserbyip [ip]\n例如: `/getuserbyip 123.56.67.8`") - return - - ip_addr = command[1] - if not is_valid_ip_addr(ip_addr): - await message.reply("IP 地址格式错误") - return - - logs = db.get_passed_users_by_ip(ip_addr) - if len(logs) == 0: - await message.reply("没有找到通过该 IP 验证的用户") - return - - groups = {} - for group_id, user_id, last_passed_at in logs: - groups.setdefault(group_id, []).append((user_id, last_passed_at)) - unique_user_count = len({user_id for _, user_id, _ in logs}) - - group_titles = {} - for group_id in groups: - try: - chat = await client.get_chat(group_id) - group_titles[group_id] = chat.title or str(group_id) - except RPCError: - group_titles[group_id] = "无法获取群名" - - text = f"IP `{ip_addr}` 通过验证的用户共 {unique_user_count} 个,用户/群记录 {len(logs)} 条,涉及 {len(groups)} 个群:\n\n" - for group_id, users in groups.items(): - text += f"群组: {group_titles[group_id]} (`{group_id}`)\n" - for user_id, last_passed_at in users: - text += f"- [用户](tg://user?id={user_id}) `{user_id}` 最近通过: `{last_passed_at}`\n" - text += "\n" - - for chunk in split_long_message(text): - await message.reply(chunk, disable_web_page_preview=True) - - @app.on_message(filters.private & filters.command("banuserbyip")) - async def ban_user_by_ip(client: Client, message: Message): - if message.from_user is None or not is_admin(message.from_user.id): - return - - command = message.command - if not command or len(command) != 3: - await message.reply("使用方法: /banuserbyip [ip] [group_id]\n例如: `/banuserbyip 123.56.67.8 -1234567889`") - return - - ip_addr = command[1] - if not is_valid_ip_addr(ip_addr): - await message.reply("IP 地址格式错误") - return - - try: - group_id = int(command[2]) - except ValueError: - await message.reply("群组 ID 格式错误") - return - - user_ids = db.get_passed_user_ids_by_ip_and_group(ip_addr, group_id) - if len(user_ids) == 0: - await message.reply("没有找到在该群通过这个 IP 验证的用户") - return - - await message.reply(f"开始封禁 `{group_id}` 中通过 IP `{ip_addr}` 验证的 {len(user_ids)} 个用户...") - - success_count = 0 - failed_users = [] - for user_id in user_ids: - try: - await client.ban_chat_member(group_id, user_id) - success_count += 1 - except ChatAdminRequired: - await message.reply("封禁失败: Bot 在该群没有封禁用户权限") - return - except RPCError as e: - logging.error(f"Failed to ban user {user_id} in group {group_id}: {e}") - failed_users.append((user_id, str(e))) - - text = f"封禁完成: 成功 {success_count} 个,失败 {len(failed_users)} 个。" - if failed_users: - text += "\n\n失败列表:\n" - for user_id, error in failed_users: - text += f"- `{user_id}`: `{error}`\n" - - for chunk in split_long_message(text): - await message.reply(chunk) - - @app.on_message(filters.private & filters.forwarded) - async def get_user_record(client: Client, message: Message): - if not is_admin(message.from_user.id): - return - if message.forward_from is None: - await message.reply("该消息用户为频道或关闭了消息转发权限") - return - user_id = message.forward_from.id - logs = db.get_logs_by_user_id(user_id) - if len(logs) == 0: - await message.reply("没有找到相关记录") - return - text = "" - for log in logs: - text += str(log) - text += "\n" - await message.reply(text) - - @app.on_message(filters.group | filters.service) - # delete service message and message send from pending validation user - async def delete_service_message(client: Client, message: Message): - service_message_need_delete = [MessageServiceType.NEW_CHAT_MEMBERS, MessageServiceType.LEFT_CHAT_MEMBER] - if message.service: - if message.service in service_message_need_delete: - try: - await message.delete() - except MessageDeleteForbidden: - pass - return - else: - return - if not message.from_user: - # 频道发言不判断 - return - await asyncio.sleep(1) # 延迟2秒再判断 - if not _current_challenges.data: - # 如果当前没有验证任务,就不用判断了 - return - chat_id, user = message.chat.id, message.from_user - if _current_challenges.is_duplicate(user.id, chat_id): - await message.delete() - await client.send_message(chat_id=_channel, - text=_config["msg_message_deleted"].format( - targetuserid=str(user.id), - messageid=str(message.id), - groupid=str(chat_id), - grouptitle=str(message.chat.title), - )) - return - - @app.on_chat_join_request() - async def on_chat_join_request(client: Client, message: ChatJoinRequest): - user_id = message.from_user.id - chat_id = message.chat.id - group_config = get_group_config(message.chat.id) - challenge_id = "{chat}|{userid}".format(chat=message.chat.id, userid=user_id) - - challenge_data = _current_challenges.get(challenge_id) - - if challenge_data: - return # 如果已经有验证任务了,就不再触发这个流程 - # reCAPTCHA 验证 ---------------------------------------------------------------------------------------------- - if group_config['challenge_type'] == ChallengeType.recaptcha: - challenge = ReCAPTCHA() - timeout = group_config["challenge_timeout"] - try: - reply_message = await client.send_message(chat_id=user_id, text=f"请在{timeout}秒内点击下方按钮完成验证,您需要使用浏览器来完成,如果您在访问页面时出现问题,请尝试关闭的匿名代理\n\n", - reply_markup=InlineKeyboardMarkup(challenge.generate_auth_button())) - - challenge.message = reply_message - except UserIsBlocked: - await client.send_message(chat_id=_channel, - text=f"用户 `{user_id}` 屏蔽了机器人,无法发送验证消息") - pass - else: # 验证码验证 ------------------------------------------------------------------------------------------- - challenge = Math() - timeout = group_config["challenge_timeout"] - try: - reply_message = await client.send_message( - user_id, - group_config["msg_challenge_math_private"].format(challenge=challenge.qus(), timeout=timeout), - reply_markup=InlineKeyboardMarkup( - challenge.generate_join_request_button(chat_id=chat_id)), - ) - challenge.message = reply_message - except UserIsBlocked: - pass - # 开始计时 ----------------------------------------------------------------------------------------------------- - timeout_event = Timer( - join_request_challenge_timeout(client, message), - timeout=group_config["challenge_timeout"], - ) - _current_challenges[challenge_id] = (challenge, message.from_user.id, timeout_event) - - @app.on_chat_member_updated() - async def challenge_user(client: Client, message: ChatMemberUpdated): - if message.via_join_request: - return # 从 request 加入的用户不触发这个流程 - # 过滤掉非用户加群消息和频道新用户消息,同时确保 form_user 这个参数不是空的 - if not bool(message.new_chat_member) or bool(message.old_chat_member) or message.chat.type == ChatType.CHANNEL: - return - # 过滤掉管理员 ban 掉用户产生的加群消息 (Durov 这什么 jb api 赶紧分遗产了) - if message.from_user.id != message.new_chat_member.user.id and not message.new_chat_member.status == ChatMemberStatus.MEMBER: - return - - target = message.new_chat_member.user - group_config = get_group_config(message.chat.id) - chat_id = message.chat.id - user_id = target.id - - # 黑名单部分---------------------------------------------------------------------------------------------------- - if group_config["enable_global_blacklist"]: - db_user = db.get_user(user_id) - if db_user: - current_time = datetime.now() - since_last_attempt = (current_time - db_user.last_attempt).total_seconds() - if since_last_attempt > group_config[ - "global_timeout_user_blacklist_remove"]: - - # 存进 current_challenge 里面一小会,以供消息删除使用 - challenge = AutoKickCache() - challenge_id = "{chat}|{msg}".format(chat=message.chat.id, msg=None) - timeout_event = Timer( - challenge_timeout(client, message, None), - timeout=5, - ) - _current_challenges[challenge_id] = (challenge, message.from_user.id, timeout_event) - - await client.ban_chat_member(chat_id, target.id, until_date=current_time + timedelta(seconds=31)) - db.update_last_try(current_time, target.id) - db.try_count_plus_one(target.id) - try_count = int(db.get_try_count(target.id)) - try: - await client.send_message(_channel, - text=_config["msg_failed_auto_kick"].format( - targetuserid=str(target.id), - targetusername=str(target.username), - targetfirstname=str(target.first_name), - targetlastname=str(target.last_name), - groupid=str(chat_id), - grouptitle=str(message.chat.title), - lastattempt=str( - db_user.last_attempt.strftime("%Y-%m-%d %H:%M:%S")), - sincelastattempt=str( - timedelta(seconds=since_last_attempt)), - trycount=str(try_count) - )) - except Exception as e: - logging.error(str(e)) - return - else: - db.delete_users_by_id(target.id) - - # 入群验证部分-------------------------------------------------------------------------------------------------- - # 这里做一个判断让当出 bug 的时候不会重复弹出一车验证消息 - ch_id, ch_data = _current_challenges.get_by_user_and_chat_id(user_id, chat_id) - if ch_data: - challenge, target_id, timeout_event = ch_data - # 如果某个 bug 导致了重复的验证,就直接返回, 除非这个验证是 AutoKickCache - if not isinstance(challenge, AutoKickCache): - logging.error(f"重复的验证,用户id:{user_id},群组id:{chat_id}") - return - # 禁言用户 ---------------------------------------------------------------------------------------------------- - if message.from_user.id != target.id: - if target.is_self: - try: - await client.send_message( - message.chat.id, group_config["msg_self_introduction"]) - _me: User = await client.get_me() - try: - await client.send_message( - int(_channel), - _config["msg_into_group"].format( - groupid=str(message.chat.id), - grouptitle=str(message.chat.title) - ) - ) - except Exception as e: - logging.error(str(e)) - except ChannelPrivate: - return - return - try: - await client.restrict_chat_member( - chat_id=message.chat.id, - user_id=target.id, - permissions=ChatPermissions(can_send_messages=False)) - except ChatAdminRequired: - return - except RPCError: - await client.send_message( - message.chat.id, - "当前群组不是超级群组,Bot 无法工作,可能是成员过少。\n请尝试添加更多用户,或者禁言一个用户,让 Telegram 将该群转换为超级群组") - return - # reCAPTCHA 验证 ---------------------------------------------------------------------------------------------- - - if group_config['challenge_type'] == ChallengeType.recaptcha: - challenge = ReCAPTCHA() - timeout = group_config["challenge_timeout"] - reply_message = await client.send_message( - message.chat.id, - group_config["msg_challenge_recaptcha"].format(target_id=target.id, - timeout=timeout), - reply_markup=InlineKeyboardMarkup( - challenge.generate_button(group_config, chat_id)), - ) - else: # 验证码验证 ------------------------------------------------------------------------------------------- - challenge = Math() - timeout = group_config["challenge_timeout"] - reply_message = await client.send_message( - message.chat.id, - group_config["msg_challenge_math"].format(target_id=target.id, - timeout=timeout, - challenge=challenge.qus()), - reply_markup=InlineKeyboardMarkup( - challenge.generate_button(group_config)), - ) - - # 开始计时 ----------------------------------------------------------------------------------------------------- - challenge.message = reply_message - challenge_id = "{chat}|{msg}".format(chat=message.chat.id, msg=reply_message.id) - timeout_event = Timer( - challenge_timeout(client, message, reply_message.id), - timeout=group_config["challenge_timeout"], - ) - _current_challenges[challenge_id] = (challenge, message.from_user.id, timeout_event) - - @app.on_callback_query(private_math_challenge_filter) - async def private_math_challenge_callback(client: Client, callback_query: CallbackQuery): - query_data = str(callback_query.data) - query_id = callback_query.id - chat_id = int(query_data.split("|")[1]) - answer = query_data.split("|")[0] - user_id = callback_query.from_user.id - msg_id = callback_query.message.id - chat = await client.get_chat(chat_id) - chat_title = chat.title - user_username = callback_query.from_user.username - user_first_name = callback_query.from_user.first_name - user_last_name = callback_query.from_user.last_name - group_config = get_group_config(chat_id) - - # 获取验证信息----------------------------------------------------------------------------------------------- - ch_id = f"{chat.id}|{user_id}" - challenge_data = _current_challenges.get(ch_id) - - if challenge_data is None: - logging.error("challenge not found, challenge_id: {}".format(ch_id)) - return - else: - challenge, target_id, timeout_event = challenge_data - - # 响应用户操作------------------------------------------------------------------------------------------------ - _current_challenges.delete(ch_id) - - correct = str(challenge.ans()) == answer - if correct: - await client.approve_chat_join_request(chat_id, user_id) - await client.edit_message_text( - user_id, - msg_id, - group_config["msg_challenge_passed"], - reply_markup=None) - await client.send_message( - int(_channel), - _config["msg_passed_answer"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - else: - await client.edit_message_text( - user_id, - msg_id, - group_config["msg_challenge_failed"], - reply_markup=None, - ) - await client.send_message( - int(_channel), - _config["msg_failed_answer"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - await client.decline_chat_join_request(chat_id, user_id) - - - - @app.on_callback_query(admin_operate_filter) - async def admin_operate_callback(client: Client, callback_query: CallbackQuery): - query_data = str(callback_query.data) - query_id = callback_query.id - chat_id = callback_query.message.chat.id - user_id = callback_query.from_user.id - msg_id = callback_query.message.id - chat_title = callback_query.message.chat.title - user_username = callback_query.from_user.username - user_first_name = callback_query.from_user.first_name - user_last_name = callback_query.from_user.last_name - group_config = get_group_config(chat_id) - - # 获取验证信息----------------------------------------------------------------------------------------------- - - ch_id = "{chat}|{msg}".format(chat=chat_id, msg=msg_id) - challenge_data = _current_challenges.get(ch_id) - - if challenge_data is None: - logging.error("challenge not found, challenge_id: {}".format(ch_id)) - return - else: - challenge, target_id, timeout_event = challenge_data - - # 响应管理员操作------------------------------------------------------------------------------------------------ - - if query_data in ["+", "-"]: - admins = [] - async for m in client.get_chat_members(chat_id, filter=ChatMembersFilter.ADMINISTRATORS): - admins.append(m) - if not any([ - admin.user.id == user_id and - (admin.status == "creator" or admin.privileges.can_restrict_members) - for admin in admins - ]): - await client.answer_callback_query( - query_id, group_config["msg_permission_denied"]) - return - _current_challenges.delete(ch_id) - timeout_event.stop() - if query_data == "+": - try: - await client.restrict_chat_member( - chat_id, - target_id, - permissions=ChatPermissions( - can_send_messages=True, - can_send_other_messages=True, - can_send_polls=True, - can_add_web_page_previews=True, - can_change_info=True, - can_invite_users=True, - can_pin_messages=True)) - except ChatAdminRequired: - await client.answer_callback_query( - query_id, group_config["msg_bot_no_permission"]) - return - - await client.edit_message_text( - chat_id, - msg_id, - group_config["msg_approved"].format(user=user_first_name), - reply_markup=None, - ) - _me: User = await client.get_me() - try: - await client.send_message( - int(_channel), - _config["msg_passed_admin"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - except Exception as e: - logging.error(str(e)) - else: - try: - await client.ban_chat_member(chat_id, target_id) - except ChatAdminRequired: - await client.answer_callback_query( - query_id, group_config["msg_bot_no_permission"]) - return - await client.edit_message_text( - chat_id, - msg_id, - group_config["msg_refused"].format(user=user_first_name), - reply_markup=None, - ) - Timer( - client.delete_messages(chat_id, msg_id), - group_config["delete_failed_challenge_interval"] - ) - _me: User = await client.get_me() - try: - await client.send_message( - int(_channel), - _config["msg_failed_admin"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - except Exception as e: - logging.error(str(e)) - await client.answer_callback_query(query_id) - return - - @app.on_callback_query() - async def challenge_answer_callback(client: Client, callback_query: CallbackQuery): - query_data = str(callback_query.data) - query_id = callback_query.id - chat_id = callback_query.message.chat.id - user_id = callback_query.from_user.id - msg_id = callback_query.message.id - chat_title = callback_query.message.chat.title - user_username = callback_query.from_user.username - user_first_name = callback_query.from_user.first_name - user_last_name = callback_query.from_user.last_name - group_config = get_group_config(chat_id) - - # 获取验证信息----------------------------------------------------------------------------------------------- - - ch_id = "{chat}|{msg}".format(chat=chat_id, msg=msg_id) - - challenge_data = _current_challenges.get(ch_id) - - if challenge_data is None: - logging.error("challenge not found, challenge_id: {}".format(ch_id)) - return - else: - challenge, target_id, timeout_event = challenge_data - - # 让捣蛋的一边玩去 --------------------------------------------------------------------------------- - - if user_id != target_id: - await client.answer_callback_query( - query_id, group_config["msg_challenge_not_for_you"]) - return None - timeout_event.stop() - - # 分析的没错的话这里应该是先给用户解开再根据回答对错处理 ----------------------------------------------------------- - - try: - await client.restrict_chat_member( - chat_id, - target_id, - permissions=ChatPermissions( - can_send_messages=True, - can_send_other_messages=True, - can_send_polls=True, - can_add_web_page_previews=True, - can_change_info=True, - can_invite_users=True, - can_pin_messages=True)) - except ChatAdminRequired: - pass - - _current_challenges.delete(ch_id) - - correct = str(challenge.ans()) == query_data - if correct: - try: - await client.edit_message_text( - chat_id, - msg_id, - group_config["msg_challenge_passed"], - reply_markup=None) - except MessageNotModified as e: - await client.send_message(int(_channel), - 'Bot 运行时发生异常: `' + str(e) + "`") - try: - await client.send_message( - int(_channel), - _config["msg_passed_answer"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - except Exception as e: - logging.error(str(e)) - else: - try: - await client.edit_message_text( - chat_id, - msg_id, - group_config["msg_challenge_failed"], - reply_markup=None, - ) - try: - await client.send_message( - int(_channel), - _config["msg_failed_answer"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) - ) - except Exception as e: - logging.error(str(e)) - except ChatAdminRequired: - return - - if group_config["challenge_failed_action"] == FailedAction.ban: - await client.ban_chat_member(chat_id, user_id) - else: - # kick - await client.ban_chat_member(chat_id, user_id, until_date=datetime.now() + timedelta(seconds=31)) - logging.info(f"{user_id} unbanned") - - if group_config["delete_failed_challenge"]: - Timer( - client.delete_messages(chat_id, msg_id), - group_config["delete_failed_challenge_interval"], - ) - if group_config["delete_passed_challenge"]: - Timer( - client.delete_messages(chat_id, msg_id), - group_config["delete_passed_challenge_interval"], - ) - - async def join_request_challenge_timeout(client: Client, message): - chat_id = message.chat.id - user_id = message.from_user.id - chat_title = message.chat.title - user_username = message.from_user.username - user_first_name = message.from_user.first_name - user_last_name = message.from_user.last_name - ch_id = f"{chat_id}|{user_id}" - challenge_data = _current_challenges.get(ch_id) - - if challenge_data is None: - logging.error("challenge not found, challenge_id: {}".format(ch_id)) - return - else: - challenge, target_id, timeout_event = challenge_data - _current_challenges.delete(f"{chat_id}|{user_id}") - await client.decline_chat_join_request(chat_id, user_id) - if challenge.message is not None: - await client.edit_message_text(chat_id=user_id, - message_id=challenge.message.id, - text="验证已超时,请重新加群尝试", - reply_markup=None) - await client.send_message(chat_id=_channel, - text=_config["msg_failed_timeout"].format( - targetuserid=str(user_id), - targetusername=str(user_username), - targetfirstname=str(user_first_name), - targetlastname=str(user_last_name), - groupid=str(chat_id), - grouptitle=str(chat_title) - )) - - - async def challenge_timeout(client: Client, message, reply_id): - chat_id = message.chat.id - from_id = message.from_user.id - chat_title = message.chat.title - user_username = message.from_user.username - user_first_name = message.from_user.first_name - user_last_name = message.from_user.last_name - group_config = get_group_config(chat_id) - _current_challenges.delete("{chat}|{msg}".format(chat=chat_id, - msg=reply_id)) - if reply_id is None: - # 删除黑名单缓存 - return - - # TODO try catch - await client.edit_message_text( - chat_id=chat_id, - message_id=reply_id, - text=group_config["msg_challenge_failed"], - reply_markup=None, - ) - - await client.send_message(chat_id=_channel, - text=_config["msg_failed_timeout"].format( - targetuserid=str(from_id), - targetusername=str(user_username), - targetfirstname=str(user_first_name), - targetlastname=str(user_last_name), - groupid=str(chat_id), - grouptitle=str(chat_title) - )) - - if group_config["challenge_timeout_action"] == FailedAction.ban: - await client.ban_chat_member(chat_id, from_id) - elif group_config["challenge_timeout_action"] == FailedAction.kick: - await client.ban_chat_member(chat_id, from_id, until_date=datetime.now() + timedelta(seconds=31)) - logging.info(f"{from_id} unbanned") - else: - pass - - if group_config["delete_failed_challenge"]: - Timer( - client.delete_messages(chat_id, reply_id), - group_config["delete_failed_challenge_interval"], - ) - - if group_config["enable_global_blacklist"]: - db.new_blacklist(datetime.now(), from_id) - -def _main(): - # db.setup() - global _channel, _start_message, _config - load_config() - _start_message = _config["msg_start_message"] - _proxy_ip = _config["proxy_addr"].strip() - _proxy_port = _config["proxy_port"].strip() - if _proxy_ip and _proxy_port: - _app = Client("bot", - bot_token=_token, - api_id=_api_id, - api_hash=_api_hash, - proxy=dict(hostname=_proxy_ip, port=int(_proxy_port))) - else: - _app = Client("bot", - bot_token=_token, - api_id=_api_id, - api_hash=_api_hash) - try: - # start web - tt = threading.Thread( - target=start_web, name="WebThread", args=(_app,)) - tt.daemon = True - logging.info('Starting webapi ....') - tt.start() - - # start bot - _update(_app) - _app.run() - except KeyboardInterrupt: - quit() - except Exception as e: - logging.error(e) - _main() +"""Backward-compatible executable entry point.""" +from captcha_bot.cli import main if __name__ == "__main__": - _main() + main(default_command="run") diff --git a/model.py b/model.py deleted file mode 100644 index c63dc14..0000000 --- a/model.py +++ /dev/null @@ -1,116 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text, BigInteger -from sqlalchemy.orm import relationship, validates -from sqlalchemy.ext.declarative import declarative_base -from datetime import datetime -from enum import StrEnum - -Base = declarative_base() - - -class ChallengeType(StrEnum): - recaptcha = "recaptcha" - math = "math" - - -class FailedAction(StrEnum): - ban = "ban" - kick = "kick" - mute = "mute" - - -class RecaptchaLogAction(StrEnum): - PageVisit = "PageVisit" - Failed = "Failed" - Passed = "Passed" - - -class BlacklistUser(Base): - __tablename__ = 'blacklist_user' - user_id = Column(BigInteger, primary_key=True, unique=True, nullable=False) - last_attempt = Column(DateTime, nullable=False, default=datetime.now) - attempt_count = Column(Integer, nullable=False, default=1) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - def __repr__(self): - return "" % ( - self.user_id, self.last_attempt, self.attempt_count, self.created_at, self.updated_at) - - -class GroupConfig(Base): - __tablename__ = 'group_config' - chat_id = Column(BigInteger, primary_key=True, unique=True, nullable=False) - timeout = Column(Integer, nullable=False, default=300) - challenge_type = Column(String(10), nullable=False, default=ChallengeType.recaptcha) - failed_action = Column(String(10), nullable=False, default=FailedAction.kick) - timeout_action = Column(String(10), nullable=False, default=FailedAction.kick) - third_party_blacklist = Column(Boolean, nullable=False, default=False) - global_blacklist = Column(Boolean, nullable=False, default=True) - - @validates('challenge_type') - def validate_challenge_type(self, key, challenge_type): - if challenge_type not in (m.value for m in ChallengeType): - raise ValueError(f"Invalid challenge type value: {challenge_type}") - return challenge_type - - @validates('failed_action') - def validate_failed_action(self, key, failed_action): - if failed_action not in (m.value for m in FailedAction): - print(failed_action) - raise ValueError(f"Invalid failed action value: {failed_action}") - return failed_action - - @validates('timeout_action') - def validate_timeout_action(self, key, timeout_action): - if timeout_action not in (m.value for m in FailedAction): - raise ValueError(f"Invalid timeout action value: {timeout_action}") - return timeout_action - - @validates('third_party_blacklist') - def validate_third_party_blacklist(self, key, third_party_blacklist): - if not isinstance(third_party_blacklist, bool): - raise ValueError(f"Invalid third party blacklist value: {third_party_blacklist}") - return third_party_blacklist - - @validates('global_blacklist') - def validate_global_blacklist(self, key, global_blacklist): - if not isinstance(global_blacklist, bool): - raise ValueError(f"Invalid global blacklist value: {global_blacklist}") - return global_blacklist - - def __repr__(self): - return "" % ( - self.chat_id, self.timeout, self.challenge_type, self.failed_action, self.timeout_action, - self.third_party_blacklist, self.global_blacklist) - - -class RecaptchaLog(Base): - __tablename__ = 'recaptcha_log' - id = Column(Integer, primary_key=True, unique=True, nullable=False) - group_id = Column(BigInteger, nullable=False) - user_id = Column(BigInteger, nullable=False) - challenge_id = Column(String(64), nullable=False) - ip_addr = Column(String(64), nullable=False) - user_agent = Column(Text, nullable=False) - action = Column(String(20), nullable=False) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - def __repr__(self): - return "" % ( - self.id, self.group_id, self.user_id, self.challenge_id, self.ip_addr, self.user_agent, self.action, - self.created_at, self.updated_at) - - def __str__(self): - return f"IP地址: `{self.ip_addr}`\nUA:`{self.user_agent}`\n状态:`{self.action}`\n时间:`{self.created_at}`\n" - -# -# class AIConfig(Base): -# chat_id = Column(BigInteger, primary_key=True, unique=True, nullable=False) -# openai_api_key = Column(String(64), nullable=False) -# admin_id = Column(BigInteger, nullable=False) -# first_message_check = Column(Boolean, nullable=False, default=False) -# report_message_check = Column(Boolean, nullable=False, default=False) -# -# created_at = Column(DateTime, default=datetime.now) -# updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/pyproject.toml b/pyproject.toml index 5bfd5b0..3f9cdd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,63 @@ [project] name = "telegram-captcha-bot-pyrogram" -version = "0.1.0" -description = "Add your description here" +version = "1.0.0" +description = "An asynchronous Telegram CAPTCHA and moderation bot" readme = "README.md" requires-python = ">=3.14" dependencies = [ - "alembic==1.18.4", - "flask[async]==3.1.3", - "kurigram==2.2.22", - "openai==2.36.0", - "psycopg2-binary~=2.9.9", - "requests==2.33.1", - "sqlalchemy~=2.0.23", - "tgcrypto~=1.2.5", - "waitress==3.0.2", + "alembic>=1.18,<2", + "fastapi>=0.139,<1", + "httpx>=0.28,<1", + "jinja2>=3.1,<4", + "kurigram>=2.2,<3", + "psycopg[binary]>=3.3,<4", + "pydantic>=2.13,<3", + "python-multipart>=0.0.22,<1", + "sqlalchemy[asyncio]>=2.0.49,<3", + "tgcrypto>=1.2.5,<2", + "tomli-w>=1.2,<2", + "uvicorn>=0.51,<1", ] + +[project.scripts] +captcha-bot = "captcha_bot.cli:main" + +[dependency-groups] +dev = [ + "pip-audit>=2.10,<3", + "pytest>=9,<10", + "pytest-asyncio>=1.3,<2", + "pytest-cov>=7,<8", + "ruff>=0.15,<1", + "ty>=0.0.21,<1", +] + +[build-system] +requires = ["hatchling>=1.29,<2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/captcha_bot"] + +[tool.hatch.build.targets.wheel.force-include] +"templates" = "captcha_bot/templates" +"static" = "captcha_bot/static" + +[tool.ruff] +target-version = "py314" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"] +allowed-confusables = [",", "。", ":", ";", "!", "?"] + +[tool.pytest.ini_options] +addopts = "-q --strict-markers --strict-config --cov=src/captcha_bot --cov-report=term-missing --cov-fail-under=80" +asyncio_mode = "auto" +filterwarnings = ["error"] +markers = ["integration: requires a disposable PostgreSQL database"] +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["captcha_bot"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 78848d76bc3691b4cfd942f6b25f7bf56d02868a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 362 zcmZvYy9&Zk5JaaI{1hb*6jE4-t&M0W7BR#aVjd)30ugq9 z%qw}y2{mH&YJReKHb>;l#NzFj;agtEoHbThV$MVnjx=-T?zd7Zk9#RsNJDhbnUNXV o$$f7xqvC{)tmI$9`;oZ?EA FastAPI: + path = Path(config_path).resolve() + initial_settings = load_settings(path) + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + config = ConfigStore(path, initial_settings) + database = Database(initial_settings) + registry = ChallengeRegistry() + http: httpx.AsyncClient | None = None + client: Client | None = None + try: + await database.ensure_schema_current() + repository = Repository(database.sessions) + http = httpx.AsyncClient() + proxy = None + if initial_settings.proxy.host and initial_settings.proxy.port: + proxy = { + "scheme": "socks5", + "hostname": initial_settings.proxy.host, + "port": initial_settings.proxy.port, + } + client = Client( + "bot", + bot_token=initial_settings.bot.token.get_secret_value(), + api_id=initial_settings.bot.api_id, + api_hash=initial_settings.bot.api_hash.get_secret_value(), + proxy=proxy, + ) + policies = PolicyService(config, repository) + context = AppContext( + config=config, + database=database, + repository=repository, + registry=registry, + http=http, + policies=policies, + turnstile=TurnstileService(http, config), + client=client, + ) + register_admin_handlers(client, context) + register_challenge_handlers(client, context) + app.state.context = context + await client.start() + logger.info("Telegram client and web service started") + yield + finally: + await registry.shutdown() + if client is not None and client.is_connected: + await client.stop() + if http is not None: + await http.aclose() + await database.close() + logger.info("application shutdown complete") + + app = FastAPI(title="Telegram CAPTCHA Bot", debug=initial_settings.web.debug, lifespan=lifespan) + workspace_root = Path(__file__).resolve().parents[2] + asset_root = workspace_root if (workspace_root / "templates").is_dir() else Path(__file__).resolve().parent + app.mount("/static", StaticFiles(directory=asset_root / "static"), name="static") + register_web_routes(app, Jinja2Templates(directory=asset_root / "templates")) + return app diff --git a/src/captcha_bot/challenges.py b/src/captcha_bot/challenges.py new file mode 100644 index 0000000..8653e13 --- /dev/null +++ b/src/captcha_bot/challenges.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import asyncio +import logging +import random +import uuid +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from enum import StrEnum + +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +logger = logging.getLogger(__name__) + + +class ChallengeKind(StrEnum): + MATH = "math" + TURNSTILE = "turnstile" + AUTO_KICK = "auto_kick" + + +@dataclass(slots=True) +class ChallengeSession: + key: str + kind: ChallengeKind + chat_id: int + user_id: int + chat_title: str + message_id: int | None = None + answer: int | None = None + turnstile_id: str | None = None + join_request: bool = False + timeout_task: asyncio.Task[None] | None = field(default=None, repr=False) + + +TimeoutCallback = Callable[[ChallengeSession], Awaitable[None]] + + +class ChallengeRegistry: + def __init__(self) -> None: + self._sessions: dict[str, ChallengeSession] = {} + self._turnstile_keys: dict[str, str] = {} + self._lock = asyncio.Lock() + + async def register(self, session: ChallengeSession, delay_seconds: float, callback: TimeoutCallback) -> bool: + async with self._lock: + if session.key in self._sessions or any( + existing.chat_id == session.chat_id and existing.user_id == session.user_id + for existing in self._sessions.values() + ): + return False + self._sessions[session.key] = session + if session.turnstile_id: + self._turnstile_keys[session.turnstile_id] = session.key + session.timeout_task = asyncio.create_task( + self._run_timeout(session.key, delay_seconds, callback), + name=f"challenge-timeout:{session.key}", + ) + return True + + async def _run_timeout(self, key: str, delay_seconds: float, callback: TimeoutCallback) -> None: + try: + await asyncio.sleep(delay_seconds) + session = await self.claim(key, cancel_timeout=False) + if session is not None: + await callback(session) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("challenge timeout failed", extra={"challenge_key": key}) + + async def get(self, key: str) -> ChallengeSession | None: + async with self._lock: + return self._sessions.get(key) + + async def get_by_turnstile_id(self, turnstile_id: str) -> ChallengeSession | None: + async with self._lock: + key = self._turnstile_keys.get(turnstile_id) + return self._sessions.get(key) if key else None + + async def find(self, user_id: int, chat_id: int) -> ChallengeSession | None: + async with self._lock: + return next( + ( + session + for session in self._sessions.values() + if session.user_id == user_id and session.chat_id == chat_id + ), + None, + ) + + async def claim(self, key: str, *, cancel_timeout: bool = True) -> ChallengeSession | None: + async with self._lock: + session = self._sessions.pop(key, None) + if session is None: + return None + if session.turnstile_id: + self._turnstile_keys.pop(session.turnstile_id, None) + task = session.timeout_task + if cancel_timeout and task and task is not asyncio.current_task() and not task.done(): + task.cancel() + return session + + async def claim_by_turnstile_id(self, turnstile_id: str) -> ChallengeSession | None: + async with self._lock: + key = self._turnstile_keys.get(turnstile_id) + return await self.claim(key) if key else None + + async def contains_user(self, user_id: int, chat_id: int) -> bool: + return await self.find(user_id, chat_id) is not None + + async def shutdown(self) -> None: + async with self._lock: + sessions = list(self._sessions.values()) + self._sessions.clear() + self._turnstile_keys.clear() + tasks = [ + session.timeout_task for session in sessions if session.timeout_task and not session.timeout_task.done() + ] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + async def count(self) -> int: + async with self._lock: + return len(self._sessions) + + +class MathChallenge: + def __init__(self, rng: random.Random | None = None) -> None: + self._rng = rng or random.SystemRandom() + operation = self._rng.choice(["加上(plus)", "减去(minus)", "乘以(time)", "除以(divide)"]) + if operation in {"加上(plus)", "减去(minus)"}: + a, b = sorted((self._rng.randint(0, 50), self._rng.randint(0, 50)), reverse=True) + answer = a + b if operation == "加上(plus)" else a - b + elif operation == "乘以(time)": + a, b = self._rng.randint(0, 9), self._rng.randint(0, 9) + answer = a * b + else: + answer, b = self._rng.randint(0, 9), self._rng.randint(1, 9) + a = answer * b + choices = self._rng.sample(range(100), self._rng.randint(3, 5)) + if answer not in choices: + choices[0] = answer + self._rng.shuffle(choices) + self.a, self.b, self.operation, self.answer, self.choices = a, b, operation, answer, choices + + @property + def question(self) -> str: + return ( + f"{self.a} {self.operation} {self.b} 的结果是多少?\n" + f"What is the result of {self.a} {self.operation} {self.b}" + ) + + def group_keyboard(self, approve: str, refuse: str) -> InlineKeyboardMarkup: + answers = [InlineKeyboardButton(str(choice), callback_data=str(choice).encode()) for choice in self.choices] + return InlineKeyboardMarkup( + [ + answers, + [InlineKeyboardButton(approve, callback_data=b"+"), InlineKeyboardButton(refuse, callback_data=b"-")], + ] + ) + + def join_request_keyboard(self, chat_id: int) -> InlineKeyboardMarkup: + answers = [ + InlineKeyboardButton(str(choice), callback_data=f"{choice}|{chat_id}".encode()) for choice in self.choices + ] + return InlineKeyboardMarkup([answers]) + + +def new_turnstile_id() -> str: + return uuid.uuid4().hex + + +def turnstile_auth_url(base_url: str, turnstile_id: str) -> str: + return f"{base_url.rstrip('/')}/recaptcha?challenge={turnstile_id}" + + +def turnstile_auth_keyboard(url: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[InlineKeyboardButton(text="开始验证", url=url)]]) + + +def turnstile_group_keyboard(bot_username: str, chat_id: int, approve: str, refuse: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [InlineKeyboardButton(text="开始验证", url=f"https://t.me/{bot_username}?start={chat_id}")], + [InlineKeyboardButton(approve, callback_data=b"+"), InlineKeyboardButton(refuse, callback_data=b"-")], + ] + ) diff --git a/src/captcha_bot/cli.py b/src/captcha_bot/cli.py new file mode 100644 index 0000000..58ae82b --- /dev/null +++ b/src/captcha_bot/cli.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys +from collections.abc import Coroutine +from pathlib import Path +from typing import Any + +import uvicorn +from pydantic import ValidationError + +from captcha_bot.app import create_app +from captcha_bot.config import load_settings, migrate_legacy_config +from captcha_bot.db.importer import import_csv +from captcha_bot.db.repository import Database, Repository + + +def parser() -> argparse.ArgumentParser: + command_parser = argparse.ArgumentParser(prog="captcha-bot") + subcommands = command_parser.add_subparsers(dest="command") + + run = subcommands.add_parser("run", help="run the Telegram bot and web service") + run.add_argument("--config", default="config.toml", type=Path) + + migrate = subcommands.add_parser("migrate-config", help="convert config.ini and config.json to TOML") + migrate.add_argument("--ini", default="config.ini", type=Path) + migrate.add_argument("--json", default="config.json", type=Path) + migrate.add_argument("--output", default="config.toml", type=Path) + + importer = subcommands.add_parser("import-csv", help="import legacy database CSV data") + importer.add_argument("file", type=Path) + importer.add_argument("--table", required=True, choices=("group-config", "blacklist-user")) + importer.add_argument("--config", default="config.toml", type=Path) + return command_parser + + +async def _import_command(config_path: Path, file_path: Path, table: str) -> int: + settings = load_settings(config_path) + database = Database(settings) + try: + await database.ensure_schema_current() + return await import_csv(Repository(database.sessions), file_path, table) + finally: + await database.close() + + +def _run_async[T](coroutine: Coroutine[Any, Any, T]) -> T: + loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else None + return asyncio.run(coroutine, loop_factory=loop_factory) + + +def _serve_application(application: Any, host: str, port: int, debug: bool) -> None: + config = uvicorn.Config( + application, + host=host, + port=port, + log_level="debug" if debug else "info", + proxy_headers=False, + ) + _run_async(uvicorn.Server(config).serve()) + + +def main(default_command: str | None = None) -> None: + arguments = sys.argv[1:] + if not arguments and default_command: + arguments = [default_command] + args = parser().parse_args(arguments) + try: + if args.command == "migrate-config": + ignored = migrate_legacy_config(args.ini, args.json, args.output) + print(f"已写入 {args.output};忽略的遗留字段: {', '.join(ignored)}") + return + if args.command == "import-csv": + os.environ["CAPTCHA_BOT_CONFIG"] = str(args.config.resolve()) + count = _run_async(_import_command(args.config, args.file, args.table)) + print(f"已导入 {count} 条记录") + return + if args.command == "run": + settings = load_settings(args.config) + os.environ["CAPTCHA_BOT_CONFIG"] = str(args.config.resolve()) + logging.basicConfig( + level=logging.DEBUG if settings.web.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + _serve_application( + create_app(args.config), + settings.web.host, + settings.web.port, + settings.web.debug, + ) + return + parser().print_help() + except (OSError, ValueError, ValidationError, RuntimeError) as exc: + raise SystemExit(f"error: {exc}") from exc diff --git a/src/captcha_bot/config.py b/src/captcha_bot/config.py new file mode 100644 index 0000000..41d482f --- /dev/null +++ b/src/captcha_bot/config.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import asyncio +import json +import os +import string +import tomllib +from configparser import ConfigParser +from enum import StrEnum +from ipaddress import ip_network +from pathlib import Path +from typing import Any, Self + +import tomli_w +from pydantic import ( + AnyHttpUrl, + BaseModel, + ConfigDict, + Field, + SecretStr, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) +from sqlalchemy.engine import make_url +from sqlalchemy.exc import ArgumentError + + +class ChallengeType(StrEnum): + MATH = "math" + RECAPTCHA = "recaptcha" + + +class FailedAction(StrEnum): + BAN = "ban" + KICK = "kick" + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class BotSettings(StrictModel): + username: str = Field(min_length=1) + token: SecretStr + api_id: int = Field(gt=0, strict=True) + api_hash: SecretStr + admin_ids: tuple[int, ...] = Field(min_length=1) + log_channel_id: int = Field(strict=True) + + @field_validator("admin_ids", mode="before") + @classmethod + def validate_admin_ids(cls, value: object) -> object: + if not isinstance(value, (list, tuple)) or any(type(item) is not int or item <= 0 for item in value): + raise ValueError("admin_ids must contain positive integers") + return value + + @field_validator("log_channel_id") + @classmethod + def validate_log_channel_id(cls, value: int) -> int: + if value == 0: + raise ValueError("log_channel_id must not be zero") + return value + + +class DatabaseSettings(StrictModel): + url: SecretStr + pool_size: int = Field(default=5, gt=0, le=50, strict=True) + max_overflow: int = Field(default=10, ge=0, le=100, strict=True) + + @field_validator("url") + @classmethod + def validate_postgresql(cls, value: SecretStr) -> SecretStr: + url = value.get_secret_value() + try: + parsed = make_url(url) + except ArgumentError as exc: + raise ValueError("invalid PostgreSQL URL") from exc + if parsed.drivername not in {"postgresql", "postgresql+psycopg"} or not parsed.database: + raise ValueError("only PostgreSQL URLs are supported") + return value + + +class WebSettings(StrictModel): + host: str = "127.0.0.1" + port: int = Field(default=5000, ge=1, le=65535, strict=True) + debug: bool = Field(default=False, strict=True) + trusted_proxy_cidrs: tuple[str, ...] = ("127.0.0.1/32", "::1/128") + + @field_validator("trusted_proxy_cidrs") + @classmethod + def validate_networks(cls, values: tuple[str, ...]) -> tuple[str, ...]: + for value in values: + ip_network(value, strict=False) + return values + + +class ProxySettings(StrictModel): + host: str | None = None + port: int | None = Field(default=None, ge=1, le=65535, strict=True) + + @model_validator(mode="after") + def validate_pair(self) -> Self: + if (self.host is None) != (self.port is None): + raise ValueError("proxy host and port must be set together") + return self + + +class TurnstileSettings(StrictModel): + site_key: str = Field(min_length=1) + secret_key: SecretStr + base_url: str + verify_url: str = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + timeout_seconds: float = Field(default=10.0, gt=0, le=60, strict=True) + + @field_validator("base_url", "verify_url") + @classmethod + def validate_http_url(cls, value: str) -> str: + return str(TypeAdapter(AnyHttpUrl).validate_python(value)).rstrip("/") + + +class DefaultPolicy(StrictModel): + challenge_timeout: int = Field(default=60, gt=0, le=3600, strict=True) + challenge_timeout_action: FailedAction = FailedAction.KICK + challenge_failed_action: FailedAction = FailedAction.KICK + challenge_type: ChallengeType = ChallengeType.MATH + delete_passed_challenge: bool = Field(default=True, strict=True) + delete_passed_challenge_interval: int = Field(default=15, ge=0, le=3600, strict=True) + delete_failed_challenge: bool = Field(default=True, strict=True) + delete_failed_challenge_interval: int = Field(default=15, ge=0, le=3600, strict=True) + enable_global_blacklist: bool = Field(default=True, strict=True) + global_timeout_user_blacklist_remove: int = Field(default=600, gt=0, strict=True) + + +class Messages(StrictModel): + start_message: str + passed_answer: str + passed_admin: str + failed_answer: str + failed_timeout: str + failed_admin: str + failed_auto_kick: str + message_deleted: str + into_group: str + leave_group: str + leave_message: str + self_introduction: str + challenge_not_for_you: str + challenge_math: str + challenge_math_private: str + challenge_recaptcha: str + challenge_passed: str + challenge_failed: str + approve_manually: str + refuse_manually: str + permission_denied: str + bot_no_permission: str + approved: str + refused: str + + @model_validator(mode="after") + def validate_placeholders(self) -> Self: + allowed = { + "targetuserid", + "targetusername", + "targetfirstname", + "targetlastname", + "groupid", + "grouptitle", + "messageid", + "lastattempt", + "sincelastattempt", + "trycount", + "target_id", + "timeout", + "challenge", + "user", + } + formatter = string.Formatter() + for name, value in self: + try: + fields = {field for _, field, _, _ in formatter.parse(value) if field} + except ValueError as exc: + raise ValueError(f"messages.{name} has invalid format syntax") from exc + unknown = fields - allowed + if unknown: + raise ValueError(f"messages.{name} has unknown placeholders: {sorted(unknown)}") + return self + + +class Settings(StrictModel): + bot: BotSettings + database: DatabaseSettings + web: WebSettings = WebSettings() + proxy: ProxySettings = ProxySettings() + turnstile: TurnstileSettings + defaults: DefaultPolicy = DefaultPolicy() + messages: Messages + + def startup_fingerprint(self) -> tuple[object, ...]: + return (self.bot, self.database, self.web, self.proxy, self.turnstile) + + +def load_settings(path: str | Path) -> Settings: + config_path = Path(path) + with config_path.open("rb") as file: + raw = tomllib.load(file) + return Settings.model_validate(raw) + + +class ConfigStore: + def __init__(self, path: str | Path, settings: Settings) -> None: + self.path = Path(path) + self._settings = settings + self._lock = asyncio.Lock() + + @property + def settings(self) -> Settings: + return self._settings + + async def reload(self) -> Settings: + candidate = load_settings(self.path) + async with self._lock: + if candidate.startup_fingerprint() != self._settings.startup_fingerprint(): + raise ValueError("启动级配置已变化,请重启服务;当前配置未修改") + self._settings = candidate + return candidate + + +def _read_legacy(ini_path: Path, json_path: Path) -> tuple[ConfigParser, dict[str, Any]]: + ini = ConfigParser() + if not ini.read(ini_path, encoding="utf-8"): + raise FileNotFoundError(ini_path) + with json_path.open(encoding="utf-8") as file: + legacy_json: dict[str, Any] = json.load(file) + return ini, legacy_json + + +def _legacy_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def migrate_legacy_config(ini_path: str | Path, json_path: str | Path, output_path: str | Path) -> list[str]: + ini_file, json_file, output_file = Path(ini_path), Path(json_path), Path(output_path) + if output_file.exists(): + raise FileExistsError(f"refusing to overwrite existing file: {output_file}") + ini, legacy = _read_legacy(ini_file, json_file) + defaults = legacy["*"] + ignored = ["msg_passed_mercy", "enable_third_party_blacklist", "flask_secret_key"] + + def legacy_action(name: str) -> str: + action = str(defaults[name]).lower() + if action == "mute": + ignored.append(f"{name}=mute (mapped to kick)") + return "kick" + return action + + admin_ids = tuple(int(value.strip()) for value in ini.get("bot", "admin").split(",")) + proxy_host = str(legacy.get("proxy_addr", "")).strip() or None + proxy_port_raw = str(legacy.get("proxy_port", "")).strip() + data: dict[str, Any] = { + "bot": { + "username": ini.get("bot", "username"), + "token": ini.get("bot", "token"), + "api_id": ini.getint("bot", "api_id"), + "api_hash": ini.get("bot", "api_hash"), + "admin_ids": list(admin_ids), + "log_channel_id": ini.getint("bot", "channel"), + }, + "database": {"url": ini.get("db", "conn_str")}, + "web": { + "host": ini.get("web", "flask_host", fallback="127.0.0.1"), + "port": ini.getint("web", "flask_port", fallback=5000), + "debug": ini.getboolean("web", "development", fallback=False), + "trusted_proxy_cidrs": ["127.0.0.1/32", "::1/128"], + }, + "proxy": ({"host": proxy_host, "port": int(proxy_port_raw)} if proxy_host and proxy_port_raw else {}), + "turnstile": { + "site_key": ini.get("reCAPTCHA", "site_key"), + "secret_key": ini.get("reCAPTCHA", "secret_key"), + "base_url": ini.get("reCAPTCHA", "base_url").rstrip("/"), + }, + "defaults": { + "challenge_timeout": int(defaults["challenge_timeout"]), + "challenge_timeout_action": legacy_action("challenge_timeout_action"), + "challenge_failed_action": legacy_action("challenge_failed_action"), + "challenge_type": str(defaults["challenge_type"]).lower(), + "delete_passed_challenge": _legacy_bool(defaults["delete_passed_challenge"]), + "delete_passed_challenge_interval": int(defaults["delete_passed_challenge_interval"]), + "delete_failed_challenge": _legacy_bool(defaults["delete_failed_challenge"]), + "delete_failed_challenge_interval": int(defaults["delete_failed_challenge_interval"]), + "enable_global_blacklist": _legacy_bool(defaults["enable_global_blacklist"]), + "global_timeout_user_blacklist_remove": int(defaults["global_timeout_user_blacklist_remove"]), + }, + "messages": { + "start_message": legacy["msg_start_message"], + "passed_answer": legacy["msg_passed_answer"], + "passed_admin": legacy["msg_passed_admin"], + "failed_answer": legacy["msg_failed_answer"], + "failed_timeout": legacy["msg_failed_timeout"], + "failed_admin": legacy["msg_failed_admin"], + "failed_auto_kick": legacy["msg_failed_auto_kick"], + "message_deleted": legacy["msg_message_deleted"], + "into_group": legacy["msg_into_group"], + "leave_group": legacy["msg_leave_group"], + "leave_message": legacy["msg_leave_msg"], + "self_introduction": defaults["msg_self_introduction"], + "challenge_not_for_you": defaults["msg_challenge_not_for_you"], + "challenge_math": defaults["msg_challenge_math"], + "challenge_math_private": defaults["msg_challenge_math_private"], + "challenge_recaptcha": defaults["msg_challenge_recaptcha"], + "challenge_passed": defaults["msg_challenge_passed"], + "challenge_failed": defaults["msg_challenge_failed"], + "approve_manually": defaults["msg_approve_manually"], + "refuse_manually": defaults["msg_refuse_manually"], + "permission_denied": defaults["msg_permission_denied"], + "bot_no_permission": defaults["msg_bot_no_permission"], + "approved": defaults["msg_approved"], + "refused": defaults["msg_refused"], + }, + } + Settings.model_validate(data) + output_file.write_bytes(tomli_w.dumps(data).encode("utf-8")) + if os.name == "posix": + output_file.chmod(0o600) + return ignored + + +__all__ = [ + "ChallengeType", + "ConfigStore", + "DefaultPolicy", + "FailedAction", + "Messages", + "Settings", + "ValidationError", + "load_settings", + "migrate_legacy_config", +] diff --git a/src/captcha_bot/context.py b/src/captcha_bot/context.py new file mode 100644 index 0000000..1074262 --- /dev/null +++ b/src/captcha_bot/context.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import httpx +from pyrogram import Client + +from captcha_bot.challenges import ChallengeRegistry +from captcha_bot.config import ConfigStore +from captcha_bot.db.repository import Database, Repository +from captcha_bot.services import PolicyService, TurnstileService + + +@dataclass(slots=True) +class AppContext: + config: ConfigStore + database: Database + repository: Repository + registry: ChallengeRegistry + http: httpx.AsyncClient + policies: PolicyService + turnstile: TurnstileService + client: Client diff --git a/src/captcha_bot/db/__init__.py b/src/captcha_bot/db/__init__.py new file mode 100644 index 0000000..a8a7c98 --- /dev/null +++ b/src/captcha_bot/db/__init__.py @@ -0,0 +1,4 @@ +from captcha_bot.db.models import RecaptchaLogAction +from captcha_bot.db.repository import Database, Repository + +__all__ = ["Database", "RecaptchaLogAction", "Repository"] diff --git a/src/captcha_bot/db/importer.py b/src/captcha_bot/db/importer.py new file mode 100644 index 0000000..a692b16 --- /dev/null +++ b/src/captcha_bot/db/importer.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import asyncio +import csv +from datetime import datetime +from pathlib import Path + +from sqlalchemy.dialects.postgresql import insert + +from captcha_bot.db.models import BlacklistUser, GroupConfig +from captcha_bot.db.repository import Repository + + +def _read_rows(file_path: str | Path) -> list[list[str]]: + with Path(file_path).open(newline="", encoding="utf-8") as file: + return list(csv.reader(file)) + + +async def import_csv(repository: Repository, file_path: str | Path, table: str) -> int: + rows = await asyncio.to_thread(_read_rows, file_path) + objects: list[dict[str, object]] = [] + if table == "group-config": + for row in rows: + objects.append( + { + "chat_id": int(row[0]), + "failed_action": (row[1] or "kick").lower(), + "timeout_action": (row[2] or "kick").lower(), + "timeout": int(row[3] or 180), + "challenge_type": (row[4] or "recaptcha").lower(), + "global_blacklist": row[5] != "0", + } + ) + statement = insert(GroupConfig).values(objects).on_conflict_do_nothing(index_elements=[GroupConfig.chat_id]) + elif table == "blacklist-user": + for row in rows: + if row[2] == "0": + continue + objects.append( + { + "user_id": int(row[0]), + "last_attempt": datetime.fromtimestamp(int(row[1])), + "attempt_count": int(row[3] or 1), + } + ) + statement = insert(BlacklistUser).values(objects).on_conflict_do_nothing(index_elements=[BlacklistUser.user_id]) + else: + raise ValueError("table must be group-config or blacklist-user") + if not objects: + return 0 + async with repository.sessions.begin() as session: + await session.execute(statement) + return len(objects) diff --git a/src/captcha_bot/db/models.py b/src/captcha_bot/db/models.py new file mode 100644 index 0000000..debd93c --- /dev/null +++ b/src/captcha_bot/db/models.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class RecaptchaLogAction(StrEnum): + PAGE_VISIT = "PageVisit" + FAILED = "Failed" + PASSED = "Passed" + + +class BlacklistUser(Base): + __tablename__ = "blacklist_user" + + user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + last_attempt: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.now) + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now) + updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class GroupConfig(Base): + __tablename__ = "group_config" + __table_args__ = ( + CheckConstraint("timeout > 0", name="ck_group_config_timeout_positive"), + CheckConstraint("challenge_type IN ('math', 'recaptcha')", name="ck_group_config_challenge_type"), + CheckConstraint("failed_action IN ('ban', 'kick')", name="ck_group_config_failed_action"), + CheckConstraint("timeout_action IN ('ban', 'kick')", name="ck_group_config_timeout_action"), + ) + + chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + timeout: Mapped[int] = mapped_column(Integer, nullable=False, default=300) + challenge_type: Mapped[str] = mapped_column(String(10), nullable=False, default="recaptcha") + failed_action: Mapped[str] = mapped_column(String(10), nullable=False, default="kick") + timeout_action: Mapped[str] = mapped_column(String(10), nullable=False, default="kick") + global_blacklist: Mapped[bool] = mapped_column(nullable=False, default=True) + + +class RecaptchaLog(Base): + __tablename__ = "recaptcha_log" + __table_args__ = ( + UniqueConstraint( + "challenge_id", + "user_id", + "group_id", + "ip_addr", + "action", + name="uq_recaptcha_log_event", + ), + Index("ix_recaptcha_log_challenge_created", "challenge_id", "created_at"), + Index("ix_recaptcha_log_user_created", "user_id", "created_at"), + Index( + "ix_recaptcha_log_ip_action_group_user_created", + "ip_addr", + "action", + "group_id", + "user_id", + "created_at", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + group_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + challenge_id: Mapped[str] = mapped_column(String(64), nullable=False) + ip_addr: Mapped[str] = mapped_column(String(64), nullable=False) + user_agent: Mapped[str] = mapped_column(Text, nullable=False) + action: Mapped[str] = mapped_column(String(20), nullable=False) + created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now) + updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/src/captcha_bot/db/repository.py b/src/captcha_bot/db/repository.py new file mode 100644 index 0000000..65d17c9 --- /dev/null +++ b/src/captcha_bot/db/repository.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, ClassVar + +from alembic.config import Config +from alembic.script import ScriptDirectory +from sqlalchemy import delete, func, select, text, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from captcha_bot.config import ChallengeType, DefaultPolicy, FailedAction, Settings +from captcha_bot.db.models import BlacklistUser, GroupConfig, RecaptchaLog, RecaptchaLogAction + + +@dataclass(frozen=True, slots=True) +class BlacklistRecord: + user_id: int + last_attempt: datetime + attempt_count: int + + +@dataclass(frozen=True, slots=True) +class GroupOverrides: + challenge_timeout: int + challenge_timeout_action: FailedAction + challenge_failed_action: FailedAction + challenge_type: ChallengeType + enable_global_blacklist: bool + + +@dataclass(frozen=True, slots=True) +class LogRecord: + group_id: int + user_id: int + challenge_id: str + ip_addr: str + user_agent: str + action: str + created_at: datetime | None + + def __str__(self) -> str: + return f"IP地址: `{self.ip_addr}`\nUA:`{self.user_agent}`\n状态:`{self.action}`\n时间:`{self.created_at}`\n" + + +def async_database_url(url: str) -> str: + if url.startswith("postgresql+psycopg://"): + return url + if url.startswith("postgresql://"): + return url.replace("postgresql://", "postgresql+psycopg://", 1) + raise ValueError("only PostgreSQL is supported") + + +class Database: + def __init__(self, settings: Settings) -> None: + database = settings.database + self.engine: AsyncEngine = create_async_engine( + async_database_url(database.url.get_secret_value()), + pool_pre_ping=True, + pool_size=database.pool_size, + max_overflow=database.max_overflow, + hide_parameters=True, + ) + self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) + + async def healthcheck(self) -> bool: + try: + async with self.engine.connect() as connection: + await connection.execute(text("SELECT 1")) + return True + except Exception: + return False + + async def ensure_schema_current(self, alembic_ini: str | Path = "alembic.ini") -> None: + config = Config(str(alembic_ini)) + expected = ScriptDirectory.from_config(config).get_current_head() + async with self.engine.connect() as connection: + current = await connection.scalar(text("SELECT version_num FROM alembic_version")) + if current != expected: + raise RuntimeError(f"database schema is {current!r}, expected {expected!r}; run alembic upgrade head") + + async def close(self) -> None: + await self.engine.dispose() + + +class Repository: + GROUP_FIELDS: ClassVar[dict[str, str]] = { + "challenge_failed_action": "failed_action", + "challenge_timeout_action": "timeout_action", + "challenge_timeout": "timeout", + "challenge_type": "challenge_type", + "enable_global_blacklist": "global_blacklist", + } + + def __init__(self, sessions: async_sessionmaker[AsyncSession]) -> None: + self.sessions = sessions + + async def get_blacklist_user(self, user_id: int) -> BlacklistRecord | None: + async with self.sessions() as session: + user = await session.get(BlacklistUser, user_id) + if user is None: + return None + return BlacklistRecord(user.user_id, user.last_attempt, user.attempt_count) + + async def record_blacklist_timeout(self, when: datetime, user_id: int) -> None: + statement = insert(BlacklistUser).values(user_id=user_id, last_attempt=when, attempt_count=1) + statement = statement.on_conflict_do_update( + index_elements=[BlacklistUser.user_id], + set_={ + "last_attempt": when, + "attempt_count": BlacklistUser.attempt_count + 1, + "updated_at": datetime.now(), + }, + ) + async with self.sessions.begin() as session: + await session.execute(statement) + + async def record_auto_kick(self, user_id: int, when: datetime) -> int: + statement = ( + update(BlacklistUser) + .where(BlacklistUser.user_id == user_id) + .values( + last_attempt=when, + attempt_count=BlacklistUser.attempt_count + 1, + updated_at=datetime.now(), + ) + .returning(BlacklistUser.attempt_count) + ) + async with self.sessions.begin() as session: + count = await session.scalar(statement) + return int(count or 0) + + async def remove_blacklist_users(self, user_ids: int | Sequence[int]) -> None: + ids = [user_ids] if isinstance(user_ids, int) else list(user_ids) + if not ids: + return + async with self.sessions.begin() as session: + await session.execute(delete(BlacklistUser).where(BlacklistUser.user_id.in_(ids))) + + async def all_blacklist_user_ids(self) -> list[int]: + async with self.sessions() as session: + return list(await session.scalars(select(BlacklistUser.user_id))) + + async def get_group_overrides(self, group_id: int) -> GroupOverrides | None: + async with self.sessions() as session: + group = await session.get(GroupConfig, group_id) + if group is None: + return None + return GroupOverrides( + challenge_timeout=group.timeout, + challenge_timeout_action=FailedAction(group.timeout_action), + challenge_failed_action=FailedAction(group.failed_action), + challenge_type=ChallengeType(group.challenge_type), + enable_global_blacklist=group.global_blacklist, + ) + + async def set_group_config(self, group_id: int, key: str, value: str, defaults: DefaultPolicy) -> None: + normalized_key = key.lower() + column_name = self.GROUP_FIELDS.get(normalized_key) + if column_name is None: + raise ValueError(f"不支持的配置项: {key}") + parsed: int | str | bool + if normalized_key == "challenge_timeout": + parsed = int(value) + if parsed <= 0: + raise ValueError("challenge_timeout 必须为正整数") + elif normalized_key in {"challenge_failed_action", "challenge_timeout_action"}: + parsed = FailedAction(value.lower()).value + elif normalized_key == "challenge_type": + parsed = ChallengeType(value.lower()).value + else: + normalized = value.lower() + if normalized not in {"0", "1", "true", "false"}: + raise ValueError(f"{key} 必须为 0/1/true/false") + parsed = normalized in {"1", "true"} + base_values: dict[str, Any] = { + "chat_id": group_id, + "timeout": defaults.challenge_timeout, + "challenge_type": defaults.challenge_type.value, + "failed_action": defaults.challenge_failed_action.value, + "timeout_action": defaults.challenge_timeout_action.value, + "global_blacklist": defaults.enable_global_blacklist, + column_name: parsed, + } + statement = insert(GroupConfig).values(**base_values) + statement = statement.on_conflict_do_update( + index_elements=[GroupConfig.chat_id], + set_={column_name: parsed}, + ) + async with self.sessions.begin() as session: + await session.execute(statement) + + async def log_recaptcha( + self, + challenge_id: str, + user_id: int, + chat_id: int, + ip_addr: str, + user_agent: str, + action: RecaptchaLogAction, + ) -> None: + statement = insert(RecaptchaLog).values( + challenge_id=challenge_id, + user_id=user_id, + group_id=chat_id, + ip_addr=ip_addr, + user_agent=user_agent, + action=action.value, + ) + statement = statement.on_conflict_do_nothing(constraint="uq_recaptcha_log_event") + async with self.sessions.begin() as session: + await session.execute(statement) + + @staticmethod + def _to_log(row: RecaptchaLog) -> LogRecord: + return LogRecord( + row.group_id, + row.user_id, + row.challenge_id, + row.ip_addr, + row.user_agent, + row.action, + row.created_at, + ) + + async def _recent_logs(self, criterion: Any) -> list[LogRecord]: + statement = select(RecaptchaLog).where(criterion).order_by(RecaptchaLog.created_at.desc()).limit(10) + async with self.sessions() as session: + rows = (await session.scalars(statement)).all() + return [self._to_log(row) for row in rows] + + async def logs_by_challenge(self, challenge_id: str) -> list[LogRecord]: + return await self._recent_logs(RecaptchaLog.challenge_id == challenge_id) + + async def logs_by_user(self, user_id: int) -> list[LogRecord]: + return await self._recent_logs(RecaptchaLog.user_id == user_id) + + async def logs_by_ip(self, ip_addr: str) -> list[LogRecord]: + return await self._recent_logs(RecaptchaLog.ip_addr == ip_addr) + + async def passed_users_by_ip(self, ip_addr: str) -> list[tuple[int, int, datetime | None]]: + last_passed_at = func.max(RecaptchaLog.created_at).label("last_passed_at") + statement = ( + select(RecaptchaLog.group_id, RecaptchaLog.user_id, last_passed_at) + .where(RecaptchaLog.ip_addr == ip_addr, RecaptchaLog.action == RecaptchaLogAction.PASSED.value) + .group_by(RecaptchaLog.group_id, RecaptchaLog.user_id) + .order_by(last_passed_at.desc()) + ) + async with self.sessions() as session: + rows = (await session.execute(statement)).all() + return [(row.group_id, row.user_id, row.last_passed_at) for row in rows] + + async def passed_user_ids_by_ip_and_group(self, ip_addr: str, group_id: int) -> list[int]: + statement = ( + select(RecaptchaLog.user_id) + .where( + RecaptchaLog.ip_addr == ip_addr, + RecaptchaLog.group_id == group_id, + RecaptchaLog.action == RecaptchaLogAction.PASSED.value, + ) + .distinct() + ) + async with self.sessions() as session: + return list(await session.scalars(statement)) diff --git a/src/captcha_bot/handlers/__init__.py b/src/captcha_bot/handlers/__init__.py new file mode 100644 index 0000000..5dbd503 --- /dev/null +++ b/src/captcha_bot/handlers/__init__.py @@ -0,0 +1,4 @@ +from captcha_bot.handlers.admin import register_admin_handlers +from captcha_bot.handlers.challenge import register_challenge_handlers + +__all__ = ["register_admin_handlers", "register_challenge_handlers"] diff --git a/src/captcha_bot/handlers/admin.py b/src/captcha_bot/handlers/admin.py new file mode 100644 index 0000000..0c3c87c --- /dev/null +++ b/src/captcha_bot/handlers/admin.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from datetime import timedelta +from functools import partial + +from pyrogram import Client, filters +from pyrogram.enums import ChatMemberStatus +from pyrogram.enums.chat_members_filter import ChatMembersFilter +from pyrogram.errors import BadRequest, ChatAdminRequired, RPCError +from pyrogram.handlers import MessageHandler +from pyrogram.types import Message, User + +from captcha_bot.context import AppContext +from captcha_bot.handlers.common import ( + can_restrict_members, + extract_message_ids, + is_bot_admin, + schedule, + send_log, + split_long_message, + valid_ip, +) + +logger = logging.getLogger(__name__) + + +async def reload_config(context: AppContext, _client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + try: + await context.config.reload() + except (OSError, ValueError) as exc: + await message.reply(f"配置重载失败:{exc}") + return + await message.reply("配置已成功重载。") + + +async def help_command(context: AppContext, _client: Client, message: Message) -> None: + await message.reply(context.config.settings.messages.self_introduction, disable_web_page_preview=True) + + +async def ping_command(_context: AppContext, _client: Client, message: Message) -> None: + await message.reply("poi~") + + +async def call_admin(context: AppContext, client: Client, message: Message) -> None: + if message.chat is None: + return + chat_id = message.chat.id + if chat_id is None: + return + mentions: list[str] = [] + me = await client.get_me() + async for member in client.get_chat_members(chat_id, filter=ChatMembersFilter.ADMINISTRATORS): + privileges = member.privileges + if member.user.id == me.id or member.user.is_bot: + continue + if member.status != ChatMemberStatus.OWNER and not ( + privileges and privileges.can_restrict_members and privileges.can_delete_messages + ): + continue + mentions.append(member.user.mention("admin")) + if not mentions: + await message.reply("没有找到可用的管理员") + return + reply = await message.reply(" ".join(mentions)) + schedule(reply.delete(), delay=300, name=f"delete-admin-call:{reply.id}") + + +async def leave_command(context: AppContext, client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id) or not message.text: + return + arguments = message.text.split() + if len(arguments) != 2: + await message.reply("使用方法: /leave [group_id]") + return + try: + chat_id = int(arguments[1]) + await client.send_message(chat_id, context.config.settings.messages.leave_message) + await client.leave_chat(chat_id, delete=True) + except ValueError, RPCError: + await message.reply("指令出错了!可能是 bot 不在参数所在群里。") + return + await message.reply(f"已离开群组: `{chat_id}`") + await send_log(context, context.config.settings.messages.leave_group.format(groupid=chat_id)) + + +async def clean_database(context: AppContext, client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + user_ids = await context.repository.all_blacklist_user_ids() + await message.reply(f"开始整理数据库,请稍等...\n预计需要时间:{timedelta(seconds=len(user_ids) // 4)}") + deleted: list[int] = [] + failures = 0 + for user_id in user_ids: + try: + user = await client.get_users(user_id) + except BadRequest, IndexError: + failures += 1 + deleted.append(user_id) + continue + if not isinstance(user, User): + user = user[0] + if user.is_deleted: + deleted.append(user_id) + await context.repository.remove_blacklist_users(deleted) + await message.reply(f"已成功清除{len(deleted)}个已经删号的用户,共有{failures}个用户信息获取失败。") + + +async def set_config(context: AppContext, client: Client, message: Message) -> None: + if message.from_user is None or message.chat is None or not message.text: + await message.reply("请从个人账号发送指令。") + return + chat_id = message.chat.id + if chat_id is None: + return + if not await can_restrict_members(client, chat_id, message.from_user.id): + reply = await message.reply(context.config.settings.messages.permission_denied) + schedule(reply.delete(), delay=5, name=f"delete-permission:{reply.id}") + schedule(message.delete(), delay=5, name=f"delete-command:{message.id}") + return + arguments = message.text.split(maxsplit=2) + if len(arguments) != 3: + await message.reply( + "使用方法:\n/faset [配置项] [值]\n\n" + "配置项: challenge_failed_action, challenge_timeout_action, challenge_timeout, " + "challenge_type, enable_global_blacklist" + ) + return + try: + await context.repository.set_group_config(chat_id, arguments[1], arguments[2], context.config.settings.defaults) + except (ValueError, TypeError) as exc: + await message.reply(str(exc)) + return + await message.reply("配置项设置成功") + + +async def sender_command(_context: AppContext, client: Client, message: Message) -> None: + if not message.command or len(message.command) != 2: + await message.reply("使用方法: /sender [message link]") + return + link = message.command[1] + if not link.startswith("https://t.me/"): + await message.reply("链接格式错误") + return + try: + chat_id, message_id = extract_message_ids(link) + found = await client.get_messages(chat_id, message_id) + except BadRequest: + await message.reply("Bot 不在该群组中") + return + except ValueError, IndexError: + await message.reply("链接格式错误") + return + except RPCError as exc: + logger.exception("failed to retrieve Telegram message") + await message.reply(f"获取消息失败: {exc}") + return + if found is None: + await message.reply("未找到消息") + return + if not isinstance(found, Message): + found = found[0] + if found.from_user is None: + await message.reply("未找到消息发送者") + return + await message.reply(found.from_user.mention(str(found.from_user.id))) + + +async def get_log(context: AppContext, _client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + if not message.command or len(message.command) != 2: + await message.reply("使用方法: /getlog [challenge_id]") + return + logs = await context.repository.logs_by_challenge(message.command[1]) + await message.reply("\n".join(map(str, logs)) if logs else "没有找到相关记录") + + +async def get_user_by_ip(context: AppContext, client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + if not message.command or len(message.command) != 2: + await message.reply("使用方法: /getuserbyip [ip]\n例如: `/getuserbyip 123.56.67.8`") + return + ip_addr = message.command[1] + if not valid_ip(ip_addr): + await message.reply("IP 地址格式错误") + return + logs = await context.repository.passed_users_by_ip(ip_addr) + if not logs: + await message.reply("没有找到通过该 IP 验证的用户") + return + groups: dict[int, list[tuple[int, object]]] = defaultdict(list) + for group_id, user_id, last_passed_at in logs: + groups[group_id].append((user_id, last_passed_at)) + titles: dict[int, str] = {} + for group_id in groups: + try: + chat = await client.get_chat(group_id) + titles[group_id] = chat.title or str(group_id) + except RPCError: + titles[group_id] = "无法获取群名" + unique_users = len({user_id for _, user_id, _ in logs}) + text = ( + f"IP `{ip_addr}` 通过验证的用户共 {unique_users} 个,用户/群记录 {len(logs)} 条,涉及 {len(groups)} 个群:\n\n" + ) + for group_id, users in groups.items(): + text += f"群组: {titles[group_id]} (`{group_id}`)\n" + text += "".join( + f"- [用户](tg://user?id={user_id}) `{user_id}` 最近通过: `{passed_at}`\n" for user_id, passed_at in users + ) + text += "\n" + for chunk in split_long_message(text): + await message.reply(chunk, disable_web_page_preview=True) + + +async def ban_user_by_ip(context: AppContext, client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + if not message.command or len(message.command) != 3: + await message.reply("使用方法: /banuserbyip [ip] [group_id]") + return + ip_addr = message.command[1] + if not valid_ip(ip_addr): + await message.reply("IP 地址格式错误") + return + try: + group_id = int(message.command[2]) + except ValueError: + await message.reply("群组 ID 格式错误") + return + user_ids = await context.repository.passed_user_ids_by_ip_and_group(ip_addr, group_id) + if not user_ids: + await message.reply("没有找到在该群通过这个 IP 验证的用户") + return + failures: list[tuple[int, str]] = [] + success = 0 + for user_id in user_ids: + try: + await client.ban_chat_member(group_id, user_id) + success += 1 + except ChatAdminRequired: + await message.reply("封禁失败: Bot 在该群没有封禁用户权限") + return + except RPCError as exc: + failures.append((user_id, str(exc))) + text = f"封禁完成: 成功 {success} 个,失败 {len(failures)} 个。" + if failures: + text += "\n\n失败列表:\n" + "".join(f"- `{user_id}`: `{error}`\n" for user_id, error in failures) + for chunk in split_long_message(text): + await message.reply(chunk) + + +async def forwarded_user_record(context: AppContext, _client: Client, message: Message) -> None: + if message.from_user is None or not is_bot_admin(context, message.from_user.id): + return + if message.forward_from is None: + await message.reply("该消息用户为频道或关闭了消息转发权限") + return + logs = await context.repository.logs_by_user(message.forward_from.id) + await message.reply("\n".join(map(str, logs)) if logs else "没有找到相关记录") + + +def register_admin_handlers(client: Client, context: AppContext) -> None: + handlers = [ + (reload_config, filters.command("reload") & filters.private), + (help_command, filters.command("help") & filters.group), + (ping_command, filters.command("ping") & filters.private), + (call_admin, filters.command("admin", prefixes="@") & filters.group), + (leave_command, filters.command("leave") & filters.private), + (clean_database, filters.command("clean") & filters.private), + (set_config, filters.command("faset") & filters.group), + (sender_command, filters.command("sender") & filters.private), + (get_log, filters.command("getlog") & filters.private), + (get_user_by_ip, filters.command("getuserbyip") & filters.private), + (ban_user_by_ip, filters.command("banuserbyip") & filters.private), + (forwarded_user_record, filters.forwarded & filters.private), + ] + for callback, handler_filter in handlers: + client.add_handler(MessageHandler(partial(callback, context), handler_filter)) diff --git a/src/captcha_bot/handlers/challenge.py b/src/captcha_bot/handlers/challenge.py new file mode 100644 index 0000000..dc75572 --- /dev/null +++ b/src/captcha_bot/handlers/challenge.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta +from functools import partial + +from pyrogram import Client, filters +from pyrogram.enums import ChatMemberStatus +from pyrogram.enums.chat_type import ChatType +from pyrogram.enums.message_service_type import MessageServiceType +from pyrogram.errors import ( + ChannelPrivate, + ChatAdminRequired, + MessageDeleteForbidden, + MessageNotModified, + RPCError, + UserIsBlocked, +) +from pyrogram.handlers import CallbackQueryHandler, ChatJoinRequestHandler, ChatMemberUpdatedHandler, MessageHandler +from pyrogram.types import CallbackQuery, ChatJoinRequest, ChatMemberUpdated, ChatPermissions, Message + +from captcha_bot.challenges import ( + ChallengeKind, + ChallengeSession, + MathChallenge, + new_turnstile_id, + turnstile_auth_keyboard, + turnstile_auth_url, + turnstile_group_keyboard, +) +from captcha_bot.config import ChallengeType +from captcha_bot.context import AppContext +from captcha_bot.handlers.common import ( + apply_failed_action, + can_restrict_members, + full_chat_permissions, + schedule, + send_log, +) + +logger = logging.getLogger(__name__) + + +async def start_command(context: AppContext, _client: Client, message: Message) -> None: + if message.from_user is None or not message.command or len(message.command) != 2: + await message.reply(context.config.settings.messages.start_message) + return + try: + chat_id = int(message.command[1]) + except ValueError: + await message.reply(context.config.settings.messages.start_message) + return + if chat_id >= 0: + await message.reply(context.config.settings.messages.start_message) + return + session = await context.registry.find(message.from_user.id, chat_id) + if session is None or session.kind != ChallengeKind.TURNSTILE or not session.turnstile_id: + await message.reply("这不是你的验证数据,请确认是否点击了正确的按钮") + return + url = turnstile_auth_url(context.config.settings.turnstile.base_url, session.turnstile_id) + await message.reply( + "点击下方按钮完成验证,您需要使用浏览器来完成,如果您在访问页面时出现问题,请尝试关闭匿名代理\n\n", + reply_markup=turnstile_auth_keyboard(url), + ) + + +async def _register_session(context: AppContext, session: ChallengeSession, delay_seconds: int) -> bool: + return await context.registry.register(session, delay_seconds, partial(challenge_timeout, context)) + + +async def on_chat_join_request(context: AppContext, client: Client, request: ChatJoinRequest) -> None: + user_id, chat_id = request.from_user.id, request.chat.id + if chat_id is None: + return + if await context.registry.contains_user(user_id, chat_id): + return + policy = await context.policies.get(chat_id) + message_id: int | None = None + answer: int | None = None + turnstile_id: str | None = None + kind: ChallengeKind + if policy.challenge_type == ChallengeType.RECAPTCHA: + kind = ChallengeKind.TURNSTILE + turnstile_id = new_turnstile_id() + url = turnstile_auth_url(context.config.settings.turnstile.base_url, turnstile_id) + try: + sent = await client.send_message( + user_id, + f"请在{policy.challenge_timeout}秒内点击下方按钮完成验证,您需要使用浏览器来完成。\n\n", + reply_markup=turnstile_auth_keyboard(url), + ) + message_id = sent.id + except UserIsBlocked: + await send_log(context, f"用户 `{user_id}` 屏蔽了机器人,无法发送验证消息") + else: + kind = ChallengeKind.MATH + challenge = MathChallenge() + answer = challenge.answer + try: + sent = await client.send_message( + user_id, + context.config.settings.messages.challenge_math_private.format( + challenge=challenge.question, timeout=policy.challenge_timeout + ), + reply_markup=challenge.join_request_keyboard(chat_id), + ) + message_id = sent.id + except UserIsBlocked: + logger.info("join request user blocked bot", extra={"user_id": user_id, "chat_id": chat_id}) + session = ChallengeSession( + key=f"{chat_id}|{user_id}", + kind=kind, + chat_id=chat_id, + user_id=user_id, + chat_title=request.chat.title or str(chat_id), + message_id=message_id, + answer=answer, + turnstile_id=turnstile_id, + join_request=True, + ) + await _register_session(context, session, policy.challenge_timeout) + + +async def on_chat_member_updated(context: AppContext, client: Client, update: ChatMemberUpdated) -> None: + if update.via_join_request or update.new_chat_member is None or update.chat.type == ChatType.CHANNEL: + return + member = update.new_chat_member + target = member.user + chat_id = update.chat.id + if chat_id is None: + return + if update.old_chat_member is not None or member.status != ChatMemberStatus.MEMBER: + return + if target.is_self: + try: + await client.send_message(chat_id, context.config.settings.messages.self_introduction) + await send_log( + context, + context.config.settings.messages.into_group.format( + groupid=chat_id, grouptitle=update.chat.title or str(chat_id) + ), + ) + except ChannelPrivate: + pass + return + if update.from_user is None or update.from_user.id != target.id: + return + user_id = target.id + policy = await context.policies.get(chat_id) + + if policy.enable_global_blacklist: + blacklisted = await context.repository.get_blacklist_user(user_id) + if blacklisted is not None: + now = datetime.now() + since_last_attempt = (now - blacklisted.last_attempt).total_seconds() + if since_last_attempt > policy.global_timeout_user_blacklist_remove: + cache = ChallengeSession( + key=f"{chat_id}|auto:{user_id}", + kind=ChallengeKind.AUTO_KICK, + chat_id=chat_id, + user_id=user_id, + chat_title=update.chat.title or str(chat_id), + ) + await _register_session(context, cache, 5) + await client.ban_chat_member(chat_id, user_id, until_date=now + timedelta(seconds=31)) + attempts = await context.repository.record_auto_kick(user_id, now) + await send_log( + context, + context.config.settings.messages.failed_auto_kick.format( + targetuserid=user_id, + targetusername=target.username, + targetfirstname=target.first_name, + targetlastname=target.last_name, + groupid=chat_id, + grouptitle=update.chat.title, + lastattempt=blacklisted.last_attempt.strftime("%Y-%m-%d %H:%M:%S"), + sincelastattempt=timedelta(seconds=since_last_attempt), + trycount=attempts, + ), + ) + return + await context.repository.remove_blacklist_users(user_id) + + if await context.registry.contains_user(user_id, chat_id): + logger.warning("duplicate challenge ignored", extra={"user_id": user_id, "chat_id": chat_id}) + return + try: + await client.restrict_chat_member(chat_id, user_id, permissions=ChatPermissions(can_send_messages=False)) + except ChatAdminRequired: + return + except RPCError: + await client.send_message(chat_id, "当前群组不是超级群组,Bot 无法工作,可能是成员过少。") + return + + turnstile_id: str | None = None + answer: int | None = None + if policy.challenge_type == ChallengeType.RECAPTCHA: + kind = ChallengeKind.TURNSTILE + turnstile_id = new_turnstile_id() + reply = await client.send_message( + chat_id, + context.config.settings.messages.challenge_recaptcha.format( + target_id=user_id, timeout=policy.challenge_timeout + ), + reply_markup=turnstile_group_keyboard( + context.config.settings.bot.username, + chat_id, + context.config.settings.messages.approve_manually, + context.config.settings.messages.refuse_manually, + ), + ) + else: + kind = ChallengeKind.MATH + challenge = MathChallenge() + answer = challenge.answer + reply = await client.send_message( + chat_id, + context.config.settings.messages.challenge_math.format( + target_id=user_id, timeout=policy.challenge_timeout, challenge=challenge.question + ), + reply_markup=challenge.group_keyboard( + context.config.settings.messages.approve_manually, + context.config.settings.messages.refuse_manually, + ), + ) + session = ChallengeSession( + key=f"{chat_id}|{reply.id}", + kind=kind, + chat_id=chat_id, + user_id=user_id, + chat_title=update.chat.title or str(chat_id), + message_id=reply.id, + answer=answer, + turnstile_id=turnstile_id, + ) + await _register_session(context, session, policy.challenge_timeout) + + +async def delete_pending_messages(context: AppContext, _client: Client, message: Message) -> None: + if message.chat is None: + return + chat_id = message.chat.id + if chat_id is None: + return + if message.service: + if message.service in {MessageServiceType.NEW_CHAT_MEMBERS, MessageServiceType.LEFT_CHAT_MEMBER}: + try: + await message.delete() + except MessageDeleteForbidden: + pass + return + if message.from_user is None: + return + await asyncio.sleep(1) + if not await context.registry.contains_user(message.from_user.id, chat_id): + return + try: + await message.delete() + except MessageDeleteForbidden: + return + await send_log( + context, + context.config.settings.messages.message_deleted.format( + targetuserid=message.from_user.id, + messageid=message.id, + groupid=chat_id, + grouptitle=message.chat.title, + ), + ) + + +async def callback_router(context: AppContext, client: Client, query: CallbackQuery) -> None: + data = str(query.data or "") + if "|" in data: + await private_math_callback(context, client, query, data) + elif data in {"+", "-"}: + await admin_callback(context, client, query, data) + else: + await math_callback(context, client, query, data) + + +async def private_math_callback(context: AppContext, client: Client, query: CallbackQuery, data: str) -> None: + if query.message is None: + return + try: + answer_text, chat_text = data.split("|", maxsplit=1) + chat_id = int(chat_text) + except ValueError: + return + key = f"{chat_id}|{query.from_user.id}" + current = await context.registry.get(key) + if current is None or current.user_id != query.from_user.id: + return + session = await context.registry.claim(key) + if session is None: + return + correct = str(session.answer) == answer_text + if correct: + await client.approve_chat_join_request(chat_id, session.user_id) + await client.edit_message_text( + session.user_id, query.message.id, context.config.settings.messages.challenge_passed + ) + await send_log( + context, + context.config.settings.messages.passed_answer.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + else: + await client.edit_message_text( + session.user_id, query.message.id, context.config.settings.messages.challenge_failed + ) + await client.decline_chat_join_request(chat_id, session.user_id) + await send_log( + context, + context.config.settings.messages.failed_answer.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + await client.answer_callback_query(query.id) + + +async def admin_callback(context: AppContext, client: Client, query: CallbackQuery, action: str) -> None: + if query.message is None or query.message.chat is None: + return + chat_id, message_id = query.message.chat.id, query.message.id + if chat_id is None: + return + key = f"{chat_id}|{message_id}" + current = await context.registry.get(key) + if current is None: + return + policy = await context.policies.get(chat_id) + if not await can_restrict_members(client, chat_id, query.from_user.id): + await client.answer_callback_query(query.id, context.config.settings.messages.permission_denied) + return + session = await context.registry.claim(key) + if session is None: + return + if action == "+": + try: + await client.restrict_chat_member(chat_id, session.user_id, permissions=full_chat_permissions()) + except ChatAdminRequired: + await client.answer_callback_query(query.id, context.config.settings.messages.bot_no_permission) + return + await client.edit_message_text( + chat_id, + message_id, + context.config.settings.messages.approved.format(user=query.from_user.first_name), + ) + await send_log( + context, + context.config.settings.messages.passed_admin.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + else: + try: + await client.ban_chat_member(chat_id, session.user_id) + except ChatAdminRequired: + await client.answer_callback_query(query.id, context.config.settings.messages.bot_no_permission) + return + await client.edit_message_text( + chat_id, + message_id, + context.config.settings.messages.refused.format(user=query.from_user.first_name), + ) + if policy.delete_failed_challenge: + schedule( + client.delete_messages(chat_id, message_id), + delay=policy.delete_failed_challenge_interval, + name=f"delete-failed:{key}", + ) + await send_log( + context, + context.config.settings.messages.failed_admin.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + await client.answer_callback_query(query.id) + + +async def math_callback(context: AppContext, client: Client, query: CallbackQuery, answer: str) -> None: + if query.message is None or query.message.chat is None: + return + chat_id, message_id = query.message.chat.id, query.message.id + if chat_id is None: + return + key = f"{chat_id}|{message_id}" + current = await context.registry.get(key) + if current is None or current.kind != ChallengeKind.MATH: + return + if query.from_user.id != current.user_id: + await client.answer_callback_query(query.id, context.config.settings.messages.challenge_not_for_you) + return + session = await context.registry.claim(key) + if session is None: + return + policy = await context.policies.get(chat_id) + try: + await client.restrict_chat_member(chat_id, session.user_id, permissions=full_chat_permissions()) + except ChatAdminRequired: + pass + correct = str(session.answer) == answer + if correct: + try: + await client.edit_message_text(chat_id, message_id, context.config.settings.messages.challenge_passed) + except MessageNotModified: + logger.info("challenge result message already updated", extra={"challenge_key": key}) + await send_log( + context, + context.config.settings.messages.passed_answer.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + else: + await client.edit_message_text(chat_id, message_id, context.config.settings.messages.challenge_failed) + await send_log( + context, + context.config.settings.messages.failed_answer.format( + targetuserid=session.user_id, groupid=chat_id, grouptitle=session.chat_title + ), + ) + await apply_failed_action(client, chat_id, session.user_id, policy.challenge_failed_action) + if policy.delete_failed_challenge: + schedule( + client.delete_messages(chat_id, message_id), + delay=policy.delete_failed_challenge_interval, + name=f"delete-failed:{key}", + ) + if correct and policy.delete_passed_challenge: + schedule( + client.delete_messages(chat_id, message_id), + delay=policy.delete_passed_challenge_interval, + name=f"delete-passed:{key}", + ) + await client.answer_callback_query(query.id) + + +async def challenge_timeout(context: AppContext, session: ChallengeSession) -> None: + if session.kind == ChallengeKind.AUTO_KICK: + return + policy = await context.policies.get(session.chat_id) + messages = context.config.settings.messages + if session.join_request: + try: + await context.client.decline_chat_join_request(session.chat_id, session.user_id) + if session.message_id is not None: + await context.client.edit_message_text( + session.user_id, session.message_id, "验证已超时,请重新加群尝试" + ) + except RPCError: + logger.exception("failed to expire join request", extra={"challenge_key": session.key}) + else: + if session.message_id is not None: + try: + await context.client.edit_message_text(session.chat_id, session.message_id, messages.challenge_failed) + except RPCError: + logger.exception("failed to edit timed out challenge", extra={"challenge_key": session.key}) + await apply_failed_action(context.client, session.chat_id, session.user_id, policy.challenge_timeout_action) + if policy.delete_failed_challenge and session.message_id is not None: + schedule( + context.client.delete_messages(session.chat_id, session.message_id), + delay=policy.delete_failed_challenge_interval, + name=f"delete-timeout:{session.key}", + ) + if policy.enable_global_blacklist: + await context.repository.record_blacklist_timeout(datetime.now(), session.user_id) + await send_log( + context, + messages.failed_timeout.format( + targetuserid=session.user_id, + targetusername="None", + targetfirstname="None", + targetlastname="None", + groupid=session.chat_id, + grouptitle=session.chat_title, + ), + ) + + +def register_challenge_handlers(client: Client, context: AppContext) -> None: + client.add_handler(MessageHandler(partial(start_command, context), filters.command("start") & filters.private)) + client.add_handler(MessageHandler(partial(delete_pending_messages, context), filters.group), group=1) + client.add_handler(ChatJoinRequestHandler(partial(on_chat_join_request, context))) + client.add_handler(ChatMemberUpdatedHandler(partial(on_chat_member_updated, context))) + client.add_handler(CallbackQueryHandler(partial(callback_router, context))) diff --git a/src/captcha_bot/handlers/common.py b/src/captcha_bot/handlers/common.py new file mode 100644 index 0000000..67b73ed --- /dev/null +++ b/src/captcha_bot/handlers/common.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from datetime import datetime, timedelta +from ipaddress import ip_address +from typing import Any + +from pyrogram import Client +from pyrogram.enums import ChatMemberStatus +from pyrogram.enums.chat_members_filter import ChatMembersFilter +from pyrogram.errors import RPCError +from pyrogram.types import ChatPermissions + +from captcha_bot.config import FailedAction +from captcha_bot.context import AppContext + +logger = logging.getLogger(__name__) + + +def is_bot_admin(context: AppContext, user_id: int) -> bool: + return user_id in context.config.settings.bot.admin_ids + + +async def can_restrict_members(client: Client, chat_id: int, user_id: int) -> bool: + async for member in client.get_chat_members(chat_id, filter=ChatMembersFilter.ADMINISTRATORS): + if member.user.id != user_id: + continue + if member.status == ChatMemberStatus.OWNER: + return True + return bool(member.privileges and member.privileges.can_restrict_members) + return False + + +def full_chat_permissions() -> ChatPermissions: + return ChatPermissions( + can_send_messages=True, + can_send_other_messages=True, + can_send_polls=True, + can_add_web_page_previews=True, + can_change_info=True, + can_invite_users=True, + can_pin_messages=True, + ) + + +async def send_log(context: AppContext, text: str) -> None: + try: + await context.client.send_message(context.config.settings.bot.log_channel_id, text) + except RPCError: + logger.exception("failed to send audit message") + + +async def apply_failed_action(client: Client, chat_id: int, user_id: int, action: FailedAction) -> None: + if action == FailedAction.BAN: + await client.ban_chat_member(chat_id, user_id) + else: + await client.ban_chat_member(chat_id, user_id, until_date=datetime.now() + timedelta(seconds=31)) + + +def schedule(coroutine: Coroutine[Any, Any, Any], *, delay: float = 0, name: str) -> asyncio.Task[None]: + async def runner() -> None: + try: + if delay: + await asyncio.sleep(delay) + await coroutine + except asyncio.CancelledError: + raise + except RPCError: + logger.exception("background Telegram operation failed", extra={"task": name}) + + return asyncio.create_task(runner(), name=name) + + +def split_long_message(text: str, max_length: int = 3900) -> list[str]: + chunks: list[str] = [] + current = "" + for original_line in text.splitlines(keepends=True): + line = original_line + if len(current) + len(line) > max_length: + if current: + chunks.append(current) + current = "" + while len(line) > max_length: + chunks.append(line[:max_length]) + line = line[max_length:] + current += line + if current: + chunks.append(current) + return chunks + + +def valid_ip(value: str) -> bool: + try: + ip_address(value) + return True + except ValueError: + return False + + +def extract_message_ids(url: str) -> tuple[int | str, int]: + from urllib.parse import urlparse + + path = urlparse(url).path + parts = path.strip("/").split("/") + if len(parts) < 2: + raise ValueError("invalid Telegram message URL") + if len(parts) >= 3 and parts[-3] == "c": + return int(f"-100{int(parts[-2])}"), int(parts[-1]) + return parts[-2], int(parts[-1]) diff --git a/src/captcha_bot/services.py b/src/captcha_bot/services.py new file mode 100644 index 0000000..9afa814 --- /dev/null +++ b/src/captcha_bot/services.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import Protocol + +import httpx + +from captcha_bot.config import ConfigStore, DefaultPolicy +from captcha_bot.db.repository import GroupOverrides + +logger = logging.getLogger(__name__) + + +class PolicyRepository(Protocol): + def get_group_overrides(self, group_id: int) -> Awaitable[GroupOverrides | None]: ... + + +class PolicyService: + def __init__(self, config: ConfigStore, repository: PolicyRepository) -> None: + self.config = config + self.repository = repository + + async def get(self, chat_id: int) -> DefaultPolicy: + defaults = self.config.settings.defaults + override = await self.repository.get_group_overrides(chat_id) + if override is None: + return defaults + return defaults.model_copy(update=_override_values(override)) + + +def _override_values(override: GroupOverrides) -> dict[str, object]: + return { + "challenge_timeout": override.challenge_timeout, + "challenge_timeout_action": override.challenge_timeout_action, + "challenge_failed_action": override.challenge_failed_action, + "challenge_type": override.challenge_type, + "enable_global_blacklist": override.enable_global_blacklist, + } + + +@dataclass(frozen=True, slots=True) +class TurnstileResult: + success: bool + error_codes: tuple[str, ...] = () + + +class TurnstileService: + def __init__(self, client: httpx.AsyncClient, config: ConfigStore) -> None: + self.client = client + self.config = config + + async def verify(self, response_token: str, remote_ip: str) -> TurnstileResult: + settings = self.config.settings.turnstile + try: + response = await self.client.post( + settings.verify_url, + data={ + "response": response_token, + "secret": settings.secret_key.get_secret_value(), + "remoteip": remote_ip, + }, + timeout=settings.timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + except httpx.HTTPError, ValueError, TypeError: + logger.exception("Turnstile verification request failed") + return TurnstileResult(False, ("verification-unavailable",)) + errors = tuple(str(value) for value in payload.get("error-codes", [])) + return TurnstileResult(bool(payload.get("success")), errors) diff --git a/src/captcha_bot/web.py b/src/captcha_bot/web.py new file mode 100644 index 0000000..6347ccd --- /dev/null +++ b/src/captcha_bot/web.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import logging +from ipaddress import ip_address, ip_network + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from pyrogram.errors import ChatAdminRequired, MessageNotModified, RPCError + +from captcha_bot.context import AppContext +from captcha_bot.db.models import RecaptchaLogAction +from captcha_bot.handlers.common import full_chat_permissions, send_log + +logger = logging.getLogger(__name__) + + +def get_context(request: Request) -> AppContext: + return request.app.state.context + + +def client_ip(request: Request, context: AppContext) -> str: + peer = request.client.host if request.client else "127.0.0.1" + try: + peer_address = ip_address(peer) + except ValueError: + return "127.0.0.1" + trusted = any( + peer_address in ip_network(cidr, strict=False) for cidr in context.config.settings.web.trusted_proxy_cidrs + ) + if trusted: + forwarded = request.headers.get("CF-Connecting-IP") + if forwarded: + try: + return str(ip_address(forwarded)) + except ValueError: + logger.warning("ignored invalid CF-Connecting-IP", extra={"peer": peer}) + return str(peer_address) + + +def register_web_routes(app: FastAPI, templates: Jinja2Templates) -> None: + @app.middleware("http") + async def response_headers(request: Request, call_next): # type: ignore[no-untyped-def] + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Robots-Tag"] = "noindex, nofollow" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; " + "frame-src https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; " + "form-action 'self'; base-uri 'none'; frame-ancestors 'none'" + ) + response.headers["Cache-Control"] = "public, max-age=300" if request.url.path == "/" else "no-store" + return response + + @app.get("/", response_class=HTMLResponse) + async def root(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request=request, name="index.html") + + @app.get("/healthz") + async def health(request: Request) -> JSONResponse: + context = get_context(request) + database_ok = await context.database.healthcheck() + telegram_ok = bool(context.client.is_connected) + status = 200 if database_ok and telegram_ok else 503 + return JSONResponse({"status": "ok" if status == 200 else "degraded"}, status_code=status) + + @app.api_route("/recaptcha", methods=["GET", "POST"], response_class=HTMLResponse) + async def verify(request: Request) -> HTMLResponse: + context = get_context(request) + challenge_id = request.query_params.get("challenge") + if not challenge_id: + return templates.TemplateResponse( + request=request, name="result.html", context={"notice": "没有这条验证数据!", "category": "error"} + ) + session = await context.registry.get_by_turnstile_id(challenge_id) + if session is None: + return templates.TemplateResponse( + request=request, name="result.html", context={"notice": "没有这条验证数据!", "category": "error"} + ) + remote_ip = client_ip(request, context) + user_agent = request.headers.get("User-Agent", "") + if request.method == "GET": + await context.repository.log_recaptcha( + challenge_id, + session.user_id, + session.chat_id, + remote_ip, + user_agent, + RecaptchaLogAction.PAGE_VISIT, + ) + return templates.TemplateResponse( + request=request, + name="recaptcha.html", + context={"sitekey": context.config.settings.turnstile.site_key}, + ) + form = await request.form() + response_token = str(form.get("g-recaptcha-response") or form.get("cf-turnstile-response") or "") + result = await context.turnstile.verify(response_token, remote_ip) + if not result.success: + await context.repository.log_recaptcha( + challenge_id, + session.user_id, + session.chat_id, + remote_ip, + user_agent, + RecaptchaLogAction.FAILED, + ) + return templates.TemplateResponse( + request=request, + name="recaptcha.html", + context={"sitekey": context.config.settings.turnstile.site_key, "error": "验证状态异常,请再试一次!"}, + ) + session = await context.registry.claim_by_turnstile_id(challenge_id) + if session is None: + return templates.TemplateResponse( + request=request, name="result.html", context={"notice": "验证已过期或已完成。", "category": "error"} + ) + try: + if session.join_request: + await context.client.approve_chat_join_request(session.chat_id, session.user_id) + else: + await context.client.restrict_chat_member( + session.chat_id, session.user_id, permissions=full_chat_permissions() + ) + except ChatAdminRequired: + logger.warning("bot lacks permission to approve challenge", extra={"challenge_id": challenge_id}) + await context.repository.log_recaptcha( + challenge_id, + session.user_id, + session.chat_id, + remote_ip, + user_agent, + RecaptchaLogAction.PASSED, + ) + await send_log( + context, + context.config.settings.messages.passed_answer.format( + targetuserid=session.user_id, + groupid=session.chat_id, + grouptitle=session.chat_title, + ) + + f"\n验证ID: `{challenge_id}`", + ) + policy = await context.policies.get(session.chat_id) + if session.message_id is not None: + if policy.delete_passed_challenge and not session.join_request: + try: + await context.client.delete_messages(session.chat_id, session.message_id) + except RPCError: + logger.exception("failed to delete passed challenge") + else: + try: + await context.client.edit_message_text( + session.user_id if session.join_request else session.chat_id, + session.message_id, + context.config.settings.messages.challenge_passed, + ) + except MessageNotModified: + pass + return templates.TemplateResponse( + request=request, + name="result.html", + context={ + "notice": "您已通过验证,欢迎加入本群!如果仍然无法发言,请重启 Telegram 客户端。", + "category": "success", + }, + ) diff --git a/templates/index.html b/templates/index.html index 0de1afa..e859420 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,7 @@ Captcha Bot Status - +
diff --git a/templates/recaptcha.html b/templates/recaptcha.html index 1758c18..749ade8 100644 --- a/templates/recaptcha.html +++ b/templates/recaptcha.html @@ -5,7 +5,7 @@ 完成验证 - + @@ -29,15 +29,11 @@

完成验证

请完成下方验证并提交。通过后会自动回到群组验证流程。

- {% with messages = get_flashed_messages(category_filter='error') %} - {% if messages %} -
    - {% for message in messages %} -
  • {{ message }}
  • - {% endfor %} -
- {% endif %} - {% endwith %} + {% if error %} +
    +
  • {{ error }}
  • +
+ {% endif %}
diff --git a/templates/result.html b/templates/result.html index e996eba..266f5a2 100644 --- a/templates/result.html +++ b/templates/result.html @@ -5,7 +5,7 @@ 验证结果 - +
@@ -28,17 +28,13 @@

验证结果

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
    - {% for category, message in messages %} -
  • {{ message }}
  • - {% endfor %} -
- {% else %} -

当前没有可显示的验证结果。

- {% endif %} - {% endwith %} + {% if notice %} +
    +
  • {{ notice }}
  • +
+ {% else %} +

当前没有可显示的验证结果。

+ {% endif %}

这个页面不会保存验证状态。需要重新验证时,请回到 Telegram 使用新的验证链接。

diff --git a/test.py b/test.py deleted file mode 100644 index 57cadfa..0000000 --- a/test.py +++ /dev/null @@ -1,47 +0,0 @@ -import re - - -def test_halal(text: str, max_halal_char_threshold: int, max_halal_pct_threshold: float) -> bool: - # 移除空白字符(包括 \s、\uFEFF、\xA0 等) - # 若需要移除更多类型的空白符,可自行扩展正则 - pattern = re.compile(r'[\s\uFEFF\xA0]+') - cleaned_text = pattern.sub('', text) - - length = len(cleaned_text) - if length == 0: - # 根据业务逻辑决定空字符串是否视为“符合”或“不符合” - return False - - count = 0 - for ch in cleaned_text: - code = ord(ch) - # 对应 JS 中的 switch(true) case 范围判断 - if (0x600 <= code <= 0x6FF or - 0x750 <= code <= 0x77F or - 0x8A0 <= code <= 0x8FF or - 0x900 <= code <= 0x97F or - 0x600 <= code <= 0x6FF or # 原 JS 代码中重复的一段,保留 - 0xA8E0 <= code <= 0xA8FF or - 0xFB50 <= code <= 0xFDFF or - 0xFE70 <= code <= 0xFEFF): - count += 1 - - # 若达到字符计数阈值,直接返回 True - if count >= max_halal_char_threshold: - return True - - # 若占比达到百分比阈值,也返回 True - if (count / length) >= max_halal_pct_threshold: - return True - - return False - - -# 示例调用 -if __name__ == "__main__": - test_string = "محمود سيد" - max_char_threshold = 3 - max_pct_threshold = 0.5 - - result = test_halal(test_string, max_char_threshold, max_pct_threshold) - print("Result:", result) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3a7336b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from captcha_bot.config import Settings, load_settings + + +@pytest.fixture +def settings() -> Settings: + return load_settings(Path("config.example.toml")) diff --git a/tests/test_challenges.py b/tests/test_challenges.py new file mode 100644 index 0000000..7a84e30 --- /dev/null +++ b/tests/test_challenges.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +import random + +import pytest + +from captcha_bot.challenges import ChallengeKind, ChallengeRegistry, ChallengeSession, MathChallenge + + +def session(key: str = "-1|1", *, token: str | None = "token") -> ChallengeSession: + return ChallengeSession(key, ChallengeKind.TURNSTILE, -1, 1, "chat", turnstile_id=token) + + +@pytest.mark.asyncio +async def test_registry_claim_is_single_use_and_cancels_timeout() -> None: + registry = ChallengeRegistry() + called: list[str] = [] + + async def timeout(item: ChallengeSession) -> None: + called.append(item.key) + + item = session() + assert await registry.register(item, 10, timeout) + assert not await registry.register(session(), 10, timeout) + assert await registry.find(1, -1) is item + assert await registry.get_by_turnstile_id("token") is item + assert await registry.claim_by_turnstile_id("token") is item + assert await registry.claim(item.key) is None + await asyncio.sleep(0) + assert called == [] + + +@pytest.mark.asyncio +async def test_registry_timeout_claims_before_callback() -> None: + registry = ChallengeRegistry() + finished = asyncio.Event() + + async def timeout(item: ChallengeSession) -> None: + assert await registry.get(item.key) is None + finished.set() + + assert await registry.register(session(), 0, timeout) + await asyncio.wait_for(finished.wait(), timeout=1) + assert await registry.count() == 0 + + +@pytest.mark.asyncio +async def test_registry_shutdown_cancels_everything() -> None: + registry = ChallengeRegistry() + + async def timeout(_item: ChallengeSession) -> None: + raise AssertionError("must not run") + + await registry.register(session("a", token="a"), 60, timeout) + await registry.register(ChallengeSession("b", ChallengeKind.MATH, -2, 2, "b"), 60, timeout) + await registry.shutdown() + assert await registry.count() == 0 + + +def test_math_challenge_has_answer_and_keyboards() -> None: + challenge = MathChallenge(random.Random(42)) + assert challenge.answer in challenge.choices + assert "What is the result" in challenge.question + assert len(challenge.group_keyboard("yes", "no").inline_keyboard) == 2 + callback = challenge.join_request_keyboard(-123).inline_keyboard[0][0].callback_data + assert callback + assert callback.endswith(b"|-123") if isinstance(callback, bytes) else callback.endswith("|-123") diff --git a/tests/test_cli_app.py b/tests/test_cli_app.py new file mode 100644 index 0000000..e61f7bb --- /dev/null +++ b/tests/test_cli_app.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +from captcha_bot import app as app_module +from captcha_bot import cli + + +def test_cli_parser_and_migrate(tmp_path: Path, monkeypatch, capsys) -> None: + assert cli.parser().prog == "captcha-bot" + ini = tmp_path / "missing.ini" + monkeypatch.setattr( + sys, + "argv", + ["captcha-bot", "migrate-config", "--ini", str(ini), "--json", "missing.json", "--output", "out.toml"], + ) + try: + cli.main() + except SystemExit as exc: + assert "missing.ini" in str(exc) + assert capsys.readouterr().out == "" + + +def test_cli_run_calls_uvicorn(monkeypatch) -> None: + called: dict[str, object] = {} + + def fake_run(app, host, port, debug): # type: ignore[no-untyped-def] + called["app"] = app + called.update(host=host, port=port, debug=debug) + + monkeypatch.setattr(cli, "_serve_application", fake_run) + monkeypatch.setattr(sys, "argv", ["captcha-bot", "run", "--config", "config.example.toml"]) + cli.main() + assert called["host"] == "127.0.0.1" + assert called["port"] == 5000 + + +class FakeDatabase: + def __init__(self, _settings) -> None: # type: ignore[no-untyped-def] + self.sessions = object() + self.closed = False + + async def ensure_schema_current(self) -> None: + return None + + async def close(self) -> None: + self.closed = True + + +class FakeClient: + def __init__(self, *_args, **_kwargs) -> None: # type: ignore[no-untyped-def] + self.is_connected = False + self.handlers: list[object] = [] + + def add_handler(self, handler, group: int = 0) -> None: # type: ignore[no-untyped-def] + self.handlers.append((handler, group)) + + async def start(self) -> None: + self.is_connected = True + + async def stop(self) -> None: + self.is_connected = False + + +@pytest.mark.asyncio +async def test_application_lifespan_owns_resources(monkeypatch) -> None: + monkeypatch.setattr(app_module, "Database", FakeDatabase) + monkeypatch.setattr(app_module, "Client", FakeClient) + monkeypatch.setattr(app_module, "Repository", lambda sessions: SimpleNamespace(sessions=sessions)) + monkeypatch.setattr(app_module, "PolicyService", lambda config, repository: SimpleNamespace()) + monkeypatch.setattr(app_module, "TurnstileService", lambda http, config: SimpleNamespace()) + application = app_module.create_app("config.example.toml") + async with application.router.lifespan_context(application): + assert application.state.context.client.is_connected + assert application.state.context.client.handlers + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=application), base_url="http://test") as client: + assert (await client.get("/")).status_code == 200 + assert not application.state.context.client.is_connected diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..6688c91 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from captcha_bot.handlers.common import extract_message_ids, split_long_message, valid_ip + + +def test_message_utilities() -> None: + assert extract_message_ids("https://t.me/c/123/456") == (-100123, 456) + assert extract_message_ids("https://t.me/channel/456") == ("channel", 456) + assert valid_ip("2001:db8::1") + assert not valid_ip("not-an-ip") + assert split_long_message("123456", max_length=3) == ["123", "456"] + assert split_long_message("a\nb\n", max_length=3) == ["a\n", "b\n"] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bf81df2 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import cast + +import pytest +from pydantic import ValidationError + +from captcha_bot.config import ConfigStore, Settings, load_settings, migrate_legacy_config + + +def test_example_config_is_valid(settings: Settings) -> None: + assert settings.bot.api_id == 123456 + assert settings.database.url.get_secret_value().startswith("postgresql+psycopg://") + assert "123456:replace-me" not in repr(settings) + + +def test_rejects_non_postgresql_and_unknown_placeholders(settings: Settings) -> None: + raw = settings.model_dump(mode="python") + raw["database"]["url"] = "sqlite:///data.sqlite" + with pytest.raises(ValidationError, match="PostgreSQL"): + Settings.model_validate(raw) + raw = settings.model_dump(mode="python") + raw["messages"]["start_message"] = "bad {unknown}" + with pytest.raises(ValidationError, match="unknown placeholders"): + Settings.model_validate(raw) + raw = settings.model_dump(mode="python") + raw["bot"]["api_id"] = "123" + with pytest.raises(ValidationError): + Settings.model_validate(raw) + + +def legacy_json() -> dict[str, object]: + defaults = { + "msg_self_introduction": "intro", + "msg_challenge_not_for_you": "not you", + "msg_challenge_math": "{target_id} {timeout} {challenge}", + "msg_challenge_math_private": "{timeout} {challenge}", + "msg_challenge_recaptcha": "{target_id} {timeout}", + "msg_challenge_passed": "passed", + "msg_challenge_failed": "failed", + "msg_approve_manually": "approve", + "msg_refuse_manually": "refuse", + "msg_permission_denied": "denied", + "msg_bot_no_permission": "no permission", + "msg_approved": "{user}", + "msg_refused": "{user}", + "challenge_timeout": 60, + "challenge_timeout_action": "kick", + "challenge_failed_action": "ban", + "challenge_type": "recaptcha", + "delete_passed_challenge": True, + "delete_passed_challenge_interval": 1, + "delete_failed_challenge": True, + "delete_failed_challenge_interval": 2, + "enable_global_blacklist": True, + "global_timeout_user_blacklist_remove": 600, + "enable_third_party_blacklist": False, + } + return { + "proxy_addr": "", + "proxy_port": "", + "msg_start_message": "start", + "msg_passed_answer": "{targetuserid} {groupid} {grouptitle}", + "msg_passed_mercy": "unused", + "msg_passed_admin": "{targetuserid} {groupid} {grouptitle}", + "msg_failed_answer": "{targetuserid} {groupid} {grouptitle}", + "msg_failed_timeout": ( + "{targetuserid} {targetusername} {targetfirstname} {targetlastname} {groupid} {grouptitle}" + ), + "msg_failed_admin": "{targetuserid} {groupid} {grouptitle}", + "msg_failed_auto_kick": ( + "{targetuserid} {targetusername} {targetfirstname} {targetlastname} {groupid} {grouptitle} " + "{lastattempt} {sincelastattempt} {trycount}" + ), + "msg_message_deleted": "{targetuserid} {messageid} {groupid} {grouptitle}", + "msg_into_group": "{groupid} {grouptitle}", + "msg_leave_group": "{groupid}", + "msg_leave_msg": "leaving", + "*": defaults, + } + + +def write_legacy(tmp_path: Path) -> tuple[Path, Path]: + ini = tmp_path / "config.ini" + ini.write_text( + """[db] +conn_str = postgresql://user:pass@localhost/db +[bot] +username = bot +token = token +api_id = 123 +api_hash = hash +admin = 1, 2 +channel = -1001 +[reCAPTCHA] +site_key = site +secret_key = secret +base_url = https://example.com +[web] +flask_secret_key = old +flask_port = 5000 +flask_host = 127.0.0.1 +development = false +""", + encoding="utf-8", + ) + json_path = tmp_path / "config.json" + json_path.write_text(json.dumps(legacy_json()), encoding="utf-8") + return ini, json_path + + +def test_migrates_legacy_config_without_overwrite(tmp_path: Path) -> None: + ini, json_path = write_legacy(tmp_path) + output = tmp_path / "config.toml" + ignored = migrate_legacy_config(ini, json_path, output) + migrated = load_settings(output) + assert migrated.bot.admin_ids == (1, 2) + assert migrated.defaults.challenge_failed_action == "ban" + assert ignored == ["msg_passed_mercy", "enable_third_party_blacklist", "flask_secret_key"] + with pytest.raises(FileExistsError): + migrate_legacy_config(ini, json_path, output) + + +def test_migration_maps_removed_mute_action(tmp_path: Path) -> None: + ini, json_path = write_legacy(tmp_path) + data = legacy_json() + defaults = cast(dict[str, object], data["*"]) + defaults["challenge_failed_action"] = "mute" + json_path.write_text(json.dumps(data), encoding="utf-8") + output = tmp_path / "config.toml" + ignored = migrate_legacy_config(ini, json_path, output) + assert load_settings(output).defaults.challenge_failed_action == "kick" + assert "challenge_failed_action=mute (mapped to kick)" in ignored + + +@pytest.mark.asyncio +async def test_config_store_reload_is_atomic(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + original = await asyncio.to_thread(Path("config.example.toml").read_text, encoding="utf-8") + await asyncio.to_thread(path.write_text, original, encoding="utf-8") + store = ConfigStore(path, load_settings(path)) + await asyncio.to_thread( + path.write_text, + original.replace('start_message = "喵?"', 'start_message = "new"'), + encoding="utf-8", + ) + assert (await store.reload()).messages.start_message == "new" + await asyncio.to_thread(path.write_text, original.replace("port = 5000", "port = 5001"), encoding="utf-8") + with pytest.raises(ValueError, match="重启"): + await store.reload() + assert store.settings.messages.start_message == "new" diff --git a/tests/test_database_integration.py b/tests/test_database_integration.py new file mode 100644 index 0000000..31ef728 --- /dev/null +++ b/tests/test_database_integration.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import asyncio +import os +import sys +import tomllib +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path + +import pytest +import tomli_w +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from alembic import command +from captcha_bot.config import load_settings +from captcha_bot.db.models import RecaptchaLogAction +from captcha_bot.db.repository import Database, Repository + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def database_config(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[str, Path, Config]]: + database_url = os.environ.get("TEST_DATABASE_URL") + if not database_url: + pytest.skip("TEST_DATABASE_URL is not set") + with Path("config.example.toml").open("rb") as source: + data = tomllib.load(source) + data["database"]["url"] = database_url + path = tmp_path_factory.mktemp("postgres") / "config.toml" + path.write_text(tomli_w.dumps(data), encoding="utf-8") + previous = os.environ.get("CAPTCHA_BOT_CONFIG") + os.environ["CAPTCHA_BOT_CONFIG"] = str(path) + alembic_config = Config("alembic.ini") + command.downgrade(alembic_config, "base") + command.upgrade(alembic_config, "head") + try: + yield database_url, path, alembic_config + finally: + command.upgrade(alembic_config, "head") + if previous is None: + os.environ.pop("CAPTCHA_BOT_CONFIG", None) + else: + os.environ["CAPTCHA_BOT_CONFIG"] = previous + + +def test_empty_upgrade_revision_cycle_and_data_preservation(database_config: tuple[str, Path, Config]) -> None: + database_url, _, alembic_config = database_config + engine = create_engine(database_url) + try: + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO blacklist_user (user_id, last_attempt, attempt_count) " + "SELECT value, CURRENT_TIMESTAMP, 1 FROM generate_series(1, 18392) AS value" + ) + ) + connection.execute( + text( + "INSERT INTO group_config " + "(chat_id, timeout, challenge_type, failed_action, timeout_action, global_blacklist) " + "VALUES (-1001, 60, 'math', 'kick', 'ban', true)" + ) + ) + command.downgrade(alembic_config, "-1") + command.upgrade(alembic_config, "head") + with engine.connect() as connection: + assert connection.scalar(text("SELECT count(*) FROM blacklist_user")) == 18_392 + assert connection.scalar(text("SELECT timeout FROM group_config WHERE chat_id = -1001")) == 60 + metadata = inspect(engine) + group_columns = {column["name"] for column in metadata.get_columns("group_config")} + assert "third_party_blacklist" not in group_columns + checks = {constraint["name"] for constraint in metadata.get_check_constraints("group_config")} + assert { + "ck_group_config_timeout_positive", + "ck_group_config_challenge_type", + "ck_group_config_failed_action", + "ck_group_config_timeout_action", + } <= checks + indexes = {index["name"] for index in metadata.get_indexes("recaptcha_log")} + assert { + "ix_recaptcha_log_challenge_created", + "ix_recaptcha_log_user_created", + "ix_recaptcha_log_ip_action_group_user_created", + } <= indexes + unique = {constraint["name"] for constraint in metadata.get_unique_constraints("recaptcha_log")} + assert unique == {"uq_recaptcha_log_event"} + command.check(alembic_config) + finally: + engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="psycopg async requires a selector event loop on Windows") +async def test_repository_concurrent_upserts_and_log_deduplication( + database_config: tuple[str, Path, Config], +) -> None: + _, config_path, _ = database_config + settings = load_settings(config_path) + database = Database(settings) + repository = Repository(database.sessions) + try: + await asyncio.gather( + repository.set_group_config(-2002, "challenge_timeout", "75", settings.defaults), + repository.set_group_config(-2002, "challenge_type", "recaptcha", settings.defaults), + ) + overrides = await repository.get_group_overrides(-2002) + assert overrides is not None + assert overrides.challenge_timeout == 75 + assert overrides.challenge_type == "recaptcha" + + await asyncio.gather(*(repository.record_blacklist_timeout(settings_time(), 99_999) for _ in range(20))) + blacklisted = await repository.get_blacklist_user(99_999) + assert blacklisted is not None + assert blacklisted.attempt_count == 20 + + await asyncio.gather( + *( + repository.log_recaptcha( + "same-event", + 42, + -2002, + "127.0.0.1", + "pytest", + RecaptchaLogAction.PASSED, + ) + for _ in range(10) + ) + ) + logs = await repository.logs_by_challenge("same-event") + assert len(logs) == 1 + finally: + await database.close() + + +def settings_time() -> datetime: + return datetime.now() diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..de9c681 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock, Mock + +import pytest +from pyrogram import Client +from pyrogram.enums import ChatMemberStatus, ChatType +from pyrogram.types import ChatJoinRequest + +from captcha_bot.challenges import ChallengeKind, ChallengeRegistry, ChallengeSession +from captcha_bot.config import ChallengeType, Settings +from captcha_bot.db.repository import BlacklistRecord +from captcha_bot.handlers import admin, challenge + + +def context_for(settings: Settings, *, policy: Any | None = None) -> Any: + client = SimpleNamespace( + send_message=AsyncMock(return_value=SimpleNamespace(id=100)), + leave_chat=AsyncMock(), + get_messages=AsyncMock(), + get_users=AsyncMock(), + get_chat=AsyncMock(return_value=SimpleNamespace(title="group")), + ban_chat_member=AsyncMock(), + restrict_chat_member=AsyncMock(), + approve_chat_join_request=AsyncMock(), + decline_chat_join_request=AsyncMock(), + edit_message_text=AsyncMock(), + answer_callback_query=AsyncMock(), + delete_messages=AsyncMock(), + add_handler=lambda *_args, **_kwargs: None, + ) + repository = SimpleNamespace( + all_blacklist_user_ids=AsyncMock(return_value=[]), + remove_blacklist_users=AsyncMock(), + set_group_config=AsyncMock(), + logs_by_challenge=AsyncMock(return_value=[]), + logs_by_user=AsyncMock(return_value=[]), + passed_users_by_ip=AsyncMock(return_value=[]), + passed_user_ids_by_ip_and_group=AsyncMock(return_value=[]), + get_blacklist_user=AsyncMock(return_value=None), + record_auto_kick=AsyncMock(return_value=2), + record_blacklist_timeout=AsyncMock(), + ) + return SimpleNamespace( + config=SimpleNamespace(settings=settings, reload=AsyncMock()), + client=client, + repository=repository, + policies=SimpleNamespace(get=AsyncMock(return_value=policy or settings.defaults)), + registry=ChallengeRegistry(), + ) + + +def message_for( + *, + user_id: int = 123456789, + chat_id: int = -1001, + text: str | None = None, + command: list[str] | None = None, +) -> Any: + reply = SimpleNamespace(id=501, delete=AsyncMock()) + return SimpleNamespace( + id=50, + from_user=SimpleNamespace(id=user_id, first_name="Admin"), + chat=SimpleNamespace(id=chat_id, title="group"), + text=text, + command=command, + forward_from=None, + service=None, + reply=AsyncMock(return_value=reply), + delete=AsyncMock(), + ) + + +def query_for(*, user_id: int = 42, message_id: int = 100, data: str = "1") -> Any: + return SimpleNamespace( + id="query", + data=data, + from_user=SimpleNamespace(id=user_id, first_name="Mod"), + message=SimpleNamespace(id=message_id, chat=SimpleNamespace(id=-1001)), + ) + + +def close_schedule(coroutine: Any, **_kwargs: Any) -> None: + coroutine.close() + + +@pytest.mark.asyncio +async def test_basic_admin_commands_and_reload(settings: Settings) -> None: + context = context_for(settings) + message = message_for() + await admin.help_command(context, context.client, message) + await admin.ping_command(context, context.client, message) + await admin.reload_config(context, context.client, message) + assert message.reply.await_count == 3 + + context.config.reload.side_effect = ValueError("bad") + await admin.reload_config(context, context.client, message) + assert "配置重载失败" in message.reply.await_args.args[0] + + outsider = message_for(user_id=999) + await admin.reload_config(context, context.client, outsider) + outsider.reply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_leave_and_set_config_commands(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: + context = context_for(settings) + usage = message_for(text="/leave") + await admin.leave_command(context, context.client, usage) + assert "使用方法" in usage.reply.await_args.args[0] + + valid = message_for(text="/leave -2002") + await admin.leave_command(context, context.client, valid) + context.client.leave_chat.assert_awaited_once_with(-2002, delete=True) + + monkeypatch.setattr(admin, "can_restrict_members", AsyncMock(return_value=True)) + command = message_for(text="/faset challenge_timeout 90") + await admin.set_config(context, context.client, command) + context.repository.set_group_config.assert_awaited_once() + assert command.reply.await_args.args[0] == "配置项设置成功" + + context.repository.set_group_config.side_effect = ValueError("invalid") + await admin.set_config(context, context.client, command) + assert command.reply.await_args.args[0] == "invalid" + + monkeypatch.setattr(admin, "can_restrict_members", AsyncMock(return_value=False)) + monkeypatch.setattr(admin, "schedule", close_schedule) + denied = message_for(text="/faset challenge_timeout 90") + await admin.set_config(context, context.client, denied) + assert denied.reply.await_args.args[0] == settings.messages.permission_denied + + +@pytest.mark.asyncio +async def test_admin_log_ip_sender_and_forward_commands(settings: Settings) -> None: + context = context_for(settings) + bad_sender = message_for(command=["sender", "bad"]) + await admin.sender_command(context, context.client, bad_sender) + assert bad_sender.reply.await_args.args[0] == "链接格式错误" + + context.client.get_messages.return_value = [ + SimpleNamespace(from_user=SimpleNamespace(id=42, mention=lambda text: text)) + ] + sender = message_for(command=["sender", "https://t.me/channel/10"]) + await admin.sender_command(context, context.client, sender) + assert sender.reply.await_args.args[0] == "42" + + get_log = message_for(command=["getlog", "challenge"]) + context.repository.logs_by_challenge.return_value = ["record"] + await admin.get_log(context, context.client, get_log) + assert get_log.reply.await_args.args[0] == "record" + + invalid_ip = message_for(command=["getuserbyip", "bad"]) + await admin.get_user_by_ip(context, context.client, invalid_ip) + assert invalid_ip.reply.await_args.args[0] == "IP 地址格式错误" + + context.repository.passed_users_by_ip.return_value = [(-1001, 42, datetime.now()), (-1001, 43, None)] + by_ip = message_for(command=["getuserbyip", "127.0.0.1"]) + await admin.get_user_by_ip(context, context.client, by_ip) + assert "用户共 2 个" in by_ip.reply.await_args.args[0] + + no_users = message_for(command=["banuserbyip", "127.0.0.1", "-1001"]) + await admin.ban_user_by_ip(context, context.client, no_users) + assert "没有找到" in no_users.reply.await_args.args[0] + context.repository.passed_user_ids_by_ip_and_group.return_value = [42, 43] + await admin.ban_user_by_ip(context, context.client, no_users) + assert "成功 2 个" in no_users.reply.await_args.args[0] + + forwarded = message_for() + await admin.forwarded_user_record(context, context.client, forwarded) + assert "关闭了消息转发权限" in forwarded.reply.await_args.args[0] + forwarded.forward_from = SimpleNamespace(id=42) + context.repository.logs_by_user.return_value = ["forwarded-record"] + await admin.forwarded_user_record(context, context.client, forwarded) + assert forwarded.reply.await_args.args[0] == "forwarded-record" + + +@pytest.mark.asyncio +async def test_clean_database_and_call_admin(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: + context = context_for(settings) + context.repository.all_blacklist_user_ids.return_value = [1, 2] + context.client.get_users.side_effect = [ + [SimpleNamespace(is_deleted=True)], + [SimpleNamespace(is_deleted=False)], + ] + message = message_for() + await admin.clean_database(context, context.client, message) + context.repository.remove_blacklist_users.assert_awaited_once_with([1]) + + async def members(*_args: Any, **_kwargs: Any): + yield SimpleNamespace(user=SimpleNamespace(id=1, is_bot=True), privileges=None) + yield SimpleNamespace( + user=SimpleNamespace(id=2, is_bot=False, mention=lambda text: f"@{text}"), + status=ChatMemberStatus.OWNER, + privileges=None, + ) + + context.client.get_me = AsyncMock(return_value=SimpleNamespace(id=10)) + context.client.get_chat_members = members + monkeypatch.setattr(admin, "schedule", close_schedule) + await admin.call_admin(context, context.client, message) + assert message.reply.await_args.args[0] == "@admin" + + +@pytest.mark.asyncio +async def test_start_and_join_request_challenges(settings: Settings) -> None: + context = context_for(settings) + start = message_for(user_id=42, command=["start", "-1001"]) + await challenge.start_command(context, context.client, start) + assert "不是你的验证数据" in start.reply.await_args.args[0] + + session = ChallengeSession( + "-1001|42", ChallengeKind.TURNSTILE, -1001, 42, "group", turnstile_id="token", join_request=True + ) + await context.registry.register(session, 60, AsyncMock()) + await challenge.start_command(context, context.client, start) + assert "点击下方按钮" in start.reply.await_args.args[0] + await context.registry.shutdown() + + request = SimpleNamespace(from_user=SimpleNamespace(id=42), chat=SimpleNamespace(id=-1001, title="group")) + await challenge.on_chat_join_request(context, context.client, cast(ChatJoinRequest, request)) + created = await context.registry.find(42, -1001) + assert created is not None and created.kind == ChallengeKind.MATH and created.message_id == 100 + await context.registry.shutdown() + + recaptcha_policy = settings.defaults.model_copy(update={"challenge_type": ChallengeType.RECAPTCHA}) + context = context_for(settings, policy=recaptcha_policy) + await challenge.on_chat_join_request(context, context.client, cast(ChatJoinRequest, request)) + created = await context.registry.find(42, -1001) + assert created is not None and created.kind == ChallengeKind.TURNSTILE and created.turnstile_id + await context.registry.shutdown() + + +def member_update(*, user_id: int = 42, is_self: bool = False) -> Any: + user = SimpleNamespace( + id=user_id, + is_self=is_self, + username="user", + first_name="First", + last_name="Last", + ) + return SimpleNamespace( + via_join_request=False, + new_chat_member=SimpleNamespace(user=user, status=ChatMemberStatus.MEMBER), + old_chat_member=None, + chat=SimpleNamespace(id=-1001, title="group", type=ChatType.SUPERGROUP), + from_user=user, + ) + + +@pytest.mark.asyncio +async def test_member_join_math_turnstile_duplicate_and_self(settings: Settings) -> None: + context = context_for(settings) + await challenge.on_chat_member_updated(context, context.client, member_update(is_self=True)) + context.client.send_message.assert_awaited() + + update = member_update() + await challenge.on_chat_member_updated(context, context.client, update) + created = await context.registry.find(42, -1001) + assert created is not None and created.kind == ChallengeKind.MATH and created.message_id == 100 + calls = context.client.restrict_chat_member.await_count + await challenge.on_chat_member_updated(context, context.client, update) + assert context.client.restrict_chat_member.await_count == calls + await context.registry.shutdown() + + policy = settings.defaults.model_copy(update={"challenge_type": ChallengeType.RECAPTCHA}) + context = context_for(settings, policy=policy) + await challenge.on_chat_member_updated(context, context.client, update) + created = await context.registry.find(42, -1001) + assert created is not None and created.kind == ChallengeKind.TURNSTILE + await context.registry.shutdown() + + +@pytest.mark.asyncio +async def test_member_join_blacklist_windows(settings: Settings) -> None: + update = member_update() + context = context_for(settings) + context.repository.get_blacklist_user.return_value = BlacklistRecord( + 42, datetime.now() - timedelta(seconds=1000), 1 + ) + await challenge.on_chat_member_updated(context, context.client, update) + context.client.ban_chat_member.assert_awaited_once() + context.repository.record_auto_kick.assert_awaited_once() + await context.registry.shutdown() + + context = context_for(settings) + context.repository.get_blacklist_user.return_value = BlacklistRecord(42, datetime.now(), 1) + await challenge.on_chat_member_updated(context, context.client, update) + context.repository.remove_blacklist_users.assert_awaited_once_with(42) + assert await context.registry.find(42, -1001) is not None + await context.registry.shutdown() + + +@pytest.mark.asyncio +async def test_private_math_callbacks(settings: Settings) -> None: + for answer, approved in (("7", True), ("8", False)): + context = context_for(settings) + session = ChallengeSession("-1001|42", ChallengeKind.MATH, -1001, 42, "group", answer=7, join_request=True) + await context.registry.register(session, 60, AsyncMock()) + query = query_for(data=f"{answer}|-1001") + await challenge.callback_router(context, context.client, query) + if approved: + context.client.approve_chat_join_request.assert_awaited_once() + else: + context.client.decline_chat_join_request.assert_awaited_once() + context.client.answer_callback_query.assert_awaited_once_with("query") + await context.registry.shutdown() + + +@pytest.mark.asyncio +async def test_admin_callbacks_allow_deny_and_permissions(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(challenge, "schedule", close_schedule) + for action in ("+", "-"): + context = context_for(settings) + session = ChallengeSession("-1001|100", ChallengeKind.MATH, -1001, 42, "group", message_id=100) + await context.registry.register(session, 60, AsyncMock()) + monkeypatch.setattr(challenge, "can_restrict_members", AsyncMock(return_value=True)) + await challenge.callback_router(context, context.client, query_for(data=action)) + if action == "+": + context.client.restrict_chat_member.assert_awaited_once() + else: + context.client.ban_chat_member.assert_awaited_once() + await context.registry.shutdown() + + context = context_for(settings) + session = ChallengeSession("-1001|100", ChallengeKind.MATH, -1001, 42, "group", message_id=100) + await context.registry.register(session, 60, AsyncMock()) + monkeypatch.setattr(challenge, "can_restrict_members", AsyncMock(return_value=False)) + await challenge.admin_callback(context, context.client, query_for(data="+"), "+") + assert await context.registry.get(session.key) is session + assert context.client.answer_callback_query.await_args.args[1] == settings.messages.permission_denied + await context.registry.shutdown() + + +@pytest.mark.asyncio +async def test_math_callbacks_correct_wrong_and_not_target(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(challenge, "schedule", close_schedule) + for answer in ("7", "8"): + context = context_for(settings) + session = ChallengeSession("-1001|100", ChallengeKind.MATH, -1001, 42, "group", message_id=100, answer=7) + await context.registry.register(session, 60, AsyncMock()) + await challenge.math_callback(context, context.client, query_for(), answer) + if answer == "8": + context.client.ban_chat_member.assert_awaited_once() + context.client.answer_callback_query.assert_awaited_once() + await context.registry.shutdown() + + context = context_for(settings) + session = ChallengeSession("-1001|100", ChallengeKind.MATH, -1001, 42, "group", message_id=100, answer=7) + await context.registry.register(session, 60, AsyncMock()) + await challenge.math_callback(context, context.client, query_for(user_id=99), "7") + assert context.client.answer_callback_query.await_args.args[1] == settings.messages.challenge_not_for_you + await context.registry.shutdown() + + +@pytest.mark.asyncio +async def test_delete_pending_and_timeouts(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(challenge.asyncio, "sleep", AsyncMock()) + monkeypatch.setattr(challenge, "schedule", close_schedule) + context = context_for(settings) + session = ChallengeSession("-1001|100", ChallengeKind.MATH, -1001, 42, "group", message_id=100) + await context.registry.register(session, 60, AsyncMock()) + pending = message_for(user_id=42) + await challenge.delete_pending_messages(context, context.client, pending) + pending.delete.assert_awaited_once() + await context.registry.shutdown() + + join = ChallengeSession("join", ChallengeKind.MATH, -1001, 42, "group", message_id=100, answer=7, join_request=True) + await challenge.challenge_timeout(context, join) + context.client.decline_chat_join_request.assert_awaited_once() + + normal = ChallengeSession("normal", ChallengeKind.MATH, -1001, 43, "group", message_id=101, answer=7) + await challenge.challenge_timeout(context, normal) + context.repository.record_blacklist_timeout.assert_awaited_once() + context.client.ban_chat_member.assert_awaited() + + auto = ChallengeSession("auto", ChallengeKind.AUTO_KICK, -1001, 44, "group") + calls = context.client.send_message.await_count + await challenge.challenge_timeout(context, auto) + assert context.client.send_message.await_count == calls + + +def test_registers_all_handlers(settings: Settings) -> None: + context = context_for(settings) + add_handler = Mock() + client = cast(Client, SimpleNamespace(add_handler=add_handler)) + admin.register_admin_handlers(client, context) + challenge.register_challenge_handlers(client, context) + assert add_handler.call_count == 17 diff --git a/tests/test_importer.py b/tests/test_importer.py new file mode 100644 index 0000000..cc27cb9 --- /dev/null +++ b/tests/test_importer.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock + +import pytest + +from captcha_bot.db.importer import import_csv +from captcha_bot.db.repository import Repository + + +class Transaction: + def __init__(self) -> None: + self.execute = AsyncMock() + + async def __aenter__(self) -> Transaction: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_imports_group_and_blacklist_csv(tmp_path: Path) -> None: + transaction = Transaction() + repository = cast(Repository, SimpleNamespace(sessions=SimpleNamespace(begin=lambda: transaction))) + groups = tmp_path / "groups.csv" + groups.write_text("-1,ban,kick,60,math,1\n-2,,,180,recaptcha,0\n", encoding="utf-8") + assert await import_csv(repository, groups, "group-config") == 2 + + blacklist = tmp_path / "blacklist.csv" + blacklist.write_text("1,1700000000,1,3\n2,1700000001,0,1\n", encoding="utf-8") + assert await import_csv(repository, blacklist, "blacklist-user") == 1 + assert transaction.execute.await_count == 2 + + +@pytest.mark.asyncio +async def test_import_rejects_unknown_table_and_skips_empty_rows(tmp_path: Path) -> None: + transaction = Transaction() + repository = cast(Repository, SimpleNamespace(sessions=SimpleNamespace(begin=lambda: transaction))) + empty = tmp_path / "empty.csv" + empty.write_text("2,1700000001,0,1\n", encoding="utf-8") + assert await import_csv(repository, empty, "blacklist-user") == 0 + with pytest.raises(ValueError, match="table must"): + await import_csv(repository, empty, "unknown") diff --git a/tests/test_repository_unit.py b/tests/test_repository_unit.py new file mode 100644 index 0000000..434dc01 --- /dev/null +++ b/tests/test_repository_unit.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest + +from captcha_bot.config import Settings +from captcha_bot.db.models import RecaptchaLogAction +from captcha_bot.db.repository import LogRecord, Repository, async_database_url + + +class Result: + def __init__(self, values: list[Any]) -> None: + self.values = values + + def __iter__(self): + return iter(self.values) + + def all(self) -> list[Any]: + return self.values + + +class Session: + def __init__(self) -> None: + self.get = AsyncMock(return_value=None) + self.execute = AsyncMock(return_value=Result([])) + self.scalar = AsyncMock(return_value=3) + self.scalars = AsyncMock(return_value=Result([])) + + async def __aenter__(self) -> Session: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + +class Sessions: + def __init__(self) -> None: + self.session = Session() + + def __call__(self) -> Session: + return self.session + + def begin(self) -> Session: + return self.session + + +def test_database_url_and_log_string() -> None: + assert async_database_url("postgresql://u:p@host/db") == "postgresql+psycopg://u:p@host/db" + assert async_database_url("postgresql+psycopg://u:p@host/db") == "postgresql+psycopg://u:p@host/db" + with pytest.raises(ValueError, match="PostgreSQL"): + async_database_url("sqlite:///db") + assert "127.0.0.1" in str(LogRecord(-1, 2, "id", "127.0.0.1", "ua", "Passed", datetime.now())) + + +@pytest.mark.asyncio +async def test_repository_reads_and_atomic_writes(settings: Settings) -> None: + sessions = Sessions() + repository = Repository(cast(Any, sessions)) + assert await repository.get_blacklist_user(1) is None + user = SimpleNamespace(user_id=1, last_attempt=datetime.now(), attempt_count=2) + sessions.session.get.return_value = user + record = await repository.get_blacklist_user(1) + assert record is not None and record.attempt_count == 2 + + await repository.record_blacklist_timeout(datetime.now(), 1) + assert await repository.record_auto_kick(1, datetime.now()) == 3 + await repository.remove_blacklist_users([]) + await repository.remove_blacklist_users([1, 2]) + sessions.session.scalars.return_value = Result([1, 2]) + assert await repository.all_blacklist_user_ids() == [1, 2] + assert sessions.session.execute.await_count == 2 + + +@pytest.mark.asyncio +async def test_group_config_parsing_and_immediate_reads(settings: Settings) -> None: + sessions = Sessions() + repository = Repository(cast(Any, sessions)) + assert await repository.get_group_overrides(-1) is None + sessions.session.get.return_value = SimpleNamespace( + timeout=90, + timeout_action="ban", + failed_action="kick", + challenge_type="recaptcha", + global_blacklist=False, + ) + overrides = await repository.get_group_overrides(-1) + assert overrides is not None and overrides.challenge_timeout == 90 and not overrides.enable_global_blacklist + + await repository.set_group_config(-1, "CHALLENGE_TIMEOUT", "120", settings.defaults) + await repository.set_group_config(-1, "challenge_type", "recaptcha", settings.defaults) + await repository.set_group_config(-1, "challenge_failed_action", "ban", settings.defaults) + await repository.set_group_config(-1, "enable_global_blacklist", "false", settings.defaults) + with pytest.raises(ValueError, match="正整数"): + await repository.set_group_config(-1, "challenge_timeout", "0", settings.defaults) + with pytest.raises(ValueError, match="0/1"): + await repository.set_group_config(-1, "enable_global_blacklist", "perhaps", settings.defaults) + with pytest.raises(ValueError, match="不支持"): + await repository.set_group_config(-1, "unknown", "1", settings.defaults) + + +@pytest.mark.asyncio +async def test_log_deduplication_queries_and_ip_lookups() -> None: + sessions = Sessions() + repository = Repository(cast(Any, sessions)) + await repository.log_recaptcha("id", 2, -1, "127.0.0.1", "ua", RecaptchaLogAction.PASSED) + + row = SimpleNamespace( + group_id=-1, + user_id=2, + challenge_id="id", + ip_addr="127.0.0.1", + user_agent="ua", + action="Passed", + created_at=datetime.now(), + ) + sessions.session.scalars.return_value = Result([row]) + assert len(await repository.logs_by_challenge("id")) == 1 + assert len(await repository.logs_by_user(2)) == 1 + assert len(await repository.logs_by_ip("127.0.0.1")) == 1 + + sessions.session.execute.return_value = Result([SimpleNamespace(group_id=-1, user_id=2, last_passed_at=None)]) + assert await repository.passed_users_by_ip("127.0.0.1") == [(-1, 2, None)] + sessions.session.scalars.return_value = Result([2, 3]) + assert await repository.passed_user_ids_by_ip_and_group("127.0.0.1", -1) == [2, 3] diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..2809981 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import httpx +import pytest + +from captcha_bot.config import ConfigStore, FailedAction +from captcha_bot.db.repository import GroupOverrides +from captcha_bot.services import PolicyService, TurnstileService + + +class FakeRepository: + def __init__(self, override: GroupOverrides | None) -> None: + self.override = override + + async def get_group_overrides(self, group_id: int) -> GroupOverrides | None: + del group_id + return self.override + + +@pytest.mark.asyncio +async def test_policy_service_merges_database_override(settings) -> None: + override = GroupOverrides(30, FailedAction.BAN, FailedAction.BAN, settings.defaults.challenge_type, False) + service = PolicyService(ConfigStore("config.example.toml", settings), FakeRepository(override)) + policy = await service.get(-1) + assert policy.challenge_timeout == 30 + assert policy.challenge_timeout_action == FailedAction.BAN + assert not policy.enable_global_blacklist + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("payload", "expected"), [({"success": True}, True), ({"success": False}, False)]) +async def test_turnstile_result(settings, payload: dict[str, bool], expected: bool) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=payload) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + service = TurnstileService(client, ConfigStore("config.example.toml", settings)) + assert (await service.verify("response", "127.0.0.1")).success is expected + + +@pytest.mark.asyncio +async def test_turnstile_network_failure(settings) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await TurnstileService(client, ConfigStore("config.example.toml", settings)).verify( + "response", "127.0.0.1" + ) + assert not result.success + assert result.error_codes == ("verification-unavailable",) diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..5496a7f --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.requests import Request + +from captcha_bot.challenges import ChallengeKind, ChallengeRegistry, ChallengeSession +from captcha_bot.config import ConfigStore +from captcha_bot.services import TurnstileResult +from captcha_bot.web import client_ip, register_web_routes + + +class FakeDatabase: + def __init__(self, healthy: bool = True) -> None: + self.healthy = healthy + + async def healthcheck(self) -> bool: + return self.healthy + + +class FakeRepository: + def __init__(self) -> None: + self.logs: list[tuple[Any, ...]] = [] + + async def log_recaptcha(self, *args: Any) -> None: + self.logs.append(args) + + +class FakeTurnstile: + def __init__(self, success: bool) -> None: + self.success = success + + async def verify(self, _token: str, _ip: str) -> TurnstileResult: + return TurnstileResult(self.success) + + +class FakePolicies: + def __init__(self, policy: Any) -> None: + self.policy = policy + + async def get(self, _chat_id: int) -> Any: + return self.policy + + +def web_app(settings, *, turnstile_success: bool = True) -> tuple[FastAPI, Any]: + app = FastAPI() + app.mount("/static", StaticFiles(directory="static"), name="static") + register_web_routes(app, Jinja2Templates(directory="templates")) + client = SimpleNamespace( + is_connected=True, + send_message=AsyncMock(), + approve_chat_join_request=AsyncMock(), + restrict_chat_member=AsyncMock(), + delete_messages=AsyncMock(), + edit_message_text=AsyncMock(), + ) + context = SimpleNamespace( + config=ConfigStore("config.example.toml", settings), + database=FakeDatabase(), + repository=FakeRepository(), + registry=ChallengeRegistry(), + turnstile=FakeTurnstile(turnstile_success), + policies=FakePolicies(settings.defaults), + client=client, + ) + app.state.context = context + return app, context + + +@pytest.mark.asyncio +async def test_status_health_and_missing_challenge(settings) -> None: + app, context = web_app(settings) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + root = await client.get("/") + assert root.status_code == 200 + assert root.headers["cache-control"] == "public, max-age=300" + assert root.headers["x-frame-options"] == "DENY" + health = await client.get("/healthz") + assert health.json() == {"status": "ok"} + missing = await client.get("/recaptcha") + assert "没有这条验证数据" in missing.text + context.database.healthy = False + assert (await client.get("/healthz")).status_code == 503 + + +@pytest.mark.asyncio +async def test_turnstile_get_failure_success_and_replay(settings) -> None: + app, context = web_app(settings, turnstile_success=False) + + async def no_timeout(_session: ChallengeSession) -> None: + return None + + session = ChallengeSession( + "-1|42", + ChallengeKind.TURNSTILE, + -1, + 42, + "group", + turnstile_id="token", + join_request=True, + ) + await context.registry.register(session, 60, no_timeout) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + page = await client.get("/recaptcha?challenge=token", headers={"CF-Connecting-IP": "1.2.3.4"}) + assert page.status_code == 200 + assert page.headers["cache-control"] == "no-store" + assert "replace-me" in page.text + failed = await client.post("/recaptcha?challenge=token", data={"g-recaptcha-response": "bad"}) + assert "验证状态异常" in failed.text + context.turnstile.success = True + passed = await client.post("/recaptcha?challenge=token", data={"g-recaptcha-response": "ok"}) + assert "您已通过验证" in passed.text + context.client.approve_chat_join_request.assert_awaited_once_with(-1, 42) + replay = await client.post("/recaptcha?challenge=token", data={"g-recaptcha-response": "ok"}) + assert "没有这条验证数据" in replay.text + assert len(context.repository.logs) == 3 + await context.registry.shutdown() + + +def test_client_ip_only_trusts_configured_proxy(settings) -> None: + _, context = web_app(settings) + trusted = cast( + Request, + SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"), headers={"CF-Connecting-IP": "8.8.8.8"}), + ) + assert client_ip(trusted, context) == "8.8.8.8" + untrusted = cast( + Request, + SimpleNamespace(client=SimpleNamespace(host="10.0.0.2"), headers={"CF-Connecting-IP": "8.8.8.8"}), + ) + assert client_ip(untrusted, context) == "10.0.0.2" + invalid = cast(Request, SimpleNamespace(client=SimpleNamespace(host="invalid"), headers={})) + assert client_ip(invalid, context) == "127.0.0.1" diff --git a/uv.lock b/uv.lock index 38615ff..be930c9 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,25 @@ requires-python = ">=3.14" [[package]] name = "alembic" -version = "1.18.4" +version = "1.18.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -27,94 +36,97 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] -name = "asgiref" -version = "3.11.1" +name = "boolean-py" +version = "5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, ] [[package]] -name = "blinker" -version = "1.9.0" +name = "cachecontrol" +version = "0.14.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, ] [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] name = "click" -version = "8.3.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -127,61 +139,138 @@ wheels = [ ] [[package]] -name = "distro" -version = "1.9.0" +name = "coverage" +version = "7.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, + { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/c9/5d0ccdd19bc7d8ab803b90695c1706aa2ea8529685d18e682dc2524d2630/cyclonedx_python_lib-11.11.0.tar.gz", hash = "sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102", size = 1442983, upload-time = "2026-06-17T11:57:49.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/f3/56ccb2884aaa3db5622368e5191a3384b15f35392aa93df8b2f508c660d2/cyclonedx_python_lib-11.11.0-py3-none-any.whl", hash = "sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44", size = 528689, upload-time = "2026-06-17T11:57:47.358Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] -name = "flask" -version = "3.1.3" +name = "fastapi" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] -[package.optional-dependencies] -async = [ - { name = "asgiref" }, +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, ] [[package]] name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, - { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, - { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, - { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, - { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, ] [[package]] @@ -223,20 +312,20 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] -name = "itsdangerous" -version = "2.2.0" +name = "iniconfig" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -251,52 +340,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, -] - [[package]] name = "kurigram" -version = "2.2.22" +version = "2.2.24" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyaes" }, { name = "python-socks" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/72/6e7098040fc2fa876c06b314bb6af9290d521276d93ce0e00833985ccb89/kurigram-2.2.22.tar.gz", hash = "sha256:b19a462b0c91b1f594f84e1b65e1c1c8010748f86d5b9a4279d7b6e4dfb021ef", size = 575603, upload-time = "2026-04-17T21:33:47.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/0a/6cd1ced210b02cabef151aa17c193418528c6d49f9d4368a2e0975d11f96/kurigram-2.2.24.tar.gz", hash = "sha256:535644dcb23696ae7d838a2f8c14675a5b3fa50906d51dc1434a29620d62813b", size = 602931, upload-time = "2026-07-11T11:29:54.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/44/3d6a84df70aa1fa3ab9940b32f0ea309c6c818062ad39709c39dc830e08f/kurigram-2.2.22-py3-none-any.whl", hash = "sha256:de1a7d787bd3727d930e600e6b4aae3189f85ddc47dd8ecacd5415a5157d812c", size = 5598390, upload-time = "2026-04-17T21:33:45.091Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/e5f953421b8229805a9f63e55accaa215fce126f75cd66e0a909c1d5ff18/kurigram-2.2.24-py3-none-any.whl", hash = "sha256:ec8417c8f767d974bb54cd19966d0057871c1b63faeb096f7a674702e3531824", size = 5799272, upload-time = "2026-07-11T11:29:52.31Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] [[package]] @@ -311,6 +377,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -342,41 +420,180 @@ wheels = [ ] [[package]] -name = "openai" -version = "2.36.0" +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pip" +version = "26.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] [[package]] -name = "psycopg2-binary" -version = "2.9.12" +name = "psycopg-binary" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, - { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, - { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, - { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] [[package]] @@ -441,6 +658,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "python-socks" version = "2.8.1" @@ -452,7 +738,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -460,73 +746,155 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "rich" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "telegram-captcha-bot-pyrogram" -version = "0.1.0" -source = { virtual = "." } +version = "1.0.0" +source = { editable = "." } dependencies = [ { name = "alembic" }, - { name = "flask", extra = ["async"] }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "jinja2" }, { name = "kurigram" }, - { name = "openai" }, - { name = "psycopg2-binary" }, - { name = "requests" }, - { name = "sqlalchemy" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "tgcrypto" }, - { name = "waitress" }, + { name = "tomli-w" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, ] [package.metadata] requires-dist = [ - { name = "alembic", specifier = "==1.18.4" }, - { name = "flask", extras = ["async"], specifier = "==3.1.3" }, - { name = "kurigram", specifier = "==2.2.22" }, - { name = "openai", specifier = "==2.36.0" }, - { name = "psycopg2-binary", specifier = "~=2.9.9" }, - { name = "requests", specifier = "==2.33.1" }, - { name = "sqlalchemy", specifier = "~=2.0.23" }, - { name = "tgcrypto", specifier = "~=1.2.5" }, - { name = "waitress", specifier = "==3.0.2" }, + { name = "alembic", specifier = ">=1.18,<2" }, + { name = "fastapi", specifier = ">=0.139,<1" }, + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "jinja2", specifier = ">=3.1,<4" }, + { name = "kurigram", specifier = ">=2.2,<3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, + { name = "pydantic", specifier = ">=2.13,<3" }, + { name = "python-multipart", specifier = ">=0.0.22,<1" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.49,<3" }, + { name = "tgcrypto", specifier = ">=1.2.5,<2" }, + { name = "tomli-w", specifier = ">=1.2,<2" }, + { name = "uvicorn", specifier = ">=0.51,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pip-audit", specifier = ">=2.10,<3" }, + { name = "pytest", specifier = ">=9,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3,<2" }, + { name = "pytest-cov", specifier = ">=7,<8" }, + { name = "ruff", specifier = ">=0.15,<1" }, + { name = "ty", specifier = ">=0.0.21,<1" }, ] [[package]] @@ -536,24 +904,73 @@ source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" } [[package]] -name = "tqdm" -version = "4.67.3" +name = "tomli" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "ty" +version = "0.0.59" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/84ae7b3bf6e3e9f57eb9635eeff5a80b36e57aa089f40be0fb5c384fa176/ty-0.0.59.tar.gz", hash = "sha256:53e53ffeed78ad59cd237fa8ea1316d2b94e13efdea9a945698acab549e005aa", size = 6145435, upload-time = "2026-07-12T20:22:02.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/e8/650b42fbef4d48e6ca682b0b6e9b68fa8fcf55cbb0a6892ab89990018b6f/ty-0.0.59-py3-none-linux_armv6l.whl", hash = "sha256:f8fb08a767ef8f11ea3c537b9d77860726cc2bc39e6f77ad13c02d5b289f20a7", size = 11700328, upload-time = "2026-07-12T20:21:26.046Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/0ca3a89d5f59ae5f308e5e83428cac5f9143200767743e052fba90b4b81e/ty-0.0.59-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c7f4d5630836c8a0ba13dd4ac7bdae080a7d6ebe965b817ff642dc961bcf2a53", size = 11494310, upload-time = "2026-07-12T20:21:28.491Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/5076de6001cefbccd8e6dc8472262697e43308ff66b0e87c72abba136357/ty-0.0.59-py3-none-macosx_11_0_arm64.whl", hash = "sha256:872f6fb02c6db5553c4d5fb283b3d50f0985fb9a29a910e4fda4793a775c1926", size = 11026797, upload-time = "2026-07-12T20:21:30.879Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/fca28481b6a138e2b798ad9fdc98a095475f9104948ba242fce4b477782b/ty-0.0.59-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af8eefbfe806337770eec12c0c819c5f1b8f5b85f8369cb1cc9fa25234a2208", size = 11475304, upload-time = "2026-07-12T20:21:33.041Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/1fed8b81b389ef4bbc0400f19e05fc16496b162577779dc0e5fc65ac216c/ty-0.0.59-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0acf8b76a1c9a7ddef460b42475f6c76193164426ab080783af1c3175b4b999b", size = 11533131, upload-time = "2026-07-12T20:21:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fc/04eec35e05a10e0fea1c6503a290ccc3935efda9c845aff64e83282c1af7/ty-0.0.59-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:043c2e00eb1d7475f928af7dedd71f69b64e69bfca55e36f4c968479e1373fc4", size = 12205932, upload-time = "2026-07-12T20:21:37.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dd/a61de859659fa11b55917ad38340a8f2c61f5ae17d1874929f29084c6990/ty-0.0.59-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0d688d857441df57f48fca66c029d85cf737c510e7be1d01144cdad1e58d968", size = 12758406, upload-time = "2026-07-12T20:21:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e8/fa66f05997eab8ca75fc4f17320140e25467849e0cc75597f898cc22099c/ty-0.0.59-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a96c9f88394a3b42c737e2125b2330543f0d90a43b49761f377d96f8c3ee0d62", size = 12288176, upload-time = "2026-07-12T20:21:41.784Z" }, + { url = "https://files.pythonhosted.org/packages/15/68/0fca59963bd5123f42d5f7da50667e7a52e8e9615e3a16d8c2c0d3b2d143/ty-0.0.59-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08dbcb268edcafcb152e59475b5b495ce28d0b340a395c09943557678f4d5a6", size = 12028471, upload-time = "2026-07-12T20:21:43.82Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/cd7dabbbab392578f11179919da5c25d8c3322e5388a688f539ea0539603/ty-0.0.59-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8812764b9a40fdc98df1272826e73a298ef56b06681135e643bcf90aad1896f7", size = 12297646, upload-time = "2026-07-12T20:21:45.76Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/2e9c94f0b383d8cbe1a35517ab470b7810bc9d7501603ab532bcd5be5e90/ty-0.0.59-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fd53b8581641d8dad7bfac6d5ea589e91a883d6837e0b9a286fdae30722b7c69", size = 11432519, upload-time = "2026-07-12T20:21:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0a/af93e9785200f11ac416cc20235fc2464c9bd978e791190684ea0e458795/ty-0.0.59-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:86da5872124a41877d95058bc17d33ddcff034b587eb5f1e2917ab88ba227dac", size = 11554993, upload-time = "2026-07-12T20:21:49.671Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dd/651bf87e20d00376c81b19124756491cffaf20eb8bec05a8794e5a8cf641/ty-0.0.59-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a233eef5f2fd4d894881e4a0aec83c9f172bfae1d787d6596ee1939fcc7723e", size = 11818230, upload-time = "2026-07-12T20:21:51.659Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/c947c4155fea751d135b19affdf734bbce72a94e446b866cf0c62f8bed69/ty-0.0.59-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ff678c18b5f1e3128b75a35e50dee7908dea55155baa31cd790619d5014cbf5", size = 12135194, upload-time = "2026-07-12T20:21:53.796Z" }, + { url = "https://files.pythonhosted.org/packages/b1/13/e5feb138888de1e95037c843571bbbd4ac21bf0a190507468098599a321f/ty-0.0.59-py3-none-win32.whl", hash = "sha256:cf8abb4b8095c5fe39102b8127f5886db308c8d4600909ddbc905512ce9c8163", size = 11179249, upload-time = "2026-07-12T20:21:55.752Z" }, + { url = "https://files.pythonhosted.org/packages/76/dd/52914dcbeeba92c207de40ef7109a58dcb5527aeb21c8f8feb7402aa9e29/ty-0.0.59-py3-none-win_amd64.whl", hash = "sha256:1dde20a82243d24407869e5a608c2f15efddd5cefc662aef461a5af84bfb3f8b", size = 12251079, upload-time = "2026-07-12T20:21:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -569,31 +986,32 @@ wheels = [ ] [[package]] -name = "urllib3" -version = "2.7.0" +name = "tzdata" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] -name = "waitress" -version = "3.0.2" +name = "urllib3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] -name = "werkzeug" -version = "3.1.8" +name = "uvicorn" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "click" }, + { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] diff --git a/web.py b/web.py deleted file mode 100644 index b435246..0000000 --- a/web.py +++ /dev/null @@ -1,162 +0,0 @@ -from pyrogram.errors import ChatAdminRequired, MessageNotModified -from pyrogram.filters import private -from pyrogram.types import ChatPermissions -from pyrogram import Client -import logging -from flask import Flask, request, flash -from flask import render_template -from challengedata import ChallengeData -import dbhelper as db - -app = Flask(__name__) -client: Client = None -_current_challenges = ChallengeData() -_config = dict() -_channel = 0 - - -@app.after_request -def add_response_headers(response): - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["X-Robots-Tag"] = "noindex, nofollow" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "script-src 'self' https://challenges.cloudflare.com; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data:; " - "frame-src https://challenges.cloudflare.com; " - "connect-src 'self' https://challenges.cloudflare.com; " - "form-action 'self'; " - "base-uri 'none'; " - "frame-ancestors 'none'" - ) - response.headers.pop("Cache-Control", None) - response.headers.pop("Pragma", None) - return response - - -@app.route("/") -def root(): - return render_template('index.html') - - -@app.route("/recaptcha", methods=["GET", "POST"]) -async def verify(): - # 测试模板用----------------------------------------------------- - # return render_template('recaptcha.html', sitekey=114514) - # 测试模板用----------------------------------------------------- - - if request.args.get('challenge') is None: - flash("没有这条验证数据!", "error") - return render_template('result.html') - else: - challenge_id = request.args.get('challenge') - try: - ch_id, challenge_data = _current_challenges.get_by_challenge_id(challenge_id) - except TypeError: - flash("没有这条验证数据!", "error") - return render_template('result.html') - - if challenge_data is None or ch_id is None: - flash("没有这条验证数据!", "error") - return render_template('result.html') - - challenge, target_id, timeout_event = challenge_data - join_request = False - if challenge.message.chat.id == target_id: - try: - chat_id = int(ch_id.split("|")[0]) - except ValueError: - logging.error(f"Invalid chat_id in challenge data: {ch_id}") - chat = await client.get_chat(chat_id) - chat_title = chat.title - join_request = True - else: - chat_id = challenge.message.chat.id - chat_title = challenge.message.chat.title - msg_id = challenge.message.id - user_ip = request.headers.get('CF-Connecting-IP', request.remote_addr) - ua = request.headers.get('User-Agent') - - group_config = _config.get(str(chat_id), _config["*"]) - - if request.method == "POST": - captcha_response = request.form['g-recaptcha-response'] - if not challenge.verify(captcha_response, user_ip): - flash('验证状态异常,请再试一次!', 'error') - return render_template('recaptcha.html', sitekey=challenge.site_key) - else: - timeout_event.stop() - try: - if not join_request: - await client.restrict_chat_member( - chat_id, - target_id, - permissions=ChatPermissions( - can_send_messages=True, - can_send_other_messages=True, - can_send_polls=True, - can_add_web_page_previews=True, - can_change_info=True, - can_invite_users=True, - can_pin_messages=True)) - else: - await client.approve_chat_join_request(chat_id, target_id) - except ChatAdminRequired: - pass - - _current_challenges.delete(ch_id) - - try: - await client.send_message( - int(_channel), - _config["msg_passed_answer"].format( - targetuserid=str(target_id), - groupid=str(chat_id), - grouptitle=str(chat_title), - ) + f"\n验证ID: `{challenge_id}`") - - db.log_recaptcha(challenge_id=challenge_id, - user_id=target_id, - chat_id=chat_id, - ip_addr=user_ip, - user_agent=ua, - action=db.RecaptchaLogAction.Passed) - except Exception as e: - logging.error(str(e)) - - if group_config["delete_passed_challenge"] and not join_request: - await client.delete_messages(chat_id, msg_id) - else: - try: - await client.edit_message_text( - chat_id=chat_id if not join_request else target_id, - message_id=msg_id, - text=group_config["msg_challenge_passed"], - reply_markup=None) - except MessageNotModified as e: - await client.send_message(int(_channel), - 'Bot 运行时发生异常: `' + str(e) + "`") - flash('您已通过验证,欢迎加入本群!如果仍然无法发言,请重启 Telegram 客户端。') - return render_template('result.html') - else: - try: - db.log_recaptcha(challenge_id=challenge_id, - user_id=target_id, - chat_id=chat_id, - ip_addr=user_ip, - user_agent=ua, - action=db.RecaptchaLogAction.PageVisit) - except Exception as e: - logging.error(e) - return render_template('recaptcha.html', sitekey=challenge.site_key) - - -if __name__ == '__main__': - app.debug = True - app.env = 'debug' - app.secret_key = 'A0Zr98j/3yX R~XHH!jmN]LWX/,?RT' - app.run(port=5000, host="127.0.0.1") From 7f62a492373c0a2d077a4130067743b5150025e6 Mon Sep 17 00:00:00 2001 From: NimaQu Date: Tue, 14 Jul 2026 01:31:26 -0500 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/captcha_bot/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/captcha_bot/services.py b/src/captcha_bot/services.py index 9afa814..388c9db 100644 --- a/src/captcha_bot/services.py +++ b/src/captcha_bot/services.py @@ -65,7 +65,7 @@ async def verify(self, response_token: str, remote_ip: str) -> TurnstileResult: ) response.raise_for_status() payload = response.json() - except httpx.HTTPError, ValueError, TypeError: + except (httpx.HTTPError, ValueError, TypeError): logger.exception("Turnstile verification request failed") return TurnstileResult(False, ("verification-unavailable",)) errors = tuple(str(value) for value in payload.get("error-codes", []))