diff --git a/README.md b/README.md index bed5ec8..5d96ca7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ -# Qwen Telegram Bot +# Qwen Console App -Telegram-бот на Python с четырьмя функциями: +Консольное приложение на Python с четырьмя функциями: -- 📷 **описание изображений** — присылаешь фото, бот возвращает текстовое описание через vision-модель Qwen -- 📊 **генерация графиков** — по текстовому запросу бот просит LLM написать код на matplotlib, выполняет его и отправляет PNG +- 📷 **описание изображений** — указываешь путь к файлу, приложение возвращает текстовое описание через vision-модель Qwen +- 📊 **генерация графиков** — по текстовому запросу LLM пишет код на matplotlib, приложение выполняет его и сохраняет PNG - 🏛 **архитектурные схемы** — по описанию системы LLM генерирует DOT, Graphviz рендерит это в PNG -- ♻️ **воспроизведение с фото** — кидаешь фото графика или архитектурной схемы, бот распознаёт тип, извлекает структуру и отрисовывает заново в своём стиле +- ♻️ **воспроизведение с фото** — указываешь путь к фото графика или схемы, приложение распознаёт тип и отрисовывает заново ## Стек -- [python-telegram-bot](https://docs.python-telegram-bot.org/) — асинхронный клиент Telegram Bot API - [openai](https://github.com/openai/openai-python) SDK — работа с любым OpenAI-совместимым провайдером (OpenRouter, DashScope, vLLM и др.) - [matplotlib](https://matplotlib.org/) + numpy — рендеринг графиков - [Graphviz](https://graphviz.org/) + [python-graphviz](https://github.com/xflr6/graphviz) — рендеринг архитектурных схем из DOT @@ -19,11 +18,10 @@ Telegram-бот на Python с четырьмя функциями: - Python **3.11+** - установленный `uv` ([инструкция](https://docs.astral.sh/uv/getting-started/installation/)) -- **бинарь Graphviz** в `$PATH` (нужен для команды `/arch`): +- **бинарь Graphviz** в `$PATH` (нужен для команды `arch`): - Linux: `sudo apt install graphviz` - macOS: `brew install graphviz` - Windows: [скачать с graphviz.org](https://graphviz.org/download/) -- токен Telegram-бота от [@BotFather](https://t.me/BotFather) - API-ключ провайдера, поддерживающего мультимодальную модель Qwen ## Установка @@ -44,57 +42,71 @@ uv sync cp .env.example .env ``` -> ⚠️ Для функции описания изображений модель должна быть **мультимодальной (VL)**. Текстовая модель упадёт при попытке передать ей картинку. +Переменные окружения: + +| Переменная | Обязательна | По умолчанию | Описание | +| --------------------- | ----------- | ----------------------------------------- | ------------------------------------------- | +| `LLM_API_KEY` | ✅ | — | API-ключ провайдера | +| `LLM_BASE_URL` | ❌ | `https://llm.lambda.coredump.ru/v1` | Base URL OpenAI-совместимого API | +| `LLM_MODEL` | ❌ | `qwen3.5-122b` | Имя модели | +| `LLM_DISABLE_THINKING`| ❌ | `false` | Отключить reasoning (для Qwen3/DeepSeek-R1) | + +> ⚠️ Для команды `describe` и `repro` модель должна быть **мультимодальной (VL)**. Текстовая модель упадёт при попытке передать ей картинку. ## Запуск ```bash -uv run bot.py +uv run src/app.py ``` -Бот запускается в режиме long-polling и пишет лог в stdout. +Приложение запускается в интерактивном режиме и ждёт команд. ## Использование -| Действие | Что делает бот | -| ------------------------------------ | ----------------------------------------------------------- | -| `/start` или `/help` | Показать справку | -| Отправить фото (без подписи-команды) | Вернуть описание изображения | -| `/plot <описание>` | Сгенерировать и прислать matplotlib-график | -| `/arch <описание>` | Нарисовать архитектурную схему через Graphviz | -| `/repro` + фото | Распознать график/схему и отрисовать её заново | - -Примеры запросов к `/plot`: - ``` -/plot sin(x) и cos(x) на отрезке [0, 2π] -/plot затухающая синусоида на [0, 10] -/plot столбчатая диаграмма продаж по месяцам с рандомными значениями -/plot гистограмма нормального распределения, 1000 сэмплов +>>> <команда> <аргумент> ``` -Примеры запросов к `/arch`: +| Команда | Что делает | +| ------------------------- | ------------------------------------------------------------- | +| `describe <путь>` | Описать изображение | +| `plot <описание>` | Сгенерировать matplotlib-график → сохраняется в `plot.png` | +| `arch <описание>` | Нарисовать архитектурную схему → сохраняется в `arch.png` | +| `repro <путь>` | Воспроизвести график/схему с картинки → сохраняется в `repro.png` | +| `help` | Показать справку | +| `exit` / `quit` / Ctrl+C | Выйти из приложения | + +Примеры для `plot`: ``` -/arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx -/arch микросервисы: auth, orders, payments. Общаются через RabbitMQ, у каждого своя БД Postgres, Prometheus собирает метрики -/arch телеграм-бот, принимает вебхуки, складывает задачи в очередь Celery с Redis-брокером, воркеры пишут результаты в S3 +>>> plot sin(x) и cos(x) на отрезке [0, 2π] +>>> plot затухающая синусоида на [0, 10] +>>> plot столбчатая диаграмма продаж по месяцам с рандомными значениями +>>> plot гистограмма нормального распределения, 1000 сэмплов ``` -### Как пользоваться `/repro` +Примеры для `arch`: -Два варианта: +``` +>>> arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx +>>> arch микросервисы: auth, orders, payments. Общаются через RabbitMQ, у каждого своя БД Postgres, Prometheus собирает метрики +>>> arch телеграм-бот, принимает вебхуки, складывает задачи в очередь Celery с Redis-брокером, воркеры пишут результаты в S3 +``` -1. **Прикрепить фото с подписью** `/repro` — бот сразу обработает его. -2. **Ответить** `/repro` на сообщение с фото (reply-to-message). +Пример для `repro`: -Бот классифицирует изображение как график (`plot`) либо архитектурную схему (`architecture`), извлекает структуру через vision-модель, генерирует для неё matplotlib-код или DOT и отрисовывает заново. Это не попиксельная копия — это реконструкция: бот старается передать тот же тип графика/те же компоненты и связи, но стиль будет его собственный. +``` +>>> repro chart.png +>>> repro /home/user/screenshots/arch_diagram.jpg +``` + +Результат `repro` — это не попиксельная копия, а реконструкция: приложение старается передать тот же тип графика / те же компоненты и связи, но стиль будет его собственный. ## Структура проекта ``` . -├── bot.py # основной скрипт: конфиг, LLM-клиент, хэндлеры Telegram +├── app.py # основной скрипт: конфиг, LLM-клиент, консольный цикл ├── pyproject.toml # зависимости и метаданные проекта для uv ├── uv.lock # фиксация версий (создаётся после uv sync) ├── .env.example # шаблон переменных окружения @@ -116,7 +128,7 @@ uv run <команда> # выполнить команду в окруж Архитектурные схемы безопаснее: Graphviz парсит DOT как данные, а не исполняет его как код, поэтому инъекция через промпт там в худшем случае даст кривую или огромную картинку. -Для публичного бота стоит изолировать исполнение `/plot`, например: +Для использования в более критичной среде стоит изолировать исполнение `plot`: - отдельный воркер-процесс с `resource.setrlimit` (CPU, память, время) - Docker-контейнер без сети и с read-only FS @@ -125,9 +137,9 @@ uv run <команда> # выполнить команду в окруж ## Возможные улучшения -- webhook вместо polling для продакшена -- очередь задач (arq / Celery) для тяжёлого рендеринга +- история команд (readline / prompt_toolkit) +- автодополнение путей к файлам по Tab +- флаг `--open` для автоматического открытия PNG после генерации - кэш описаний по хэшу изображения -- вывод сгенерированного кода графика в подписи к картинке (дебаг-режим) -- rate-limiting на пользователя +- вывод сгенерированного кода графика в режиме отладки (`--debug`) - тесты для `render_plot` с фиксированными промптами diff --git a/env.example b/env.example index fd50e57..ea8ad11 100644 --- a/env.example +++ b/env.example @@ -1,5 +1,4 @@ -TELEGRAM_BOT_TOKEN=your_telegram_bot_token LLM_API_KEY=your_api_key diff --git a/pyproject.toml b/pyproject.toml index 336c7e1..89efd4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,9 @@ [project] -name = "qwen-telegram-bot" +name = "qwen-console-app" version = "0.1.0" -description = "Telegram-бот: описание изображений и генерация графиков через Qwen" +description = "Консольное приложение: описание изображений и генерация графиков через Qwen" requires-python = ">=3.11" dependencies = [ - "python-telegram-bot>=21.0", "openai>=1.40.0", "python-dotenv>=1.0.0", "matplotlib>=3.7", diff --git a/src/bot.py b/src/app.py similarity index 58% rename from src/bot.py rename to src/app.py index bf81631..52df52f 100644 --- a/src/bot.py +++ b/src/app.py @@ -1,9 +1,11 @@ """ -Telegram-бот с четырьмя функциями: -1) получает изображение → возвращает его описание (vision-модель Qwen); -2) /plot <описание> → matplotlib-график; -3) /arch <описание системы> → архитектурная схема через Graphviz; -4) /repro (в подписи или ответе к фото) → воспроизводит график/схему с картинки. +Консольное приложение с четырьмя функциями: +1) describe <путь_к_картинке> — описание изображения (vision-модель Qwen); +2) plot <описание> — matplotlib-график (сохраняется в plot.png); +3) arch <описание системы> — архитектурная схема через Graphviz (arch.png); +4) repro <путь_к_картинке> — воспроизводит график/схему с картинки (repro.png). + +Команды вводятся в интерактивном режиме. Для выхода: exit / quit / Ctrl+C. """ import os @@ -15,42 +17,31 @@ import logging import asyncio import matplotlib -matplotlib.use("Agg") # без GUI-бэкенда, мы только сохраняем в буфер +matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import graphviz from dotenv import load_dotenv from openai import AsyncOpenAI -from telegram import Update -from telegram.ext import ( - Application, - CommandHandler, - MessageHandler, - ContextTypes, - filters, -) # ---------- конфиг ---------- load_dotenv() -TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") -LLM_API_KEY = os.getenv("LLM_API_KEY") -LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://llm.lambda.coredump.ru/v1") -LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-122b") -# Отключить thinking у reasoning-моделей (актуально для Qwen3/DeepSeek-R1 и т.п.), -# чтобы все токены шли в финальный ответ, а не в -блок. +LLM_API_KEY = os.getenv("LLM_API_KEY") +LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://llm.lambda.coredump.ru/v1") +LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-122b") DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "false").lower() in ("1", "true", "yes") -if not TELEGRAM_TOKEN or not LLM_API_KEY: - raise RuntimeError("В .env должны быть заданы TELEGRAM_BOT_TOKEN и LLM_API_KEY") +if not LLM_API_KEY: + raise RuntimeError("В .env должна быть задана переменная LLM_API_KEY") logging.basicConfig( - format="%(asctime)s | %(name)s | %(levelname)s | %(message)s", - level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(message)s", + level=logging.WARNING, # в консоли не засоряем вывод INFO-логами ) -logger = logging.getLogger("qwen-bot") +logger = logging.getLogger("qwen-app") client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL) @@ -95,7 +86,7 @@ async def describe_image(image_bytes: bytes) -> str: ], } ], - **_chat_kwargs(max_tokens=1024), + **_chat_kwargs(max_tokens=8192), ) return _extract_content(response).strip() @@ -149,7 +140,7 @@ def render_plot(code: str) -> bytes: Выполняет сгенерированный код и возвращает PNG-байты. ВНИМАНИЕ: exec используется в ограниченном неймспейсе, но это не настоящая - песочница. Для публичного бота лучше изолировать исполнение (отдельный + песочница. Для публичного приложения лучше изолировать исполнение (отдельный процесс с resource-лимитами, Docker, nsjail и т. п.). """ plt.close("all") @@ -200,7 +191,7 @@ async def generate_arch_dot(description: str) -> str: {"role": "system", "content": ARCH_SYSTEM_PROMPT}, {"role": "user", "content": description}, ], - **_chat_kwargs(max_tokens=8192), # DOT + возможный reasoning могут съесть много + **_chat_kwargs(max_tokens=8192), ) return _strip_code_fences(_extract_content(response)) @@ -275,145 +266,144 @@ async def analyze_and_reproduce(image_bytes: bytes) -> tuple[str, str, str]: ) -# ---------- Telegram-хэндлеры ---------- +# ---------- вспомогательные утилиты ---------- -async def cmd_start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None: - await update.message.reply_text( - "Привет! Я умею:\n" - "📷 описывать фото — просто пришли картинку\n" - "📊 строить графики — команда /plot <описание>\n" - "🏛 рисовать архитектурные схемы — команда /arch <описание системы>\n" - "♻️ воспроизводить график/схему с фото — пришли фото с подписью /repro " - "или ответь /repro на сообщение с фото\n\n" - "Примеры:\n" - " /plot sin(x) и cos(x) на отрезке [0, 2π]\n" - " /arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx" +def read_image(path: str) -> bytes: + """Читает файл изображения с диска.""" + if not os.path.isfile(path): + raise FileNotFoundError(f"Файл не найден: {path}") + with open(path, "rb") as f: + return f.read() + + +def save_png(png: bytes, filename: str) -> str: + """Сохраняет PNG-байты в файл рядом со скриптом и возвращает путь.""" + out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename) + with open(out_path, "wb") as f: + f.write(png) + return out_path + + +def print_help() -> None: + print( + "\nДоступные команды:\n" + " describe <путь> — описать изображение\n" + " plot <описание> — построить график (сохраняется в plot.png)\n" + " arch <описание> — нарисовать архитектурную схему (arch.png)\n" + " repro <путь> — воспроизвести график/схему с картинки (repro.png)\n" + " help — эта справка\n" + " exit / quit — выход\n" ) -async def on_photo(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None: - await update.message.chat.send_action("typing") - photo = update.message.photo[-1] # берём самый большой размер - tg_file = await photo.get_file() - image_bytes = bytes(await tg_file.download_as_bytearray()) - try: - text = await describe_image(image_bytes) - await update.message.reply_text(text) - except Exception as e: - logger.exception("Ошибка при описании изображения") - await update.message.reply_text(f"Не смог разобрать изображение: {e}") +# ---------- обработчики команд ---------- - -async def cmd_plot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - request = " ".join(context.args).strip() - if not request: - await update.message.reply_text( - "Опиши, какой график построить. Пример:\n/plot затухающая синусоида на [0, 10]" - ) +async def handle_describe(arg: str) -> None: + if not arg: + print("Укажи путь к картинке. Пример: describe photo.jpg") return - - await update.message.chat.send_action("upload_photo") - try: - code = await generate_plot_code(request) - logger.info("Сгенерирован код:\n%s", code) - png = await asyncio.to_thread(render_plot, code) - await update.message.reply_photo(photo=png, caption=request[:1024]) - except Exception as e: - logger.exception("Ошибка при построении графика") - await update.message.reply_text(f"Не получилось построить график: {e}") + print("⏳ Анализирую изображение...") + image_bytes = read_image(arg) + text = await describe_image(image_bytes) + print(f"\n{text}\n") -async def cmd_arch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - description = " ".join(context.args).strip() - if not description: - await update.message.reply_text( +async def handle_plot(arg: str) -> None: + if not arg: + print("Опиши, какой график построить. Пример: plot sin(x) на [0, 2π]") + return + print("⏳ Генерирую код и строю график...") + code = await generate_plot_code(arg) + logger.debug("Сгенерирован код:\n%s", code) + png = await asyncio.to_thread(render_plot, code) + path = save_png(png, "plot.png") + print(f"✅ График сохранён: {path}\n") + + +async def handle_arch(arg: str) -> None: + if not arg: + print( "Опиши архитектуру системы. Пример:\n" - "/arch React-фронт, FastAPI-бэкенд с воркерами Celery, Postgres, Redis и S3" + " arch React-фронт, FastAPI-бэкенд, Postgres и Redis за nginx" ) return - - await update.message.chat.send_action("upload_photo") - try: - dot = await generate_arch_dot(description) - logger.info("Сгенерирован DOT:\n%s", dot) - png = await asyncio.to_thread(render_architecture, dot) - await update.message.reply_photo(photo=png, caption=description[:1024]) - except graphviz.ExecutableNotFound: - logger.exception("Нет бинаря graphviz") - await update.message.reply_text( - "На сервере не установлен Graphviz. Поставь его: `apt install graphviz` или `brew install graphviz`." - ) - except Exception as e: - logger.exception("Ошибка при построении архитектуры") - await update.message.reply_text(f"Не получилось построить схему: {e}") + print("⏳ Генерирую архитектурную схему...") + dot = await generate_arch_dot(arg) + logger.debug("Сгенерирован DOT:\n%s", dot) + png = await asyncio.to_thread(render_architecture, dot) + path = save_png(png, "arch.png") + print(f"✅ Схема сохранена: {path}\n") -async def cmd_repro(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None: - """ - Воспроизводит график/схему с приложенного фото. - Работает двумя способами: - 1) фото с подписью "/repro" - 2) команда "/repro" в ответ (reply) на сообщение с фото - """ - msg = update.message - if msg.photo: - source_msg = msg - elif msg.reply_to_message and msg.reply_to_message.photo: - source_msg = msg.reply_to_message +async def handle_repro(arg: str) -> None: + if not arg: + print("Укажи путь к картинке. Пример: repro chart.png") + return + print("⏳ Анализирую и воспроизвожу...") + image_bytes = read_image(arg) + kind, code, notes = await analyze_and_reproduce(image_bytes) + logger.debug("repro type=%s notes=%s\ncode:\n%s", kind, notes, code) + + if kind == "plot": + png = await asyncio.to_thread(render_plot, code) + path = save_png(png, "repro.png") + label = f"📊 {notes}" if notes else "📊 Воспроизведённый график" + print(f"✅ {label}\n Сохранён: {path}\n") + elif kind == "architecture": + png = await asyncio.to_thread(render_architecture, code) + path = save_png(png, "repro.png") + label = f"🏛 {notes}" if notes else "🏛 Воспроизведённая схема" + print(f"✅ {label}\n Сохранена: {path}\n") else: - await msg.reply_text( - "Пришли фото с подписью /repro или ответь /repro на сообщение с фото." - ) - return - - await msg.chat.send_action("upload_photo") - tg_file = await source_msg.photo[-1].get_file() - image_bytes = bytes(await tg_file.download_as_bytearray()) - - try: - kind, code, notes = await analyze_and_reproduce(image_bytes) - logger.info("repro type=%s notes=%s\ncode:\n%s", kind, notes, code) - - if kind == "plot": - png = await asyncio.to_thread(render_plot, code) - caption = f"📊 {notes}" if notes else "📊 Воспроизведённый график" - elif kind == "architecture": - png = await asyncio.to_thread(render_architecture, code) - caption = f"🏛 {notes}" if notes else "🏛 Воспроизведённая схема" - else: - await msg.reply_text( - "Это не похоже ни на график, ни на архитектурную схему." - + (f"\n\n{notes}" if notes else "") - ) - return - - await msg.reply_photo(photo=png, caption=caption[:1024]) - except graphviz.ExecutableNotFound: - logger.exception("Нет бинаря graphviz") - await msg.reply_text( - "На сервере не установлен Graphviz. Поставь его: `apt install graphviz` / " - "`brew install graphviz` / `winget install graphviz`." - ) - except Exception as e: - logger.exception("Ошибка при воспроизведении") - await msg.reply_text(f"Не получилось воспроизвести: {e}") + msg = "Это не похоже ни на график, ни на архитектурную схему." + if notes: + msg += f"\n{notes}" + print(f"ℹ️ {msg}\n") -# ---------- запуск ---------- +# ---------- главный цикл ---------- -def main() -> None: - app = Application.builder().token(TELEGRAM_TOKEN).build() - app.add_handler(CommandHandler(["start", "help"], cmd_start)) - app.add_handler(CommandHandler("plot", cmd_plot)) - app.add_handler(CommandHandler("arch", cmd_arch)) - app.add_handler(CommandHandler("repro", cmd_repro)) - # фото без подписи-команды → описание (иначе команда уже обработана выше) - app.add_handler( - MessageHandler(filters.PHOTO & ~filters.CaptionRegex(r"^/"), on_photo) - ) - logger.info("Бот запущен") - app.run_polling(allowed_updates=Update.ALL_TYPES) +async def main() -> None: + print("🤖 Qwen Console App") + print_help() + + while True: + try: + line = input(">>> ").strip() + except (EOFError, KeyboardInterrupt): + print("\nВыход.") + break + + if not line: + continue + + parts = line.split(maxsplit=1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) > 1 else "" + + try: + if cmd in ("exit", "quit"): + print("Выход.") + break + elif cmd == "help": + print_help() + elif cmd == "describe": + await handle_describe(arg) + elif cmd == "plot": + await handle_plot(arg) + elif cmd == "arch": + await handle_arch(arg) + elif cmd == "repro": + await handle_repro(arg) + else: + print(f"Неизвестная команда: {cmd!r}. Введи help для справки.") + except FileNotFoundError as e: + print(f"❌ {e}") + except graphviz.ExecutableNotFound: + print("❌ Graphviz не установлен. Установи: apt install graphviz / brew install graphviz / winget install graphviz") + except Exception as e: + print(f"❌ Ошибка: {e}") if __name__ == "__main__": - main() \ No newline at end of file + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index df28301..994ceab 100644 --- a/uv.lock +++ b/uv.lock @@ -852,20 +852,7 @@ wheels = [ ] [[package]] -name = "python-telegram-bot" -version = "22.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpcore", marker = "python_full_version >= '3.14'" }, - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" }, -] - -[[package]] -name = "qwen-telegram-bot" +name = "qwen-console-app" version = "0.1.0" source = { virtual = "." } dependencies = [ @@ -874,7 +861,6 @@ dependencies = [ { name = "numpy" }, { name = "openai" }, { name = "python-dotenv" }, - { name = "python-telegram-bot" }, ] [package.metadata] @@ -884,7 +870,6 @@ requires-dist = [ { name = "numpy", specifier = ">=1.24" }, { name = "openai", specifier = ">=1.40.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "python-telegram-bot", specifier = ">=21.0" }, ] [[package]]