From 4b2c419c3c03ee65a73dffc735e204ed3c0ab38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=20=D0=9C=D0=B0=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2?= Date: Fri, 21 Nov 2025 14:35:23 +0300 Subject: [PATCH 1/4] Init branch dev --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b7faf40..ed56063 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[codz] *$py.class +.DS_Store # C extensions *.so From fc3bab6d306faf1604fcd20b3c07c13733fa9b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=20=D0=9C=D0=B0=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2?= Date: Thu, 25 Dec 2025 01:22:54 +0300 Subject: [PATCH 2/4] create main structure and functional --- .gitignore | 10 +- .pre-commit-config.yaml | 27 ++ Makefile | 26 ++ README.md | 43 +- api/openapi/bots.yaml | 652 +++++++++++++++++++++++++++++ itsreg_cli/__init__.py | 0 itsreg_cli/__main__.py | 4 + itsreg_cli/api/__init__.py | 0 itsreg_cli/api/client.py | 91 ++++ itsreg_cli/config/__init__.py | 0 itsreg_cli/config/settings.py | 28 ++ itsreg_cli/domain/__init__.py | 0 itsreg_cli/domain/models.py | 36 ++ itsreg_cli/main.py | 42 ++ itsreg_cli/services/__init__.py | 0 itsreg_cli/services/bot_service.py | 166 ++++++++ itsreg_cli/tui/__init__.py | 0 itsreg_cli/tui/menus.py | 108 +++++ openapi-config.json | 5 + pre-commit.sh | 75 ++++ requirements-dev.txt | 8 + requirements.txt | 5 + 22 files changed, 1323 insertions(+), 3 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 api/openapi/bots.yaml create mode 100644 itsreg_cli/__init__.py create mode 100644 itsreg_cli/__main__.py create mode 100644 itsreg_cli/api/__init__.py create mode 100644 itsreg_cli/api/client.py create mode 100644 itsreg_cli/config/__init__.py create mode 100644 itsreg_cli/config/settings.py create mode 100644 itsreg_cli/domain/__init__.py create mode 100644 itsreg_cli/domain/models.py create mode 100644 itsreg_cli/main.py create mode 100644 itsreg_cli/services/__init__.py create mode 100644 itsreg_cli/services/bot_service.py create mode 100644 itsreg_cli/tui/__init__.py create mode 100644 itsreg_cli/tui/menus.py create mode 100644 openapi-config.json create mode 100755 pre-commit.sh create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index ed56063..63bf7df 100644 --- a/.gitignore +++ b/.gitignore @@ -183,15 +183,21 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ # Ruff stuff: .ruff_cache/ +# Generated OpenAPI client +itsreg_api/ +openapi_client/ +itsreg_api_client/ +its_reg_api_client/ + # PyPI configuration file .pypirc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..561d467 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + args: ["--profile", "black"] + + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: ["--max-line-length=120", "--extend-ignore=E203"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d303a20 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +VENV=.venv +PYTHON=$(VENV)/bin/python +PIP=$(VENV)/bin/pip +OPENAPI_CLIENT=$(VENV)/bin/openapi-python-client + +$(VENV): + python3 -m venv $(VENV) + +install: $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt + $(PIP) install -r requirements-dev.txt + $(VENV)/bin/pre-commit install + +generate-client: $(VENV) + rm -rf its_reg_api_client itsreg_api_client openapi_client itsreg_api + $(OPENAPI_CLIENT) generate \ + --url https://raw.githubusercontent.com/bmstu-itstech/itsreg/main/api/openapi/bots.yaml \ + --config openapi-config.json \ + --meta none + +run: generate-client + $(PYTHON) -m itsreg_cli + +clean: + rm -rf $(VENV) itsreg_api diff --git a/README.md b/README.md index 0300b4c..dc464e9 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# itsreg-cli \ No newline at end of file +# itsreg-cli + +TUI клиент для управления Telegram-ботами ITS Reg. + +## Установка +```bash +git clone https://github.com/bmstu-itstech/itsreg-cli +cd itsreg-cli +make install +``` + +## Запуск +```bash +export ITSREG_JWT_TOKEN=your_token +export ITSREG_API_URL=https://itsreg.itsbmstu.ru/api/v2/ +make run +``` + +## Архитектура +``` +itsreg-cli/ +├── domain/ # Domain Models (Pydantic) +├── api/ # API Layer (openapi-codegen + wrapper) +├── services/ # Business Logic (domain only) +├── tui/ # TUI экраны (questionary + rich) +├── config/ # Конфигурация +├── main.py # Entry point +├── requirements*.txt # зависимости (runtime/dev) +├── Makefile # make install, generate-client +└── README.md # Инструкции +``` + +## Конфигурация (приоритет) +1. CLI флаги: `itsreg-tui --api=https://... --jwt-token=...` +2. ENV: `ITSREG_JWT_TOKEN`, `ITSREG_API_URL` +3. Если ничего нет → ошибка: `ITSREG_JWT_TOKEN не задан` + +## Генерация клиента +```bash +make generate-client +``` +Используется `openapi-python-client` и спецификация `api/openapi/bots.yaml`. diff --git a/api/openapi/bots.yaml b/api/openapi/bots.yaml new file mode 100644 index 0000000..7e6f1a5 --- /dev/null +++ b/api/openapi/bots.yaml @@ -0,0 +1,652 @@ +openapi: 3.0.3 +info: + title: ITS Reg API + description: ITS Reg API + version: 2.0.0 +servers: + - url: "{url}" +security: + - bearerAuth: [] + +paths: + /bots: + put: + operationId: createBot + description: Создать или заменить существующего бота с данным ID. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PutBots' + responses: + "201": + description: Бот успешно создан. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + get: + operationId: getBots + description: Получить информацию о ботах пользователя + responses: + "200": + description: Успешно получена информация о ботах. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Bot' + "400": + description: "Данные в запросе невалидны." + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}: + get: + operationId: getBot + description: Получить информацию о боте с указанным ID. + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "200": + description: Бот найден. + content: + application/json: + schema: + $ref: '#/components/schemas/Bot' + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + delete: + operationId: deleteBot + description: Удалить бота с указанным ID + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "204": + description: Бот успешно удалён. + content: + application/json: + schema: + $ref: '#/components/schemas/Bot' + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/status: + get: + operationId: getStatus + description: Получить статус инстанса бота в данный момент времени. + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "200": + description: Успешно получен статус бота + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/answers: + get: + operationId: getAnswers + description: Получить ответы участников на бота с данным ID в формате CSV. + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "200": + description: Успешно получены ответы участников. + content: + text/csv: { } + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/start: + post: + operationId: startBot + description: Запустить бота с указанным ID + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "204": + description: Бот успешно запущен. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/stop: + post: + operationId: stopBot + description: Остановить бота с указанным ID + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "204": + description: Бот успешно остановлен. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/enable: + post: + operationId: enableBot + description: Включить автозапуск для бота + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "204": + description: Бот успешно запущен и добавлен в автозапуск. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/disable: + post: + operationId: disableBot + description: Выключить автозапуск бота + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + responses: + "204": + description: Бот удалён из автозапуска. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + + /bots/{id}/mailing: + post: + operationId: mailing + description: Сделать рассылку по массиву пользователей. + parameters: + - in: path + name: id + schema: + type: string + example: example_bot + required: true + description: Уникальный ID бота. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PostMailing' + responses: + "200": + description: Рассылка прошла успешно. + "400": + description: Данные в запросе невалидны. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "401": + description: Не был указан JWT токен. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + "404": + description: Бот с данным ID не найден. + content: + application/json: + schema: + $ref: '#/components/schemas/PlainError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + Predicate: + type: object + description: Predicate описывает условие перехода по ребру. + oneOf: + - $ref: '#/components/schemas/AlwaysPredicate' + - $ref: '#/components/schemas/ExactPredicate' + - $ref: '#/components/schemas/RegexPredicate' + discriminator: + propertyName: type + mapping: + always: '#/components/schemas/AlwaysPredicate' + exact: '#/components/schemas/ExactPredicate' + regex: '#/components/schemas/RegexPredicate' + + AlwaysPredicate: + type: object + description: Переход по ребру осуществляется на любое сообщение пользователя. + properties: + type: + type: string + enum: [always] + required: + - type + + ExactPredicate: + type: object + description: Переход по ребру осуществляется при полном совпадении строки text с сообщением пользователя. + properties: + type: + type: string + enum: [exact] + text: + type: string + required: + - type + - text + + RegexPredicate: + type: object + description: Переход по ребру осуществляется при совпадении с регулярным выражением pattern. + properties: + type: + type: string + enum: [regex] + pattern: + type: string + required: + - type + - pattern + + Edge: + type: object + description: Обозначают связь между узлами как переход в результате ответа пользователя. + properties: + predicate: + $ref: '#/components/schemas/Predicate' + to: + type: integer + description: State узла, к которому совершается переход. + operation: + type: string + description: > + Действие, которое выполнится в результате перехода пользователя по ребру. + - noop. Ничего не происходит. Подходит для использования в меню и промежуточных узлах. + - save. Сохраняет ответ или перезаписывает предыдущий. Подходит в большинстве ситуаций. + - append. Добавляет ответ к предыдущему. Подходит для вопросов с множественным выбором. + enum: + - noop + - save + - append + required: + - predicate + - to + - operation + + Message: + type: object + description: Любое сообщение в Telegram. На данный момент описывается текстом, но в будущем добавится поддержка файлов. + properties: + text: + type: string + required: + - text + + Node: + type: object + description: > + Минимальная структурная единица сценария бота. Представляет собой сообщение (сообщения), которые отправляются + пользователю. Ожидается ответ пользователя для перехода к следующему узлу. + properties: + state: + type: integer + description: Уникальный номер узла в сценарии бота. + title: + type: string + description: Человеко-читаемое название узла. В таблице ответов будет отображаться как заголовок столбца. + edges: + type: array + description: Массив исходящих рёбер узла. + items: + $ref: '#/components/schemas/Edge' + messages: + type: array + description: Массив отправляемых ботом сообщений при вхождении в узел. + items: + $ref: '#/components/schemas/Message' + options: + type: array + description: Массив кнопок (опций) ответа для пользователя. + items: + type: string + required: + - state + - title + - messages + + Entry: + type: object + description: > + Точка входа в сценарий бота. Пользователь может вызвать точку входу командой / (как, например, /start). + Может быть вызвана рассылкой по такому же ключу. + properties: + key: + type: string + description: Ключ точки входа. + start: + type: integer + description: Указатель на State узла, с которого начинается выполнение сценария. + required: + - key + - start + + Script: + type: object + description: Сценарий бота. + properties: + nodes: + type: array + items: + $ref: '#/components/schemas/Node' + entries: + type: array + items: + $ref: '#/components/schemas/Entry' + required: + - nodes + - entries + + Bot: + type: object + properties: + id: + type: string + description: Уникальный ID бота. + token: + type: string + description: Телеграм токен для бота, полученный в @BotFather. + author: + type: integer + format: int64 + description: ID пользователя - автора бота. + enabled: + type: boolean + description: Автозапуск бота + script: + $ref: '#/components/schemas/Script' + required: + - id + - token + - author + - enabled + - script + + PutBots: + type: object + properties: + id: + type: string + description: Уникальный ID бота. + token: + type: string + description: Телеграм токен для бота, полученный в @BotFather. + script: + $ref: '#/components/schemas/Script' + required: + - id + - token + - script + + PlainError: + type: object + properties: + message: + type: string + example: error message + required: + - message + + Status: + type: string + enum: + - running + - idle + - dead + description: Статус инстанса бота. + + PostMailing: + type: object + properties: + entryKey: + type: string + description: Ключ точки входа, которая будет выполнена для списка пользователей. + users: + type: array + items: + type: integer + format: int64 + description: Список пользователей, для которых будет выполнен скрипт начиная с точки входа entryKey. + required: + - entryKey + - users + + InvalidInputError: + type: object + properties: + code: + type: string + example: message-empty-text + message: + type: string + example: expected not empty message text + details: + type: object + additionalProperties: + type: string + example: + field: text + state: 5 + required: + - code + - message + + Error: + oneOf: + - $ref: '#/components/schemas/PlainError' + - $ref: '#/components/schemas/InvalidInputError' + - type: array + items: + oneOf: + - $ref: '#/components/schemas/PlainError' + - $ref: '#/components/schemas/InvalidInputError' + example: + - code: message-empty-text + message: expected not empty message text + details: + field: text + state: 5 + - code: edge-invalid-pattern + message: failed to compile regex pattern + details: + field: pattern + state: 4 + pattern: some-invalid-pattern diff --git a/itsreg_cli/__init__.py b/itsreg_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/__main__.py b/itsreg_cli/__main__.py new file mode 100644 index 0000000..a6d0d6b --- /dev/null +++ b/itsreg_cli/__main__.py @@ -0,0 +1,4 @@ +from itsreg_cli.main import main + +if __name__ == "__main__": + main() diff --git a/itsreg_cli/api/__init__.py b/itsreg_cli/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/api/client.py b/itsreg_cli/api/client.py new file mode 100644 index 0000000..53108d5 --- /dev/null +++ b/itsreg_cli/api/client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import List + +import httpx + +from itsreg_cli.config.settings import Settings +from itsreg_cli.domain.models import Bot, Script + + +class ApiError(RuntimeError): + pass + + +class ItsRegClient: + def __init__(self, settings: Settings): + self.settings = settings + base_url = settings.api_url.rstrip("/") + "/" + self._client = httpx.Client( + base_url=base_url, + headers={"Authorization": settings.jwt_token}, + timeout=10.0, + ) + + def list_bots(self) -> List[Bot]: + response = self._client.get("bots") + self._raise_if_error(response) + payload = response.json() + raw_items = payload.get("bots") if isinstance(payload, dict) else payload + return [Bot.model_validate(item) for item in raw_items or []] + + def create_bot(self, bot: Bot) -> Bot: + payload = { + "id": bot.id, + "token": bot.token or "", + "script": bot.script.model_dump() + if bot.script + else {"nodes": [], "entries": []}, + } + response = self._client.put("bots", json=payload) + self._raise_if_error(response, payload) + try: + data = response.json() + return Bot.model_validate(data) + except Exception: + fetch = self._client.get(f"bots/{bot.id}") + self._raise_if_error(fetch) + return Bot.model_validate(fetch.json()) + + def delete_bot(self, bot_id: str) -> None: + response = self._client.delete(f"bots/{bot_id}") + self._raise_if_error(response) + + def get_bot(self, bot_id: str) -> Bot: + response = self._client.get(f"bots/{bot_id}") + self._raise_if_error(response) + return Bot.model_validate(response.json()) + + def update_script(self, bot_id: str, script: Script) -> Bot: + response = self._client.post(f"bots/{bot_id}/script", json=script.model_dump()) + self._raise_if_error(response) + return Bot.model_validate(response.json()) + + def enable_bot(self, bot_id: str) -> None: + response = self._client.post(f"bots/{bot_id}/enable") + self._raise_if_error(response) + + def disable_bot(self, bot_id: str) -> None: + response = self._client.post(f"bots/{bot_id}/disable") + self._raise_if_error(response) + + def close(self) -> None: + self._client.close() + + def _raise_if_error( + self, response: httpx.Response, request_payload: dict | None = None + ) -> None: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text + payload_info = f" | Request: {request_payload}" if request_payload else "" + raise ApiError( + f"API error {exc.response.status_code}: {detail}{payload_info}" + ) from exc + + def __enter__(self) -> "ItsRegClient": + return self + + def __exit__(self, *_) -> None: + self.close() diff --git a/itsreg_cli/config/__init__.py b/itsreg_cli/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/config/settings.py b/itsreg_cli/config/settings.py new file mode 100644 index 0000000..25b3c3d --- /dev/null +++ b/itsreg_cli/config/settings.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + api_url: str = "https://itsreg.itsbmstu.ru/api/v2/" + jwt_token: str + + model_config = SettingsConfigDict( + env_prefix="ITSREG_", + extra="forbid", + case_sensitive=False, + env_file=".env", + env_file_encoding="utf-8", + ) + + +def load_settings( + cli_api_url: str | None = None, cli_jwt_token: str | None = None +) -> Settings: + overrides: dict[str, str] = {} + if cli_api_url: + overrides["api_url"] = cli_api_url + if cli_jwt_token: + overrides["jwt_token"] = cli_jwt_token + + return Settings(**overrides) diff --git a/itsreg_cli/domain/__init__.py b/itsreg_cli/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/domain/models.py b/itsreg_cli/domain/models.py new file mode 100644 index 0000000..b12d4ea --- /dev/null +++ b/itsreg_cli/domain/models.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import List, Literal + +from pydantic import BaseModel + + +class Message(BaseModel): + text: str + + +class Edge(BaseModel): + predicate: str | dict + to: int | None = None + operation: Literal["noop", "save", "append"] | None = None + + +class Node(BaseModel): + state: int + title: str + messages: List[Message] + edges: List[Edge] = [] + options: List[str] = [] + + +class Script(BaseModel): + nodes: List[Node] = [] + entries: List[dict] = [] + + +class Bot(BaseModel): + id: str + token: str | None = None + enabled: bool + status: Literal["running", "idle", "dead"] | None = "idle" + script: Script | None = None diff --git a/itsreg_cli/main.py b/itsreg_cli/main.py new file mode 100644 index 0000000..ff5a02e --- /dev/null +++ b/itsreg_cli/main.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import argparse +import sys + +from pydantic import ValidationError + +from itsreg_cli.api.client import ItsRegClient +from itsreg_cli.config.settings import Settings, load_settings +from itsreg_cli.services.bot_service import BotService +from itsreg_cli.tui.menus import main_menu + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ITS Reg TUI клиент") + parser.add_argument("--api", dest="api_url", help="Базовый URL API") + parser.add_argument( + "--jwt-token", dest="jwt_token", help="JWT токен аутентификации" + ) + return parser.parse_args(argv) + + +def ensure_token(settings: Settings) -> None: + if not settings.jwt_token: + raise SystemExit("ITSREG_JWT_TOKEN не задан") + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(argv or sys.argv[1:]) + try: + settings = load_settings(cli_api_url=args.api_url, cli_jwt_token=args.jwt_token) + ensure_token(settings) + except ValidationError as exc: + raise SystemExit("ITSREG_JWT_TOKEN не задан") from exc + + with ItsRegClient(settings) as client: + service = BotService(client) + main_menu(service) + + +if __name__ == "__main__": + main() diff --git a/itsreg_cli/services/__init__.py b/itsreg_cli/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/services/bot_service.py b/itsreg_cli/services/bot_service.py new file mode 100644 index 0000000..b7ad2a2 --- /dev/null +++ b/itsreg_cli/services/bot_service.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import List + +import questionary + +from itsreg_cli.api.client import ItsRegClient +from itsreg_cli.domain.models import Bot, Edge, Message, Node, Script + + +class BotService: + def __init__(self, client: ItsRegClient): + self.client = client + + def get_my_bots(self) -> List[Bot]: + return self.client.list_bots() + + def get_bot(self, bot_id: str) -> Bot: + return self.client.get_bot(bot_id) + + def delete_bot(self, bot_id: str) -> None: + self.client.delete_bot(bot_id) + + def create_bot_interactive(self) -> Bot: + bot_id = questionary.text("Введите идентификатор бота:").ask() + if not bot_id: + raise ValueError("Идентификатор бота обязателен") + + token = questionary.text("Введите Telegram токен (BotFather):").ask() + if not token: + raise ValueError("Токен обязателен") + + nodes_by_state: dict[int, Node] = {} + self._ask_node_full(nodes_by_state, prefilled_state=1) + while questionary.confirm("Добавить ещё узел?", default=False).ask(): + self._ask_node_full(nodes_by_state) + + if not nodes_by_state: + raise ValueError("Нужно создать хотя бы один узел") + + entries: List[dict] = [] + while True: + key = questionary.text( + "Entry key:", default="start" if not entries else "" + ).ask() + if not key: + break + start_str = questionary.text( + "Стартовое состояние:", default=str(min(nodes_by_state.keys())) + ).ask() or str(min(nodes_by_state.keys())) + try: + start = int(start_str) + except ValueError: + start = min(nodes_by_state.keys()) + if start == 0 or start not in nodes_by_state: + raise ValueError("Указано несуществующее состояние для entry") + entries.append({"key": key, "start": start}) + if not questionary.confirm("Добавить ещё entry?", default=False).ask(): + break + if not entries: + entries.append({"key": "start", "start": min(nodes_by_state.keys())}) + + nodes = list(nodes_by_state.values()) + script = Script(nodes=nodes, entries=entries) + bot = Bot(id=bot_id, token=token, enabled=True, status="idle", script=script) + return self.client.create_bot(bot) + + def _ask_node_full( + self, nodes_by_state: dict[int, Node], prefilled_state: int | None = None + ) -> Node: + default_state = ( + prefilled_state + if prefilled_state is not None + else (max(nodes_by_state.keys()) + 1 if nodes_by_state else 1) + ) + state_str = questionary.text( + "Номер состояния узла:", default=str(default_state) + ).ask() or str(default_state) + try: + state = int(state_str) + except ValueError: + state = default_state + if state == 0: + state = 1 + title = ( + questionary.text(f"Название узла #{state}:", default=f"state-{state}").ask() + or f"state-{state}" + ) + messages: List[Message] = [] + while True: + message_text = questionary.text("Текст сообщения узла:").ask() + if message_text: + messages.append(Message(text=message_text)) + if not questionary.confirm("Добавить ещё сообщение?", default=False).ask(): + break + if not messages: + messages.append(Message(text="")) + edges: List[Edge] = [] + while questionary.confirm( + "Добавить переход из этого узла?", default=False + ).ask(): + predicate_type = ( + questionary.select( + "Тип условия (predicate):", + choices=[ + questionary.Choice(title="Всегда", value="always"), + questionary.Choice(title="Точный текст", value="exact"), + questionary.Choice(title="Регекс", value="regex"), + ], + default="exact", + ).ask() + or "exact" + ) + if predicate_type == "always": + predicate = {"type": "always"} + elif predicate_type == "regex": + pattern = ( + questionary.text("Введите regex-паттерн:", default=".*").ask() + or ".*" + ) + predicate = {"type": "regex", "pattern": pattern} + else: + text_value = ( + questionary.text("Ожидаемый текст:", default="Далее").ask() + or "Далее" + ) + predicate = {"type": "exact", "text": text_value} + to_state_str = questionary.text( + "Целевое состояние (число):", default=str(state + 1) + ).ask() or str(state + 1) + try: + to_state = int(to_state_str) + except ValueError: + to_state = state + 1 + if to_state == 0: + to_state = 1 + operation = ( + questionary.select( + "Операция:", choices=["noop", "save", "append"], default="noop" + ).ask() + or "noop" + ) + if to_state not in nodes_by_state: + if questionary.confirm( + f"Создать узел состояния {to_state} сейчас?", default=True + ).ask(): + self._ask_node_full(nodes_by_state, prefilled_state=to_state) + edges.append(Edge(predicate=predicate, to=to_state, operation=operation)) + options: List[str] = [] + while questionary.confirm( + "Добавить вариант ответа (option)?", default=False + ).ask(): + option_text = questionary.text("Текст опции:").ask() + if option_text: + options.append(option_text) + node = Node( + state=state, title=title, messages=messages, edges=edges, options=options + ) + nodes_by_state[state] = node + return node + + def enable_bot(self, bot_id: str) -> None: + self.client.enable_bot(bot_id) + + def disable_bot(self, bot_id: str) -> None: + self.client.disable_bot(bot_id) diff --git a/itsreg_cli/tui/__init__.py b/itsreg_cli/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/itsreg_cli/tui/menus.py b/itsreg_cli/tui/menus.py new file mode 100644 index 0000000..d37ac2b --- /dev/null +++ b/itsreg_cli/tui/menus.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +from typing import List + +import questionary +from rich.console import Console +from rich.table import Table + +from itsreg_cli.domain.models import Bot +from itsreg_cli.services.bot_service import BotService + +console = Console() + + +def main_menu(service: BotService) -> None: + while True: + choice = questionary.select( + "Выберите опцию:", + choices=[ + "Создать бота", + "Мои боты", + questionary.Separator(), + "Выход", + ], + ).ask() + + if choice == "Мои боты": + show_bots_list(service) + elif choice == "Создать бота": + try: + bot = service.create_bot_interactive() + console.print(f"Бот '{bot.id}' создан!", style="green") + except Exception as e: + console.print(f"Ошибка: {e}", style="red") + elif choice == "Выход": + break + + +def show_bots_list(service: BotService) -> None: + bots = service.get_my_bots() + if not bots: + console.print("Ботов пока нет.", style="yellow") + return + + _render_bots_table(bots) + selected = questionary.select( + "Выберите бота для деталей:", + choices=[*(bot.id for bot in bots), "Назад"], + ).ask() + if selected and selected != "Назад": + show_bot_details(service, selected) + + +def show_bot_details(service: BotService, bot_id: str) -> None: + while True: + bot = service.get_bot(bot_id) + console.print(f"Детали бота {bot.id}", style="cyan") + console.print(f"Статус: {bot.status}, Включен: {bot.enabled}") + console.print("Сценарий:") + console.print_json(json.dumps(bot.script.model_dump(), ensure_ascii=False)) + action = questionary.select( + "Действие:", + choices=[ + "Включить автозапуск", + "Выключить автозапуск", + "Удалить бота", + "Назад", + ], + ).ask() + if action == "Включить автозапуск": + try: + service.enable_bot(bot_id) + console.print("Включено в автозапуск.", style="green") + except Exception as e: + console.print(f"Ошибка: {e}", style="red") + elif action == "Выключить автозапуск": + try: + service.disable_bot(bot_id) + console.print("Удалён из автозапуска.", style="yellow") + except Exception as e: + console.print(f"Ошибка: {e}", style="red") + elif action == "Удалить бота": + if questionary.confirm(f"Удалить бота '{bot_id}'?", default=False).ask(): + try: + service.delete_bot(bot_id) + console.print(f"Бот '{bot_id}' удален.", style="red") + break + except Exception as e: + console.print(f"Ошибка: {e}", style="red") + else: + break + + +def _render_bots_table(bots: List[Bot]) -> None: + table = Table(title="Мои боты") + table.add_column("ID") + table.add_column("Статус") + table.add_column("Включен") + table.add_column("Узлов") + + for bot in bots: + nodes_count = ( + str(len(bot.script.nodes)) if bot.script and bot.script.nodes else "0" + ) + table.add_row(bot.id, bot.status, "Да" if bot.enabled else "Нет", nodes_count) + + console.print(table) diff --git a/openapi-config.json b/openapi-config.json new file mode 100644 index 0000000..3029508 --- /dev/null +++ b/openapi-config.json @@ -0,0 +1,5 @@ +{ + "project_name_override": "itsreg_api_client", + "package_name_override": "its_reg_api_client", + "post_hooks": [] +} diff --git a/pre-commit.sh b/pre-commit.sh new file mode 100755 index 0000000..15cce35 --- /dev/null +++ b/pre-commit.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +echo "Running pre-commit checks..." + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +ERRORS=0 + +error() { + echo -e "${RED}ERROR: $1${NC}" + ERRORS=1 +} + +warning() { + echo -e "${YELLOW}WARNING: $1${NC}" +} + +info() { + echo -e "${GREEN}INFO: $1${NC}" +} + +info "Checking Python syntax..." +find . -name "*.py" -not -path "./venv/*" -not -path "./.venv/*" -not -path "*/__pycache__/*" | while read -r file; do + if ! python -m py_compile "$file" >/dev/null 2>&1; then + error "Syntax error in $file" + python -m py_compile "$file" 2>&1 | head -10 + fi +done + +if command -v flake8 >/dev/null 2>&1; then + info "Running flake8..." + if ! flake8 . --exclude=venv,.venv,__pycache__ --max-line-length=120; then + error "flake8 found issues" + fi +else + warning "flake8 not installed, skipping" +fi + +if command -v isort >/dev/null 2>&1; then + info "Checking import sorting with isort..." + if ! isort . --check-only --diff --skip venv --skip .venv --skip __pycache__; then + error "Imports need sorting. Run 'isort .' to fix" + fi +else + warning "isort not installed, skipping" +fi + +if command -v black >/dev/null 2>&1; then + info "Checking code formatting with black..." + if ! black . --check --exclude="venv|.venv|__pycache__"; then + error "Code needs formatting. Run 'black .' to fix" + fi +else + warning "black not installed, skipping" +fi + +if command -v mypy >/dev/null 2>&1; then + info "Running type checking with mypy..." + if ! mypy . --ignore-missing-imports --exclude '(venv|.venv|__pycache__)'; then + error "Type checking found issues" + fi +else + info "mypy not installed, skipping" +fi + +if [ $ERRORS -eq 0 ]; then + info "All pre-commit checks passed!" + exit 0 +else + error "Pre-commit checks failed. Please fix the issues above." + exit 1 +fi diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ca4ec96 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt +openapi-python-client>=0.19,<0.20 +pre-commit>=3.6,<3.7 +ruff>=0.3,<0.4 +black>=24.4,<25.0 +mypy>=1.10,<1.11 +typer>=0.9,<0.10 +click>=8.1,<8.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32287d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +questionary>=2.0,<3.0 +rich>=13.0,<14.0 +pydantic-settings>=2.0,<3.0 +httpx>=0.27,<0.28 +python-dotenv>=1.0,<2.0 From adc22f3a2c46583cbea72abd29a7308a6ff86ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=20=D0=9C=D0=B0=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2?= Date: Thu, 25 Dec 2025 19:03:00 +0300 Subject: [PATCH 3/4] update tui, add function to generate bot by json file --- README.md | 224 ++++++++++++++++++++++++++--- itsreg_cli/api/client.py | 30 +++- itsreg_cli/json_importer.py | 86 +++++++++++ itsreg_cli/services/bot_service.py | 107 ++++++++------ itsreg_cli/tui/menus.py | 80 ++++++++++- requirements-dev.txt | 8 -- 6 files changed, 460 insertions(+), 75 deletions(-) create mode 100644 itsreg_cli/json_importer.py delete mode 100644 requirements-dev.txt diff --git a/README.md b/README.md index dc464e9..7eab539 100644 --- a/README.md +++ b/README.md @@ -3,40 +3,232 @@ TUI клиент для управления Telegram-ботами ITS Reg. ## Установка + +### Требования +- Python 3.10+ +- pip +- git + +### Шаги установки ```bash git clone https://github.com/bmstu-itstech/itsreg-cli cd itsreg-cli make install ``` +Команда `make install` создаст виртуальное окружение и установит все зависимости. + ## Запуск + +### Использование переменных окружения ```bash -export ITSREG_JWT_TOKEN=your_token +export ITSREG_JWT_TOKEN=your_jwt_token_here export ITSREG_API_URL=https://itsreg.itsbmstu.ru/api/v2/ make run ``` +### Использование .env файла +Создайте файл `.env` в корне проекта: +```env +ITSREG_API_URL=https://itsreg.itsbmstu.ru/api/v2/ +ITSREG_JWT_TOKEN=your_jwt_token_here +``` + +Затем запустите: +```bash +make run +``` + +### Использование аргументов командной строки +```bash +python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_jwt_token +``` + +### Аргументы CLI + +- `--api URL` - Базовый URL API (по умолчанию: из переменной окружения `ITSREG_API_URL`) +- `--jwt-token TOKEN` - JWT токен для аутентификации (по умолчанию: из переменной окружения `ITSREG_JWT_TOKEN`) + +### Приоритет настроек +1. Аргументы командной строки (`--api`, `--jwt-token`) +2. Переменные окружения (`ITSREG_API_URL`, `ITSREG_JWT_TOKEN`) +3. Файл `.env` + +Если JWT токен не указан ни одним из способов, приложение завершится с ошибкой. + +## Функционал + +### Главное меню +При запуске приложения открывается главное меню с опциями: +- **Создать бота** - интерактивное создание нового бота +- **Мои боты** - просмотр списка вашихботов +- **Выход** - завершение работы + +### Создание бота + +Интерактивный режим создания бота позволяет: + +1. **Основная информация** + - Указать идентификатор бота (ID) + - Ввести Telegram токен от BotFather + +2. **Создание узлов (nodes)** + - Указать номер состояния (state) - любое положительное число + - Задать название узла (title) + - Добавить одно или несколько сообщений для пользователя + - Настроить переходы (edges) между узлами: + - **Тип условия (predicate)**: + - **Всегда** - переход срабатывает на любое сообщение + - **Точный текст** - переход по точному совпадению текста + - **Регекс** - переход по регулярному выражению + - **Целевое состояние** - номер узла, к которому произойдет переход + - **Операция**: + - `noop` - ничего не происходит + - `save` - сохранить или перезаписать ответ пользователя + - `append` - добавить ответ к предыдущему + - Добавить опции (кнопки) для пользователя + +3. **Создание связанных узлов** + - При указании перехода к несуществующему узлу, система предложит создать его немедленно + - Поддержка произвольных номеров состояний (1, 2, 104, 201 и т.д.) + +4. **Точки входа (entries)** + - Задать ключи для входа в сценарий (например, `start`, `m_ok`, `m_progress`) + - Указать начальное состояние для каждой точки входа + - Команды доступны пользователям как `/start`, `/m_ok` и т.д. + +### Просмотр ботов + +В разделе "Мои боты": +- Отображается таблица со всеми вашими ботами +- Показывается ID, статус, включен ли автозапуск, количество узлов +- Выбор бота для просмотра деталей + +### Управление ботом + +После выбора бота доступны действия: + +1. **Включить автозапуск** + - Добавляет бота в автозапуск + - Бот будет автоматически запускаться при перезагрузке сервера + +2. **Выключить автозапуск** + - Удаляет бота из автозапуска + - Бот останется доступен, но не будет автоматически запускаться + +3. **Удалить бота** + - Полное удаление бота со всеми данными + - Требует подтверждения + +4. **Просмотр деталей** + - Статус бота (running/idle/dead) + - Полный JSON сценария со всеми узлами и переходами + ## Архитектура + ``` itsreg-cli/ -├── domain/ # Domain Models (Pydantic) -├── api/ # API Layer (openapi-codegen + wrapper) -├── services/ # Business Logic (domain only) -├── tui/ # TUI экраны (questionary + rich) -├── config/ # Конфигурация -├── main.py # Entry point -├── requirements*.txt # зависимости (runtime/dev) -├── Makefile # make install, generate-client -└── README.md # Инструкции +├── itsreg_cli/ +│ ├── domain/ # Доменные модели (Pydantic) +│ │ └── models.py # Bot, Node, Edge, Script, Message +│ ├── api/ # API клиент +│ │ └── client.py # ItsRegClient (httpx) +│ ├── services/ # Бизнес-логика +│ │ └── bot_service.py # Интерактивное создание ботов +│ ├── tui/ # Пользовательский интерфейс +│ │ └── menus.py # Меню и экраны (questionary + rich) +│ ├── config/ # Конфигурация +│ │ └── settings.py # Загрузка настроек из .env +│ └── main.py # Точка входа +├── api/ +│ └── openapi/ +│ └── bots.yaml # OpenAPI спецификация +├── requirements.txt # Зависимости для запуска +├── requirements-dev.txt # Зависимости для разработки +├── Makefile # Команды для разработки +├── .pre-commit-config.yaml # Настройки pre-commit хуков +└── README.md # Этот файл ``` -## Конфигурация (приоритет) -1. CLI флаги: `itsreg-tui --api=https://... --jwt-token=...` -2. ENV: `ITSREG_JWT_TOKEN`, `ITSREG_API_URL` -3. Если ничего нет → ошибка: `ITSREG_JWT_TOKEN не задан` +## Разработка + +### Установка зависимостей для разработки +```bash +make install-dev +``` -## Генерация клиента +### Генерация клиента API +При обновлении OpenAPI спецификации: ```bash make generate-client ``` -Используется `openapi-python-client` и спецификация `api/openapi/bots.yaml`. + +Используется `openapi-python-client` и спецификация из `api/openapi/bots.yaml`. + +### Pre-commit хуки +```bash +pip install pre-commit +pre-commit install +pre-commit run --all-files +``` + +Автоматически запускаются проверки: +- trailing whitespace +- end of file fixer +- YAML валидация +- black (форматирование) +- isort (сортировка импортов) +- flake8 (линтинг) + +### Запуск тестов +```bash +make test +``` + +## Примеры использования + +### Создание простого бота +```bash +make run +# Выберите "Создать бота" +# ID: my_bot +# Token: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz +# Entry key: start +# Узел #1: "Приветствие" +# Сообщение: "Добро пожаловать!" +# Добавить переход: Да +# Тип: Точный текст +# Текст: "Далее" +# Состояние: 2 +# Операция: noop +``` + +### Создание сложного сценария с множеством узлов +См. пример в API: узлы с состояниями 1-9, 104, 201, 202 для регистрации на мероприятие с проверкой группы, переводом средств и подтверждениями. + +## Получение JWT токена + +1. Войдите на https://itsreg.itsbmstu.ru +2. Откройте DevTools (F12) → Network +3. Найдите любой запрос к API +4. Скопируйте значение заголовка `Authorization` + +## Troubleshooting + +### Ошибка "ITSREG_JWT_TOKEN не задан" +Убедитесь, что токен указан через: +- Переменную окружения `ITSREG_JWT_TOKEN` +- Файл `.env` +- Аргумент `--jwt-token` + +### Ошибка "API error 400: node-not-found" +При создании ботов убедитесь, что: +- Все указанные в переходах состояния существуют +- Не используется зарезервированное состояние 0 +- В entries указаны существующие состояния + +### Ошибка подключения к API +Проверьте: +- Доступность API по адресу из `ITSREG_API_URL` +- Корректность JWT токена +- Наличие интернет-соединения diff --git a/itsreg_cli/api/client.py b/itsreg_cli/api/client.py index 53108d5..b2d3ea2 100644 --- a/itsreg_cli/api/client.py +++ b/itsreg_cli/api/client.py @@ -26,17 +26,22 @@ def list_bots(self) -> List[Bot]: response = self._client.get("bots") self._raise_if_error(response) payload = response.json() - raw_items = payload.get("bots") if isinstance(payload, dict) else payload + raw_items = payload return [Bot.model_validate(item) for item in raw_items or []] def create_bot(self, bot: Bot) -> Bot: payload = { "id": bot.id, "token": bot.token or "", - "script": bot.script.model_dump() - if bot.script - else {"nodes": [], "entries": []}, + "script": ( + bot.script.model_dump() if bot.script else {"nodes": [], "entries": []} + ), } + import json + + print("\n=== DEBUG: Payload being sent to API ===") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + print("=== End DEBUG ===\n") response = self._client.put("bots", json=payload) self._raise_if_error(response, payload) try: @@ -69,6 +74,11 @@ def disable_bot(self, bot_id: str) -> None: response = self._client.post(f"bots/{bot_id}/disable") self._raise_if_error(response) + def get_bot_answers(self, bot_id: str) -> str: + response = self._client.get(f"bots/{bot_id}/answers", timeout=300.0) + self._raise_if_error(response) + return response.text + def close(self) -> None: self._client.close() @@ -80,6 +90,18 @@ def _raise_if_error( except httpx.HTTPStatusError as exc: detail = exc.response.text payload_info = f" | Request: {request_payload}" if request_payload else "" + if exc.response.status_code >= 500: + import json + + print("\n=== SERVER ERROR DEBUG ===") + print(f"Status: {exc.response.status_code}") + print(f"Response text: {detail}") + print(f"Response headers: {dict(exc.response.headers)}") + if request_payload: + print( + f"Sent payload: {json.dumps(request_payload, indent=2, ensure_ascii=False)}" + ) + print("=== End Debug ===\n") raise ApiError( f"API error {exc.response.status_code}: {detail}{payload_info}" ) from exc diff --git a/itsreg_cli/json_importer.py b/itsreg_cli/json_importer.py new file mode 100644 index 0000000..4b88a39 --- /dev/null +++ b/itsreg_cli/json_importer.py @@ -0,0 +1,86 @@ +import json +import sys +from pathlib import Path + +from itsreg_cli.api.client import ItsRegClient +from itsreg_cli.config.settings import load_settings +from itsreg_cli.domain.models import Bot, Edge, Message, Node, Script + + +def load_bot_from_json(json_path: str) -> Bot: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + + nodes = [] + for node_data in data.get("script", {}).get("nodes", []): + edges = [] + for edge_data in node_data.get("edges", []): + edge = Edge( + predicate=edge_data.get("predicate"), + to=edge_data.get("to"), + operation=edge_data.get("operation"), + ) + edges.append(edge) + + messages = [] + for msg_data in node_data.get("messages", []): + messages.append(Message(text=msg_data.get("text", ""))) + + node = Node( + state=node_data.get("state"), + title=node_data.get("title", ""), + messages=messages, + edges=edges, + options=node_data.get("options", []), + ) + nodes.append(node) + + entries = data.get("script", {}).get("entries", []) + + script = Script(nodes=nodes, entries=entries) + + bot = Bot( + id=data.get("id"), + token=data.get("token"), + enabled=data.get("enabled", True), + status=data.get("status", "idle"), + script=script, + ) + + return bot + + +def import_bot(json_path: str) -> None: + json_file = Path(json_path) + if not json_file.exists(): + raise FileNotFoundError(f"JSON файл не найден: {json_path}") + + bot = load_bot_from_json(json_path) + settings = load_settings() + client = ItsRegClient(settings) + + try: + print(f"Загрузка бота из {json_path}...") + print(f"ID: {bot.id}") + print(f"Token: {bot.token[:20]}...") + print(f"Узлов: {len(bot.script.nodes)}") + print(f"Entries: {len(bot.script.entries)}\n") + + created_bot = client.create_bot(bot) + print(f"✓ Бот '{created_bot.id}' успешно создан!") + + except Exception as e: + print(f"✗ Ошибка при создании бота: {e}", file=sys.stderr) + sys.exit(1) + + finally: + client.close() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Использование: python -m itsreg_cli.json_importer <путь_к_json>") + print("Пример: python -m itsreg_cli.json_importer bot_schema.json") + sys.exit(1) + + import_bot(sys.argv[1]) diff --git a/itsreg_cli/services/bot_service.py b/itsreg_cli/services/bot_service.py index b7ad2a2..f23b317 100644 --- a/itsreg_cli/services/bot_service.py +++ b/itsreg_cli/services/bot_service.py @@ -31,9 +31,10 @@ def create_bot_interactive(self) -> Bot: raise ValueError("Токен обязателен") nodes_by_state: dict[int, Node] = {} - self._ask_node_full(nodes_by_state, prefilled_state=1) - while questionary.confirm("Добавить ещё узел?", default=False).ask(): - self._ask_node_full(nodes_by_state) + start_state = self._input_state_number( + nodes_by_state, default_state=1, prompt_title="Номер стартового состояния" + ) + self._build_node_recursive(nodes_by_state, start_state) if not nodes_by_state: raise ValueError("Нужно создать хотя бы один узел") @@ -46,62 +47,77 @@ def create_bot_interactive(self) -> Bot: if not key: break start_str = questionary.text( - "Стартовое состояние:", default=str(min(nodes_by_state.keys())) - ).ask() or str(min(nodes_by_state.keys())) + "Стартовое состояние:", default=str(start_state) + ).ask() or str(start_state) try: start = int(start_str) except ValueError: - start = min(nodes_by_state.keys()) + start = start_state if start == 0 or start not in nodes_by_state: raise ValueError("Указано несуществующее состояние для entry") entries.append({"key": key, "start": start}) if not questionary.confirm("Добавить ещё entry?", default=False).ask(): break if not entries: - entries.append({"key": "start", "start": min(nodes_by_state.keys())}) + entries.append({"key": "start", "start": start_state}) nodes = list(nodes_by_state.values()) script = Script(nodes=nodes, entries=entries) bot = Bot(id=bot_id, token=token, enabled=True, status="idle", script=script) return self.client.create_bot(bot) - def _ask_node_full( - self, nodes_by_state: dict[int, Node], prefilled_state: int | None = None + def _input_state_number( + self, nodes_by_state: dict[int, Node], default_state: int, prompt_title: str + ) -> int: + while True: + state_str = questionary.text( + prompt_title + ":", default=str(default_state) + ).ask() or str(default_state) + try: + state = int(state_str) + except ValueError: + continue + if state == 0: + state = 1 + if state in nodes_by_state: + continue + return state + + def _build_node_recursive( + self, nodes_by_state: dict[int, Node], state: int ) -> Node: - default_state = ( - prefilled_state - if prefilled_state is not None - else (max(nodes_by_state.keys()) + 1 if nodes_by_state else 1) - ) - state_str = questionary.text( - "Номер состояния узла:", default=str(default_state) - ).ask() or str(default_state) - try: - state = int(state_str) - except ValueError: - state = default_state - if state == 0: - state = 1 + if state in nodes_by_state: + return nodes_by_state[state] title = ( - questionary.text(f"Название узла #{state}:", default=f"state-{state}").ask() + questionary.text(f"Узел {state}: Название:", default=f"state-{state}").ask() or f"state-{state}" ) messages: List[Message] = [] while True: - message_text = questionary.text("Текст сообщения узла:").ask() + message_text = questionary.text(f"Узел {state}: Текст сообщения:").ask() if message_text: messages.append(Message(text=message_text)) - if not questionary.confirm("Добавить ещё сообщение?", default=False).ask(): + if not questionary.confirm( + f"Узел {state}: Добавить ещё сообщение?", default=False + ).ask(): break if not messages: messages.append(Message(text="")) + options: List[str] = [] + while questionary.confirm( + f"Узел {state}: Добавить кнопку-опцию?", default=False + ).ask(): + option_text = questionary.text(f"Узел {state}: Текст опции:").ask() + if option_text: + options.append(option_text) edges: List[Edge] = [] + edge_index = 1 while questionary.confirm( - "Добавить переход из этого узла?", default=False + f"Узел {state}: Добавить исходящее ребро?", default=False ).ask(): predicate_type = ( questionary.select( - "Тип условия (predicate):", + f"Узел {state}, Ребро #{edge_index}: Тип условия:", choices=[ questionary.Choice(title="Всегда", value="always"), questionary.Choice(title="Точный текст", value="exact"), @@ -115,18 +131,25 @@ def _ask_node_full( predicate = {"type": "always"} elif predicate_type == "regex": pattern = ( - questionary.text("Введите regex-паттерн:", default=".*").ask() + questionary.text( + f"Узел {state}, Ребро #{edge_index}: Regex-паттерн:", + default=".*", + ).ask() or ".*" ) predicate = {"type": "regex", "pattern": pattern} else: text_value = ( - questionary.text("Ожидаемый текст:", default="Далее").ask() + questionary.text( + f"Узел {state}, Ребро #{edge_index}: Ожидаемый текст:", + default="Далее", + ).ask() or "Далее" ) predicate = {"type": "exact", "text": text_value} to_state_str = questionary.text( - "Целевое состояние (число):", default=str(state + 1) + f"Узел {state}, Ребро #{edge_index}: Целевое состояние:", + default=str(state + 1), ).ask() or str(state + 1) try: to_state = int(to_state_str) @@ -136,23 +159,16 @@ def _ask_node_full( to_state = 1 operation = ( questionary.select( - "Операция:", choices=["noop", "save", "append"], default="noop" + f"Узел {state}, Ребро #{edge_index}: Операция:", + choices=["noop", "save", "append"], + default="noop", ).ask() or "noop" ) - if to_state not in nodes_by_state: - if questionary.confirm( - f"Создать узел состояния {to_state} сейчас?", default=True - ).ask(): - self._ask_node_full(nodes_by_state, prefilled_state=to_state) edges.append(Edge(predicate=predicate, to=to_state, operation=operation)) - options: List[str] = [] - while questionary.confirm( - "Добавить вариант ответа (option)?", default=False - ).ask(): - option_text = questionary.text("Текст опции:").ask() - if option_text: - options.append(option_text) + if to_state not in nodes_by_state: + self._build_node_recursive(nodes_by_state, to_state) + edge_index += 1 node = Node( state=state, title=title, messages=messages, edges=edges, options=options ) @@ -164,3 +180,6 @@ def enable_bot(self, bot_id: str) -> None: def disable_bot(self, bot_id: str) -> None: self.client.disable_bot(bot_id) + + def get_bot_answers(self, bot_id: str) -> str: + return self.client.get_bot_answers(bot_id) diff --git a/itsreg_cli/tui/menus.py b/itsreg_cli/tui/menus.py index d37ac2b..1e37521 100644 --- a/itsreg_cli/tui/menus.py +++ b/itsreg_cli/tui/menus.py @@ -1,10 +1,14 @@ from __future__ import annotations +import csv +import io import json +from datetime import datetime from typing import List import questionary from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table from itsreg_cli.domain.models import Bot @@ -57,18 +61,27 @@ def show_bot_details(service: BotService, bot_id: str) -> None: bot = service.get_bot(bot_id) console.print(f"Детали бота {bot.id}", style="cyan") console.print(f"Статус: {bot.status}, Включен: {bot.enabled}") - console.print("Сценарий:") - console.print_json(json.dumps(bot.script.model_dump(), ensure_ascii=False)) + nodes_count = len(bot.script.nodes) if bot.script and bot.script.nodes else 0 + entries_count = ( + len(bot.script.entries) if bot.script and bot.script.entries else 0 + ) + console.print(f"Узлов в сценарии: {nodes_count}, Точек входа: {entries_count}") action = questionary.select( "Действие:", choices=[ + "Показать сценарий", "Включить автозапуск", "Выключить автозапуск", + "Экспорт ответов", "Удалить бота", "Назад", ], ).ask() - if action == "Включить автозапуск": + if action == "Показать сценарий": + console.print("Сценарий:", style="cyan") + console.print_json(json.dumps(bot.script.model_dump(), ensure_ascii=False)) + input("\nНажмите Enter для продолжения...") + elif action == "Включить автозапуск": try: service.enable_bot(bot_id) console.print("Включено в автозапуск.", style="green") @@ -80,6 +93,8 @@ def show_bot_details(service: BotService, bot_id: str) -> None: console.print("Удалён из автозапуска.", style="yellow") except Exception as e: console.print(f"Ошибка: {e}", style="red") + elif action == "Экспорт ответов": + export_bot_answers(service, bot_id) elif action == "Удалить бота": if questionary.confirm(f"Удалить бота '{bot_id}'?", default=False).ask(): try: @@ -92,6 +107,65 @@ def show_bot_details(service: BotService, bot_id: str) -> None: break +def export_bot_answers(service: BotService, bot_id: str) -> None: + try: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task( + "Загрузка ответов с сервера (может занять несколько минут)...", + total=None, + ) + csv_data = service.get_bot_answers(bot_id) + progress.update(task, completed=True) + + if not csv_data.strip(): + console.print("Нет ответов для экспорта.", style="yellow") + return + + reader = csv.reader(io.StringIO(csv_data)) + rows = list(reader) + + if not rows: + console.print("Нет ответов для экспорта.", style="yellow") + return + + table = Table(title=f"Ответы на бота {bot_id}") + headers = rows[0] if rows else [] + for header in headers: + table.add_column(header, overflow="fold") + + for row in rows[1:]: + table.add_row(*row) + + console.print(table) + + if questionary.confirm("Сохранить в CSV файл?", default=True).ask(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{bot_id}_answers_{timestamp}.csv" + with open(filename, "w", encoding="utf-8") as f: + f.write(csv_data) + console.print(f"Сохранено в {filename}", style="green") + + except Exception as e: + error_msg = str(e) + if "504" in error_msg or "Gateway Time-out" in error_msg: + console.print( + "Ошибка: Сервер не успел обработать запрос (504 Gateway Timeout).", + style="red", + ) + console.print("Это серверная проблема. Возможные решения:", style="yellow") + console.print("1. Попробуйте позже, когда на сервере меньше нагрузки") + console.print( + "2. Обратитесь к администраторам для увеличения timeout на сервере" + ) + console.print("3. Используйте веб-интерфейс для экспорта") + else: + console.print(f"Ошибка при экспорте: {e}", style="red") + + def _render_bots_table(bots: List[Bot]) -> None: table = Table(title="Мои боты") table.add_column("ID") diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index ca4ec96..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements.txt -openapi-python-client>=0.19,<0.20 -pre-commit>=3.6,<3.7 -ruff>=0.3,<0.4 -black>=24.4,<25.0 -mypy>=1.10,<1.11 -typer>=0.9,<0.10 -click>=8.1,<8.2 From 14dfd696aa5c9d54a36fd22f97a7d85ed563c66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=20=D0=9C=D0=B0=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2?= Date: Thu, 22 Jan 2026 23:56:07 +0300 Subject: [PATCH 4/4] update tui, add function to generate bot by json file --- .gitignore | 2 +- README.md | 96 ++++++++++------------------------- bot_schema.json | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 71 deletions(-) create mode 100644 bot_schema.json diff --git a/.gitignore b/.gitignore index 63bf7df..00eadcd 100644 --- a/.gitignore +++ b/.gitignore @@ -174,7 +174,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. diff --git a/README.md b/README.md index 7eab539..d0b3dbd 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ TUI клиент для управления Telegram-ботами ITS Reg. ## Установка ### Требования + - Python 3.10+ - pip - git ### Шаги установки + ```bash git clone https://github.com/bmstu-itstech/itsreg-cli cd itsreg-cli @@ -21,6 +23,7 @@ make install ## Запуск ### Использование переменных окружения + ```bash export ITSREG_JWT_TOKEN=your_jwt_token_here export ITSREG_API_URL=https://itsreg.itsbmstu.ru/api/v2/ @@ -28,18 +31,22 @@ make run ``` ### Использование .env файла + Создайте файл `.env` в корне проекта: + ```env ITSREG_API_URL=https://itsreg.itsbmstu.ru/api/v2/ ITSREG_JWT_TOKEN=your_jwt_token_here ``` Затем запустите: + ```bash make run ``` ### Использование аргументов командной строки + ```bash python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_jwt_token ``` @@ -50,6 +57,7 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j - `--jwt-token TOKEN` - JWT токен для аутентификации (по умолчанию: из переменной окружения `ITSREG_JWT_TOKEN`) ### Приоритет настроек + 1. Аргументы командной строки (`--api`, `--jwt-token`) 2. Переменные окружения (`ITSREG_API_URL`, `ITSREG_JWT_TOKEN`) 3. Файл `.env` @@ -59,9 +67,11 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j ## Функционал ### Главное меню + При запуске приложения открывается главное меню с опциями: + - **Создать бота** - интерактивное создание нового бота -- **Мои боты** - просмотр списка вашихботов +- **Мои боты** - просмотр списка ваших ботов - **Выход** - завершение работы ### Создание бота @@ -69,10 +79,11 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j Интерактивный режим создания бота позволяет: 1. **Основная информация** + - Указать идентификатор бота (ID) - Ввести Telegram токен от BotFather - 2. **Создание узлов (nodes)** + - Указать номер состояния (state) - любое положительное число - Задать название узла (title) - Добавить одно или несколько сообщений для пользователя @@ -87,12 +98,12 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j - `save` - сохранить или перезаписать ответ пользователя - `append` - добавить ответ к предыдущему - Добавить опции (кнопки) для пользователя - 3. **Создание связанных узлов** + - При указании перехода к несуществующему узлу, система предложит создать его немедленно - Поддержка произвольных номеров состояний (1, 2, 104, 201 и т.д.) - 4. **Точки входа (entries)** + - Задать ключи для входа в сценарий (например, `start`, `m_ok`, `m_progress`) - Указать начальное состояние для каждой точки входа - Команды доступны пользователям как `/start`, `/m_ok` и т.д. @@ -100,6 +111,7 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j ### Просмотр ботов В разделе "Мои боты": + - Отображается таблица со всеми вашими ботами - Показывается ID, статус, включен ли автозапуск, количество узлов - Выбор бота для просмотра деталей @@ -109,18 +121,19 @@ python -m itsreg_cli --api https://itsreg.itsbmstu.ru/api/v2/ --jwt-token your_j После выбора бота доступны действия: 1. **Включить автозапуск** + - Добавляет бота в автозапуск - Бот будет автоматически запускаться при перезагрузке сервера - 2. **Выключить автозапуск** + - Удаляет бота из автозапуска - Бот останется доступен, но не будет автоматически запускаться - 3. **Удалить бота** + - Полное удаление бота со всеми данными - Требует подтверждения - 4. **Просмотр деталей** + - Статус бота (running/idle/dead) - Полный JSON сценария со всеми узлами и переходами @@ -153,82 +166,25 @@ itsreg-cli/ ## Разработка ### Установка зависимостей для разработки + ```bash -make install-dev +make install ``` ### Генерация клиента API + При обновлении OpenAPI спецификации: + ```bash make generate-client ``` Используется `openapi-python-client` и спецификация из `api/openapi/bots.yaml`. -### Pre-commit хуки -```bash -pip install pre-commit -pre-commit install -pre-commit run --all-files -``` - -Автоматически запускаются проверки: -- trailing whitespace -- end of file fixer -- YAML валидация -- black (форматирование) -- isort (сортировка импортов) -- flake8 (линтинг) - -### Запуск тестов -```bash -make test -``` - -## Примеры использования - -### Создание простого бота -```bash -make run -# Выберите "Создать бота" -# ID: my_bot -# Token: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz -# Entry key: start -# Узел #1: "Приветствие" -# Сообщение: "Добро пожаловать!" -# Добавить переход: Да -# Тип: Точный текст -# Текст: "Далее" -# Состояние: 2 -# Операция: noop -``` - -### Создание сложного сценария с множеством узлов -См. пример в API: узлы с состояниями 1-9, 104, 201, 202 для регистрации на мероприятие с проверкой группы, переводом средств и подтверждениями. - -## Получение JWT токена - -1. Войдите на https://itsreg.itsbmstu.ru -2. Откройте DevTools (F12) → Network -3. Найдите любой запрос к API -4. Скопируйте значение заголовка `Authorization` - -## Troubleshooting - -### Ошибка "ITSREG_JWT_TOKEN не задан" -Убедитесь, что токен указан через: -- Переменную окружения `ITSREG_JWT_TOKEN` -- Файл `.env` -- Аргумент `--jwt-token` - -### Ошибка "API error 400: node-not-found" -При создании ботов убедитесь, что: -- Все указанные в переходах состояния существуют -- Не используется зарезервированное состояние 0 -- В entries указаны существующие состояния - ### Ошибка подключения к API + Проверьте: + - Доступность API по адресу из `ITSREG_API_URL` - Корректность JWT токена - Наличие интернет-соединения diff --git a/bot_schema.json b/bot_schema.json new file mode 100644 index 0000000..5895ea6 --- /dev/null +++ b/bot_schema.json @@ -0,0 +1,129 @@ +{ + "id": "", + "token": "", + "script": { + "nodes": [ + { + "state": 6, + "title": "state-6", + "messages": [ + { + "text": "конец" + } + ], + "edges": [], + "options": [] + }, + { + "state": 5, + "title": "точно", + "messages": [ + { + "text": "точно" + } + ], + "edges": [ + { + "predicate": { + "type": "always" + }, + "to": 6, + "operation": "append" + } + ], + "options": [ + "да", + "нет", + "возможно" + ] + }, + { + "state": 4, + "title": "хотите получать уведомление о дне рождения", + "messages": [ + { + "text": "хотите получать уведомление о дне рождения" + } + ], + "edges": [ + { + "predicate": { + "type": "always" + }, + "to": 5, + "operation": "save" + } + ], + "options": [] + }, + { + "state": 3, + "title": "введите группу", + "messages": [ + { + "text": "введите группу" + }, + { + "text": "ну же" + } + ], + "edges": [ + { + "predicate": { + "type": "regex", + "pattern": "[А-Я]+-[1-9][0-9]." + }, + "to": 4, + "operation": "save" + } + ], + "options": [] + }, + { + "state": 2, + "title": "введите имя", + "messages": [ + { + "text": "введите имя" + } + ], + "edges": [ + { + "predicate": { + "type": "always" + }, + "to": 3, + "operation": "save" + } + ], + "options": [] + }, + { + "state": 1, + "title": "введите свой возраст", + "messages": [ + { + "text": "введите свой возраст" + } + ], + "edges": [ + { + "predicate": { + "type": "regex", + "pattern": "[0-9]+" + }, + "to": 2, + "operation": "save" + } + ], + "options": [] + } + ], + "entries": [ + { + "key": "start", + "start": 1 + } + ] + } +}