diff --git a/.gitignore b/.gitignore index b7faf40..00eadcd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[codz] *$py.class +.DS_Store # C extensions *.so @@ -173,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. @@ -182,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..d0b3dbd 100644 --- a/README.md +++ b/README.md @@ -1 +1,190 @@ -# itsreg-cli \ No newline at end of file +# itsreg-cli + +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_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/ +├── 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 # Этот файл +``` + +## Разработка + +### Установка зависимостей для разработки + +```bash +make install +``` + +### Генерация клиента API + +При обновлении OpenAPI спецификации: + +```bash +make generate-client +``` + +Используется `openapi-python-client` и спецификация из `api/openapi/bots.yaml`. + +### Ошибка подключения к API + +Проверьте: + +- Доступность API по адресу из `ITSREG_API_URL` +- Корректность JWT токена +- Наличие интернет-соединения 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/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 + } + ] + } +} 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..b2d3ea2 --- /dev/null +++ b/itsreg_cli/api/client.py @@ -0,0 +1,113 @@ +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 + 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": []} + ), + } + 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: + 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 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() + + 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 "" + 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 + + 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/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/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..f23b317 --- /dev/null +++ b/itsreg_cli/services/bot_service.py @@ -0,0 +1,185 @@ +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] = {} + 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("Нужно создать хотя бы один узел") + + 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(start_state) + ).ask() or str(start_state) + try: + start = int(start_str) + except ValueError: + 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": 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 _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: + if state in nodes_by_state: + return nodes_by_state[state] + title = ( + questionary.text(f"Узел {state}: Название:", default=f"state-{state}").ask() + or f"state-{state}" + ) + messages: List[Message] = [] + while True: + message_text = questionary.text(f"Узел {state}: Текст сообщения:").ask() + if message_text: + messages.append(Message(text=message_text)) + 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( + f"Узел {state}: Добавить исходящее ребро?", default=False + ).ask(): + predicate_type = ( + questionary.select( + f"Узел {state}, Ребро #{edge_index}: Тип условия:", + 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( + f"Узел {state}, Ребро #{edge_index}: Regex-паттерн:", + default=".*", + ).ask() + or ".*" + ) + predicate = {"type": "regex", "pattern": pattern} + else: + text_value = ( + questionary.text( + f"Узел {state}, Ребро #{edge_index}: Ожидаемый текст:", + default="Далее", + ).ask() + or "Далее" + ) + predicate = {"type": "exact", "text": text_value} + to_state_str = questionary.text( + f"Узел {state}, Ребро #{edge_index}: Целевое состояние:", + 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( + f"Узел {state}, Ребро #{edge_index}: Операция:", + choices=["noop", "save", "append"], + default="noop", + ).ask() + or "noop" + ) + edges.append(Edge(predicate=predicate, to=to_state, operation=operation)) + 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 + ) + 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) + + def get_bot_answers(self, bot_id: str) -> str: + return self.client.get_bot_answers(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..1e37521 --- /dev/null +++ b/itsreg_cli/tui/menus.py @@ -0,0 +1,182 @@ +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 +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}") + 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 == "Показать сценарий": + 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") + 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 == "Экспорт ответов": + export_bot_answers(service, bot_id) + 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 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") + 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.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