From c5ec32f87add49e57ea52b7f5d68fc0b9c50b9ac2d35afe0e79dca480a9ebb00 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 6 Jun 2026 13:45:02 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20Telegram-=D0=B1=D0=BE=D1=82=D0=B0,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DAO_запуск/README.md | 255 ++++++++++++++++++ Dev_Docs/Future_Features/README.md | 4 + ...26-06-05_esp32_hardware_wallet_device_session.md | 64 +++++ ...6-05_1240_esp32_argon2_ui_совместимость.md | 25 ++ ...1735_редактируемый_статус_telegram_бота.md | 17 ++ ...026-06-06_1324_settings_telegram_агента.md | 24 ++ .../argon2_sd_test/argon2_sd_test.ino | 145 +++++++++- README.md | 8 +- SHiNE-agent-bot-coder/AGENT.md | 4 +- SHiNE-agent-bot-coder/README.md | 11 + SHiNE-agent-bot-coder/py_bot_service.py | 232 +++++++++++++--- .../java/server/ws/BlockchainWsEndpoint.java | 13 +- VERSION.properties | 4 +- shine-UI/js/pages/login-password-view.js | 8 +- .../js/pages/registration-draft-keys-view.js | 7 +- shine-UI/js/services/auth-service.js | 7 +- 16 files changed, 772 insertions(+), 56 deletions(-) create mode 100644 DAO_запуск/README.md create mode 100644 Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md create mode 100644 Dev_Docs/Pending_Features/2026-06-05_1240_esp32_argon2_ui_совместимость.md create mode 100644 Dev_Docs/Pending_Features/2026-06-05_1735_редактируемый_статус_telegram_бота.md create mode 100644 Dev_Docs/Pending_Features/2026-06-06_1324_settings_telegram_агента.md diff --git a/DAO_запуск/README.md b/DAO_запуск/README.md new file mode 100644 index 0000000..c739f28 --- /dev/null +++ b/DAO_запуск/README.md @@ -0,0 +1,255 @@ +# DAO_запуск + +Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE. + +Логика документа: + +- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде; +- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата. + +Базовая среда первого прохода: + +- сеть `Solana devnet`; +- модель синхронизации: `server-to-server`; +- `Solana + Arweave` используются как якорь и архив; +- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги. + +## Краткий вывод + +Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как: + +- можно развернуть и проверить базовый DAO-контур; +- можно зарегистрировать пользователей и ключевые сущности; +- можно провести тестовую покупку билета через smart contract; +- можно завести тестовый денежный поток в программы, управляемые DAO; +- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой. + +Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять. + +## Этап1 + +Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры. + +### 1. Переписать и стабилизировать регистрацию пользователей без Anchor + +Что сделать: + +- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния; +- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI; +- проверить создание и чтение `user_pda`; +- проверить update пользовательской записи и связанные экономические параметры; +- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами. + +Почему это в `этап1`: + +- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии. + +### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции + +Что сделать: + +- руками прогнать регистрацию нового пользователя; +- руками прогнать создание и update server PDA там, где это требуется текущему сценарию; +- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов; +- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет. + +Почему это в `этап1`: + +- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым. + +### 3. Создать стандартный DAO smart contract / governance-контур + +Что сделать: + +- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE; +- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах; +- подготовить тестовую DAO-структуру в `devnet`. + +Минимум для первого запуска: + +- DAO существует как управляемая сущность; +- DAO может владеть или контролировать ключевые права управления денежными программами; +- есть понятный путь, как DAO влияет на доходные программы SHiNE. + +Почему это в `этап1`: + +- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы. + +### 4. Доработать смарт-контракт выплат с третьей очередью + +Что сделать: + +- добавить в `shine_payments` третью очередь, о которой уже принято решение; +- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления; +- убедиться, что логика очередей соответствует ожидаемой экономике проекта. + +Почему это в `этап1`: + +- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики. + +### 5. Сделать UI для покупки билетов и просмотра очереди + +Что сделать: + +- добавить UI-сценарий покупки билетов через smart contract; +- показать пользователю, сколько перед ним человек в очереди; +- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения. + +Почему это в `этап1`: + +- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция; +- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки. + +### 6. Реализовать базовую синхронизацию серверов + +Что сделать: + +- сделать обмен состоянием между серверами по модели `server-to-server`; +- определить минимальный набор данных, который обязан синхронизироваться; +- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой; +- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт. + +Почему это в `этап1`: + +- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую; +- это напрямую связано с идеей, что любой сможет поднять свой сервер. + +### 7. Подготовить базовый сценарий архивирования и восстановления + +Что сделать: + +- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`; +- определить минимальный сценарий восстановления блоков или состояния из архивного слоя; +- подтвердить, что новый сервер может получить достаточно данных для старта. + +Почему это в `этап1`: + +- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры. + +## Этап2 + +Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`. + +### 1. Смена ключей цифровой подписи + +Что сделать: + +- продумать и реализовать смену `root key`, `device key`, `blockchain key`; +- описать ограничения, кто и в каком сценарии может менять каждый тип ключа; +- продумать, как не потерять доступ и как обновлять доверие к новым ключам. + +Почему это в `этап2`: + +- для production это очень важно; +- для первого тестового запуска можно временно использовать фиксированный набор ключей. + +### 2. Полная повторная перепроверка всех сценариев + +Что сделать: + +- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`; +- оформить итоговый чек-лист ручной проверки; +- отдельно проверить пограничные сценарии и восстановление после ошибок. + +Почему это в `этап2`: + +- это обязательный шаг перед переходом от "собрали" к "доверяем". + +### 3. Устройство на ESP32 как сабсервер с ключами + +Что сделать: + +- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами; +- дать ему возможность регистрироваться и подключаться к серверу; +- определить, какие операции устройство подписывает и где хранит ключевой материал. + +Почему это в `этап2`: + +- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск. + +### 4. Логин и подпись через коробочки / устройства + +Что сделать: + +- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство; +- определить, как это встраивается в регистрацию DAO и подтверждение действий; +- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды. + +Почему это в `этап2`: + +- это следующий уровень безопасности и UX, но не минимальный блокер первого старта. + +### 5. Создание тестового DAO с использованием устройств подписи + +Что сделать: + +- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием; +- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ. + +Почему это в `этап2`: + +- это проверка усиленной модели, а не базового старта. + +### 6. Расписание синхронизации серверов + +Что сделать: + +- определить периодичность и правила фоновой синхронизации; +- продумать ручной и автоматический режим; +- решить, как часто публиковать архивные снимки и якоря. + +Почему это в `этап2`: + +- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной. + +### 7. Полное восстановление блоков из Solana/Arweave + +Что сделать: + +- довести процедуру восстановления до сценария "любой может поднять свой сервер"; +- определить минимальный bootstrap-набор; +- проверить восстановление на чистом окружении. + +Почему это в `этап2`: + +- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации. + +## Что блокирует первый запуск сильнее всего + +Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так: + +1. pure Rust регистрация пользователей и ручная проверка сценария; +2. DAO/gov-контур и его права управления; +3. доработка выплат с третьей очередью; +4. покупка билетов через smart contract и UI-проверка очереди; +5. межсерверная синхронизация; +6. архивирование в `Arweave` с якорем в `Solana`; +7. минимальное восстановление состояния новым сервером. + +## Что уже частично похоже на готовое + +По текущим документам и следам в проекте уже видно, что: + +- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан; +- архитектура DAO, `shine_users` и `shine_payments` уже описана; +- часть Solana-структуры и PDA-форматов уже формализована; +- тема ESP32 уже отдельно присутствует в проекте как направление. + +Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах. + +## Вопросы, которые всё ещё стоит уточнить + +1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков? +2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики? +3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов? +4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии? +5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов? + +## Рекомендуемый следующий практический шаг + +Если идти без распыления, то следующим рабочим фокусом стоит считать: + +1. закрыть ручную проверку pure Rust регистрации; +2. после этого формализовать минимальный DAO-контур; +3. затем переходить к третьей очереди выплат и к UI покупки билетов; +4. после этого делать синхронизацию, архив и восстановление. diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index 2b80f0d..e81b0e3 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -39,6 +39,10 @@ - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. - `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи. +### DAO-запуск + +- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона. + ### Дальнее будущее - Сейчас задач нет. diff --git a/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md b/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md new file mode 100644 index 0000000..b86d377 --- /dev/null +++ b/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md @@ -0,0 +1,64 @@ +# ESP32 как аппаратный кошелёк (device-сессия) + +## Суть фичи + +ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения. + +## Два ключевых сценария + +### Сценарий 1 — Создание делегированной сессии +1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X» +2. Сервер → ESP32 (device-сессия): «запрос на одобрение» +3. Пользователь нажимает «Да» на сенсорном экране ESP32 +4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера + +### Сценарий 2 — Подпись транзакции / блока +1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это» +2. ESP32 показывает запрос на экране, пользователь подтверждает +3. ESP32 подписывает нужным ключом → ответ через сервер → браузер + +## Что нужно сделать + +### ESP32 (основная работа) +- [ ] Инициализация WiFi (SSID/пароль в NVS) +- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером +- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS +- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу +- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет +- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх) +- [ ] Переподключение при разрыве (reconnect loop) + +### Сервер (минимальные изменения) +- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions` +- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии +- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено) +- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ +- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда + +### Клиент (отдельный этап) +- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest` + +## Что уже готово (переиспользуем) + +- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него. +- **Ed25519 на ESP32** — библиотека `` уже используется в скетче. Подписи работают. +- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко. +- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере. +- **Аутентификация** — схема `AuthChallenge` → `CreateAuthSession` через Ed25519 уже полностью реализована. + +## Оценка сложности + +| Компонент | Сложность | +|---|---| +| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя | +| ESP32: обработчик входящих + UI подтверждений | Средняя | +| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя | +| Браузерное расширение | Высокая (отдельный этап) | + +**Итого фазы ESP32 + сервер: ~1–1.5 недели.** + +## С чего начинать + +1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`. +2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI. +3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают. diff --git a/Dev_Docs/Pending_Features/2026-06-05_1240_esp32_argon2_ui_совместимость.md b/Dev_Docs/Pending_Features/2026-06-05_1240_esp32_argon2_ui_совместимость.md new file mode 100644 index 0000000..fb9357d --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-05_1240_esp32_argon2_ui_совместимость.md @@ -0,0 +1,25 @@ +# ESP32 Argon2/UI совместимость и экран результата + +- краткое описание фичи: + выравнивание derivation на `ESP32` с текущим `UI` по нормализации логина, совместимости `master secret`/`root.key`/`bch.key`/`dev.key`, а также правки экрана результата и progress bar. + +- что именно проверять: + 1. На `UI` и `ESP32` ввести один и тот же логин в разном регистре, например `Anya24`, и один и тот же непустой пароль. + 2. Убедиться, что после нормализации логина на `ESP32` и `UI` получаются одинаковые: + `master secret`, `root`, `blockchain`, `device` в `Base58`. + 3. Проверить режим пустого пароля: + `UI` и `ESP32` должны выдать одинаковые ключи в legacy-режиме. + 4. Проверить, что пустой логин на `ESP32` не запускает расчёт и показывает сообщение об ошибке. + 5. Проверить progress bar: + при непустом пароле полоса должна быть видна и двигаться. + 6. Проверить экран результата: + сначала `Login`, затем `Password`, затем `Master secret` и ключи; + свайп вверх/вниз должен прокручивать длинный результат без артефактов. + +- ожидаемый результат: + `ESP32` и `UI` считают одинаковый `master secret` и одинаковые ключи для одинаковых входных данных; + progress bar виден; + экран результата читаемый и корректно прокручивается. + +- статус: + pending diff --git a/Dev_Docs/Pending_Features/2026-06-05_1735_редактируемый_статус_telegram_бота.md b/Dev_Docs/Pending_Features/2026-06-05_1735_редактируемый_статус_telegram_бота.md new file mode 100644 index 0000000..54c18d2 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-05_1735_редактируемый_статус_telegram_бота.md @@ -0,0 +1,17 @@ +## Краткое описание +В `SHiNE-agent-bot-coder` для личных чатов добавлен режим одного редактируемого статусного сообщения. Бот принимает запрос, обновляет это сообщение по этапам обработки и в конце превращает его в финальный текстовый ответ. При длинном ответе допускается ещё одно дополнительное текстовое сообщение с продолжением. Голосовой ответ остаётся отдельным сообщением. + +## Что проверять +1. Отправить в личный чат короткий текстовый запрос и убедиться, что бот не шлёт цепочку промежуточных сообщений, а редактирует одно сообщение до финального ответа. +2. Отправить в личный чат `voice` или `audio` и убедиться, что в том же сообщении последовательно видны этапы распознавания и выполнения. +3. Проверить длинный ответ, который не помещается в один Telegram message: должно получиться не больше двух текстовых сообщений. +4. Проверить, что `voice`-ответ приходит отдельным новым сообщением после текстового. +5. Проверить, что в `@shine_writing` по-прежнему логируются только итоговые `вопрос -> ответ`, без промежуточных статусов. + +## Ожидаемый результат +- В личке основная переписка стала чище: промежуточные этапы живут в одном редактируемом сообщении. +- При длинном ответе бот не разбрасывает ответ на много сообщений. +- Канал `@shine_writing` работает по старой схеме без лишнего шума. + +## Статус +`pending` diff --git a/Dev_Docs/Pending_Features/2026-06-06_1324_settings_telegram_агента.md b/Dev_Docs/Pending_Features/2026-06-06_1324_settings_telegram_агента.md new file mode 100644 index 0000000..ca2e0eb --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-06_1324_settings_telegram_агента.md @@ -0,0 +1,24 @@ +## Краткое описание +В локальный Telegram-бот `SHiNE-agent-bot-coder` добавлена команда `/settings`, которая сразу показывает текущие персональные настройки пользователя и список доступных команд для их изменения. В `/help` оставлена только ссылка на `/settings` без перечисления самих команд настроек. Также добавлен переключатель режима ответа в личке: один редактируемый статус или отдельные сообщения по этапам. + +## Что проверять +1. Отправить `/help` и убедиться, что в справке есть `/settings`, но нет списка команд `/voice_*` и `/single_message_*`. +2. Отправить `/settings` и проверить, что бот показывает текущие значения: + - озвучивание финальных ответов; + - адаптацию текста перед озвучкой; + - режим одного редактируемого сообщения в личке. +3. По очереди переключить: + - `/voice_on` и `/voice_off`; + - `/voice_rewrite_on` и `/voice_rewrite_off`; + - `/single_message_on` и `/single_message_off`. +4. После каждого переключения снова вызвать `/settings` и убедиться, что статус изменился и сохранился. +5. При `/single_message_on` отправить обычный запрос в личку и проверить, что бот ведёт его через одно редактируемое сообщение. +6. При `/single_message_off` отправить обычный запрос в личку и проверить, что бот снова шлёт отдельные сообщения по этапам и отдельный финальный ответ. + +## Ожидаемый результат +- `/settings` стал основной точкой входа для пользовательских настроек. +- `/help` стал короче и не дублирует список команд настроек. +- Режим ответа в личке реально переключается персонально для пользователя и сохраняется после перезапуска сервиса. + +## Статус +`pending` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino index 43f5ce5..cdeab1f 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/argon2_sd_test/argon2_sd_test.ino @@ -4,9 +4,12 @@ * Результат сохраняется в NVS (внутренняя flash ESP32). * * Алгоритм совпадает с JS crypto-utils.js: - * salt = SHA256("shine-auth-v2|login=|suffix=master.secret")[0:16] - * passBytes = UTF8("\n") + * loginNorm = trim(lowercase(login)) + * salt = SHA256("shine-auth-v2|login=|suffix=master.secret")[0:16] + * passBytes = UTF8("\n") * secret = Argon2id(passBytes, salt, t=2, m=65536 KB, p=1, dkLen=32) + * legacy(empty password): + * secret = SHA256(base64(SHA256(password)) + "master.secret") * keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i)) * suffixes = ["root.key", "bch.key", "dev.key"] * @@ -105,6 +108,7 @@ static char gLogin[64] = {}; static char gPass[64] = {}; static uint8_t gSecret[32]; static char gSecretHex[65]; +static char gInputStatus[96] = {}; static bool gKbNums = false; // ═══════════════════════════════════════════════════════════ @@ -356,6 +360,61 @@ static void sha256calc(const uint8_t *in,size_t len,uint8_t *out32){ mbedtls_sha256_free(&ctx); } +static void trimInPlace(char *text){ + if(!text)return; + size_t len=strlen(text); + size_t start=0; + while(startstart&&(text[end-1]==' '||text[end-1]=='\t'||text[end-1]=='\n'||text[end-1]=='\r'))end--; + if(start>0&&end>start)memmove(text,text+start,end-start); + if(end<=start){text[0]='\0';return;} + text[end-start]='\0'; +} + +static void lowercaseAsciiInPlace(char *text){ + if(!text)return; + for(int i=0;text[i];i++){ + if(text[i]>='A'&&text[i]<='Z')text[i]+=32; + } +} + +static void normalizeLoginInPlace(char *text){ + trimInPlace(text); + lowercaseAsciiInPlace(text); +} + +static void setInputStatus(const char *message){ + snprintf(gInputStatus,sizeof(gInputStatus),"%s",message?message:""); +} + +static void clearInputStatus(){ + gInputStatus[0]='\0'; +} + +static void bytesToBase64Std(const uint8_t *data,size_t len,char *out,size_t outSz){ + size_t b64len=0; + if(outSz==0)return; + if(mbedtls_base64_encode((uint8_t*)out,outSz,&b64len,data,len)!=0){ + out[0]='\0'; + return; + } + if(b64len>=outSz)b64len=outSz-1; + out[b64len]='\0'; +} + +static void deriveLegacyMasterSecret(const char *password,uint8_t *out32){ + uint8_t baseHash[32]; + sha256calc((const uint8_t*)password,strlen(password),baseHash); + + char baseB64[64]; + bytesToBase64Std(baseHash,32,baseB64,sizeof(baseB64)); + + char material[96]; + snprintf(material,sizeof(material),"%smaster.secret",baseB64); + sha256calc((const uint8_t*)material,strlen(material),out32); +} + // ═══════════════════════════════════════════════════════════ // BASE58 // ═══════════════════════════════════════════════════════════ @@ -473,6 +532,13 @@ static void argon2Init(const char *login,const char *password){ #undef B2LE32 b2_final(&gB2S,gH0); + // Debug: print inputs and H0 to Serial for cross-check with reference + Serial.printf("[DBG] login='%s' passLen=%u saltLen=%u\n",login,passLen,saltLen); + Serial.print("[DBG] salt: "); + for(int i=0;i<16;i++)Serial.printf("%02x",salt[i]);Serial.println(); + Serial.print("[DBG] H0: "); + for(int i=0;i<64;i++)Serial.printf("%02x",gH0[i]);Serial.println(); + uint8_t input[72];memcpy(input,gH0,64); for(uint32_t i=0;i<2;i++){ input[64]=i;input[65]=0;input[66]=0;input[67]=0; @@ -541,6 +607,13 @@ static void argon2Finalize(){ Serial.println("=== END ==="); } +static void completeResultFromSecret(){ + for(int i=0;i<32;i++)snprintf(gSecretHex+i*2,3,"%02x",gSecret[i]); + gSecretHex[64]='\0'; + deriveKeyPairs(); + nvsSave(); +} + // ═══════════════════════════════════════════════════════════ // UI — HELPERS // ═══════════════════════════════════════════════════════════ @@ -592,6 +665,9 @@ static void drawInputScreen(const char *title,const char *buf,bool isMasked){ else{strncpy(show,buf,67);show[67]='\0';} gfx->setTextSize(2);gfx->setTextColor(C_TEXT);gfx->setCursor(30,78);gfx->print(show); int cx=30+strlen(show)*12;gfx->drawFastVLine(cx,78,20,C_GREEN); + if(gInputStatus[0]){ + gfx->setTextSize(2);gfx->setTextColor(C_RED);gfx->setCursor(20,132);gfx->print(gInputStatus); + } drawKeyboard(); } @@ -628,6 +704,7 @@ static void drawProgressHeader(){ // нарисовать пустую полосу один раз int bx=20,by=90,bw=DISP_W-40,bh=36; uiRect(bx,by,bw,bh,C_BAR_BG,C_SEP,4); + gfx->fillRect(bx+1,by+1,bw-2,bh-2,C_BG); } static void drawProgress(){ @@ -639,7 +716,7 @@ static void drawProgress(){ int filled=(int)((uint64_t)bw*gDone/TOTAL_FILLS); if(gDone>0&&filled==0)filled=1; // at least 1px once started if(filled>gBarFilledPx){ - gfx->fillRect(bx+gBarFilledPx,by+1,filled-gBarFilledPx,bh-2,C_BAR_FG); + gfx->fillRect(bx+1+gBarFilledPx,by+1,filled-gBarFilledPx,bh-2,C_GREEN); gBarFilledPx=filled; } @@ -722,9 +799,11 @@ static void drawResultContent(){ char buf[96]; // — заголовок — - d.line("RESULT",C_GREEN,2); + d.line(" RESULT",C_GREEN,2); snprintf(buf,sizeof(buf),"Login: %s",gLogin); d.line(buf,C_TEXT,2); + snprintf(buf,sizeof(buf),"Password: %s",gPass[0]?gPass:"(empty)"); + d.line(buf,C_TEXT,2); d.gap(4); d.line("Master secret (base58):",C_HINT,2); d.longval(gSecretB58,C_GREEN); @@ -758,7 +837,6 @@ static void drawResultBtnBar(){ } static void drawResultFull(){ - gScrollY=0; drawResultContent(); drawResultBtnBar(); } @@ -790,27 +868,53 @@ static void handleTap(int tx,int ty){ char *buf=isPass?gPass:gLogin; char c=kbTouch(tx,ty); if(!c)return; - if(c=='\x03'){gKbNums=!gKbNums;drawInputScreen(isPass?"Password:":"Login:",buf,isPass);return;} - if(c=='\x08'){int n=strlen(buf);if(n>0)buf[n-1]='\0';} + if(c=='\x03'){gKbNums=!gKbNums;clearInputStatus();drawInputScreen(isPass?"Password:":"Login:",buf,isPass);return;} + if(c=='\x08'){int n=strlen(buf);if(n>0)buf[n-1]='\0';clearInputStatus();} else if(c=='\x0D'){ - if(!strlen(buf))return; if(gState==ST_LOGIN){ - for(int i=0;buf[i];i++)if(buf[i]>='A'&&buf[i]<='Z')buf[i]+=32; + normalizeLoginInPlace(buf); + if(!strlen(buf)){ + setInputStatus("Логин обязателен"); + drawInputScreen("Login:",gLogin,false); + return; + } + clearInputStatus(); gState=ST_PASS;drawInputScreen("Password:",gPass,true);return; } else { + clearInputStatus(); gState=ST_RUNNING; gfx->fillScreen(C_BG);drawProgressHeader(); SD_MMC.remove(SD_MEM_FILE); - gSdFile=SD_MMC.open(SD_MEM_FILE,FILE_WRITE); + gSdFile=SD_MMC.open(SD_MEM_FILE,"w+"); if(!gSdFile){ gfx->setTextSize(2);gfx->setTextColor(C_RED); gfx->setCursor(20,200);gfx->println("SD open failed"); gState=ST_ERROR;return; } + normalizeLoginInPlace(gLogin); + if(!gLogin[0]){ + gSdFile.close(); + SD_MMC.remove(SD_MEM_FILE); + gState=ST_LOGIN; + setInputStatus("Логин обязателен"); + drawInputScreen("Login:",gLogin,false); + return; + } + if(!gPass[0]){ + deriveLegacyMasterSecret(gPass,gSecret); + gElapsedSec=0; + completeResultFromSecret(); + gSdFile.close(); + SD_MMC.remove(SD_MEM_FILE); + gState=ST_DONE; + gScrollY=0; + drawResultFull(); + return; + } argon2Init(gLogin,gPass); drawProgress();return; } - } else {int n=strlen(buf);if(n<63){buf[n]=c;buf[n+1]='\0';}} + } else {int n=strlen(buf);if(n<63){buf[n]=c;buf[n+1]='\0';}clearInputStatus();} drawInputScreen(isPass?"Password:":"Login:",buf,isPass); return; } @@ -875,6 +979,25 @@ void setup(){ while(true)delay(1000); } + // BLAKE2b self-test: BLAKE2b-512("") = 786a02f7... + { + static const uint8_t EXP[64]={ + 0x78,0x6a,0x02,0xf7,0x42,0x01,0x59,0x03,0xc6,0xc6,0xfd,0x85,0x25,0x52,0xd2,0x72, + 0x91,0x2f,0x47,0x40,0xe1,0x58,0x47,0x61,0x8a,0x86,0xe2,0x17,0xf7,0x1f,0x54,0x19, + 0xd2,0x5e,0x10,0x31,0xaf,0xee,0x58,0x53,0x13,0x89,0x64,0x44,0x93,0x4e,0xb0,0x4b, + 0x90,0x3a,0x68,0x5b,0x14,0x48,0xb7,0x55,0xd5,0x6f,0x70,0x1a,0xfe,0x9b,0xe2,0xce + }; + uint8_t got[64]; + b2_init(&gB2S,64);b2_final(&gB2S,got); + bool ok=(memcmp(got,EXP,64)==0); + Serial.printf("BLAKE2b-512('') : %s\n",ok?"PASS":"FAIL"); + if(!ok){ + Serial.print("got: "); + for(int i=0;i<64;i++)Serial.printf("%02x",got[i]); + Serial.println(); + } + } + // PSRAM буферы gBufPrev=(uint8_t*)ps_malloc(A2_BLKSZ);gBufRef=(uint8_t*)ps_malloc(A2_BLKSZ); gBufOut=(uint8_t*)ps_malloc(A2_BLKSZ);gBufZero=(uint8_t*)ps_calloc(1,A2_BLKSZ); diff --git a/README.md b/README.md index dd729ff..a6dffaf 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# - \ No newline at end of file +# SHiNE + +## План запуска DAO + +План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md). + +Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности. diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index 3137095..a0236c1 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -34,8 +34,10 @@ - Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. - Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию. - Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа. +- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше. +- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения. - Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать. -- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой. +- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке. ## Правила голосовой версии ответа - Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index 6f32f8d..a6ab372 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -8,6 +8,7 @@ - обрабатывает задачи строго последовательно; - поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); - вызывает Codex CLI и отправляет ответ в Telegram; +- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам; - умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS; - при рестарте восстанавливает незавершённые задачи; - отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи; @@ -60,6 +61,13 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI. - Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`. +## Статусы в личке +- Для `private`-чата бот поддерживает персональную настройку режима ответа. +- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам. +- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно. +- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа. +- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением. + ## Запуск как systemd-сервис Файлы для установки: - `scripts/systemd/shine-agent-bot-coder.service` @@ -77,6 +85,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь ## Telegram-команды - `/status` — активная задача и размер очереди. +- `/settings` — текущие пользовательские настройки и команды для их изменения. - `/queue` — список задач в очереди. - `/stop` — остановить текущую задачу. - `/cancel ` — удалить задачу по id/префиксу или очистить очередь. @@ -85,5 +94,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. - `/voice_rewrite_on` — включить адаптацию текста перед озвучкой. - `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой. +- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение. +- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ. - `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара). - `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара). diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index e2bfb95..ddececc 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -77,6 +77,23 @@ def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] +def split_final_private_text(text: str, first_chunk_size: int = 3900, second_chunk_size: int = 3900) -> list[str]: + text = (text or "").strip() + if not text: + return ["(пустой ответ)"] + if len(text) <= first_chunk_size: + return [text] + first = text[:first_chunk_size].rstrip() + rest = text[first_chunk_size:].lstrip() + if len(rest) <= second_chunk_size: + return [first, rest] + second = rest[:second_chunk_size].rstrip() + if len(second) > 40: + second = second[:-40].rstrip() + second = second.rstrip() + "\n...[ответ обрезан]" + return [first, second] + + def split_text_for_tts(text: str, chunk_size: int) -> list[str]: text = (text or "").strip() if not text: @@ -266,6 +283,10 @@ class TelegramApi: payload["reply_to_message_id"] = reply_to_message_id return self.call("sendMessage", payload=payload, timeout=30) + def edit_message_text(self, chat_id: int | str, message_id: int, text: str) -> dict[str, Any]: + payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text} + return self.call("editMessageText", payload=payload, timeout=30) + def send_voice( self, chat_id: int | str, @@ -690,6 +711,8 @@ class ShinePyBotService: user_settings["voice_replies_enabled"] = True if not isinstance(user_settings.get("voice_rewrite_enabled"), bool): user_settings["voice_rewrite_enabled"] = True + if not isinstance(user_settings.get("single_status_message_enabled"), bool): + user_settings["single_status_message_enabled"] = True return user_settings def _voice_replies_enabled(self, username: str) -> bool: @@ -706,6 +729,13 @@ class ShinePyBotService: self._user_settings(username)["voice_rewrite_enabled"] = enabled self._persist_state() + def _single_status_message_enabled(self, username: str) -> bool: + return bool(self._user_settings(username).get("single_status_message_enabled")) + + def _set_single_status_message_enabled(self, username: str, enabled: bool) -> None: + self._user_settings(username)["single_status_message_enabled"] = enabled + self._persist_state() + def _remember_private_chat(self, username: str, chat_id: int) -> None: uname = normalize_username(username) if not uname: @@ -1088,7 +1118,15 @@ class ShinePyBotService: with self.queue_lock: self.queue.append(job) self._persist_queue() - self._safe_send(chat_id, f"Принял задачу #{job['num']}", reply_to=message_id) + if chat_type == "private": + self._ensure_job_status_message( + job["id"], + chat_id, + message_id, + f"Задача #{job['num']} получена.\nСтатус: в очереди.", + ) + else: + self._safe_send(chat_id, f"Принял задачу #{job['num']}", reply_to=message_id) def _enqueue_voice_job( self, @@ -1140,7 +1178,15 @@ class ShinePyBotService: with self.queue_lock: self.queue.append(job) self._persist_queue() - self._safe_send(chat_id, f"Принял voice в задачу #{job['num']}", reply_to=message_id) + if chat_type == "private": + self._ensure_job_status_message( + job["id"], + chat_id, + message_id, + f"Voice для задачи #{job['num']} получен.\nСтатус: в очереди.", + ) + else: + self._safe_send(chat_id, f"Принял voice в задачу #{job['num']}", reply_to=message_id) def _build_job_base(self, chat_id: int, message_id: int, username: str, history_file: str) -> dict[str, Any]: with self.queue_lock: @@ -1172,6 +1218,8 @@ class ShinePyBotService: "created_at": now_iso(), "updated_at": now_iso(), "active_since": None, + "status_message_id": None, + "status_message_text": "", } def _handle_task_center_text(self, chat_id: int, message_id: int, username: str, text: str) -> bool: @@ -1279,6 +1327,9 @@ class ShinePyBotService: if command in ("/start", "/help"): self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) return + if command == "/settings": + self._safe_send(chat_id, self._settings_text(username), reply_to=message_id) + return if command == "/status": self._safe_send(chat_id, self._status_text(username), reply_to=message_id) return @@ -1317,6 +1368,16 @@ class ShinePyBotService: self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username) self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id) return + if command == "/single_message_on": + self._set_single_status_message_enabled(username, True) + self._append_history_event("single_status_message_enabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке включён для вашего пользователя.", reply_to=message_id) + return + if command == "/single_message_off": + self._set_single_status_message_enabled(username, False) + self._append_history_event("single_status_message_disabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке выключен. Бот будет отправлять отдельные сообщения по этапам.", reply_to=message_id) + return if command == "/new": archived = self._rotate_history("command_new", username) self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) @@ -1383,15 +1444,12 @@ class ShinePyBotService: lines = [ "Доступные команды:", "/status — активная задача и размер очереди", + "/settings — текущие настройки и команды для их изменения", "/queue — список задач в очереди", "/tasks — список ваших задач и предложений", "/stop — остановить текущую задачу", "/cancel — удалить задачу по id (префикс) или все", "/new — архивировать историю и начать новую", - "/voice_on — включить озвучивание финальных ответов", - "/voice_off — выключить озвучивание финальных ответов", - "/voice_rewrite_on — включить адаптацию текста перед озвучкой", - "/voice_rewrite_off — выключить адаптацию текста перед озвучкой", "/help — эта справка", ] if is_owner: @@ -1400,15 +1458,37 @@ class ShinePyBotService: lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас") return "\n".join(lines) + def _settings_text(self, username: str) -> str: + voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" + rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" + single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" + lines = [ + "Текущие настройки:", + f"Озвучивание финальных ответов: {voice_status}", + f"Адаптация текста перед озвучкой: {rewrite_status}", + f"Режим одного редактируемого сообщения в личке: {single_message_status}", + "", + "Команды настроек:", + "/voice_on — включить озвучивание", + "/voice_off — выключить озвучивание", + "/voice_rewrite_on — адаптировать текст перед озвучкой", + "/voice_rewrite_off — озвучивать обычный текст без адаптации", + "/single_message_on — один редактируемый ответ в личке", + "/single_message_off — отдельные сообщения по этапам и финалу", + ] + return "\n".join(lines) + def _status_text(self, username: str) -> str: with self.queue_lock: active = next((j for j in self.queue if j.get("status") == "active"), None) pending = sum(1 for j in self.queue if j.get("status") == "pending") voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" + single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" settings_text = ( f"Голосовые ответы: {voice_status}\n" - f"Адаптация текста перед озвучкой: {rewrite_status}" + f"Адаптация текста перед озвучкой: {rewrite_status}\n" + f"Режим одного сообщения в личке: {single_message_status}" ) restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else "" if not active: @@ -1505,38 +1585,53 @@ class ShinePyBotService: chat_id = int(job["chat_id"]) message_id = int(job["message_id"]) history_path = Path(job["history_file"]) - self._safe_send(chat_id, f"Задача #{job_num} в работе.", reply_to=message_id) + private_single_message = ( + (job.get("chat_type") or "") == "private" + and self._single_status_message_enabled(job.get("username") or "") + ) + self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняется.") try: if job.get("type") == "voice": - self._safe_send(chat_id, f"#{job_num}: распознаю голосовое...", reply_to=message_id) + self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: распознаю voice.") recognized = self._transcribe_voice_job( job, - status_cb=lambda note: self._safe_send(chat_id, f"#{job_num}: {note}", reply_to=message_id), + status_cb=lambda note: self._set_job_status_text( + job, + f"Задача #{job_num} в работе.\nСтатус: {note}", + ), ) job["text"] = recognized self._append_history(history_path, "voice_transcription", {"jobId": job_id, "jobNum": job_num, "text": recognized}) preview = recognized.strip() - if len(preview) > 1200: - preview = preview[:1200] + " ...[обрезано]" - self._safe_send(chat_id, f"#{job_num}: распознано:\n{preview}", reply_to=message_id) - self._safe_send(chat_id, f"#{job_num}: распознано, отправляю в Codex.", reply_to=message_id) + if len(preview) > 800: + preview = preview[:800].rstrip() + " ...[обрезано]" + self._set_job_status_text( + job, + f"Задача #{job_num} в работе.\nСтатус: voice распознан, отправляю в Codex.\n\nТекст:\n{preview}", + ) prompt = self._build_prompt(job) self._append_history(history_path, "codex_request", {"jobId": job_id, "prompt": prompt}) - answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num) - for chunk in split_long_text(answer): - self._safe_send(chat_id, chunk, reply_to=message_id) + self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняю через Codex.") + answer = self._run_codex(prompt, job) + if private_single_message: + parts = split_final_private_text(answer) + self._set_job_status_text(job, parts[0]) + if len(parts) > 1: + self._safe_send(chat_id, parts[1], reply_to=message_id) + else: + for chunk in split_long_text(answer): + self._safe_send(chat_id, chunk, reply_to=message_id) self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) self._send_private_job_public_report(job, answer) self._send_task_center_reminder(job) if self._voice_replies_enabled(job.get("username") or ""): self._send_voice_reply_for_answer(job, answer, history_path, job_id) - self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id) self._mark_job_done(job_id) except Exception as e: if self.stop_current_job: self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)}) - self._safe_send(chat_id, f"Задача #{job_num} остановлена.", reply_to=message_id) + self._set_job_status_text(job, f"Задача #{job_num} остановлена.") self._mark_job_removed(job_id) self.stop_current_job = False return @@ -1584,8 +1679,10 @@ class ShinePyBotService: f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}" ) - def _run_codex(self, prompt: str, chat_id: int, message_id: int, job_id: str, job_num: Any) -> str: + def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: output_lines: list[str] = [] + job_id = str(job["id"]) + job_num = job.get("num", "?") with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: output_file = Path(tmp.name) @@ -1624,7 +1721,7 @@ class ShinePyBotService: note = self._extract_codex_user_note(line) now = time.time() if note and note != last_user_note and now - last_user_note_at > 8: - self._safe_send(chat_id, f"#{job_num}: {note}", reply_to=message_id) + self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: {note}") last_user_note = note last_user_note_at = now last_job_message_at = now @@ -1656,10 +1753,9 @@ class ShinePyBotService: raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") if now - codex_started_at >= 120 and now - last_job_message_at >= 120: elapsed = self._format_duration(int(now - codex_started_at)) - self._safe_send( - chat_id, - f"#{job_num}: задача ещё выполняется, работает уже {elapsed}. От Codex давно нет сообщений.", - reply_to=message_id, + self._set_job_status_text( + job, + f"Задача #{job_num} в работе.\nСтатус: выполняется уже {elapsed}, от Codex давно нет новых сообщений.", ) last_job_message_at = now self.last_heartbeat_at = now @@ -1705,8 +1801,6 @@ class ShinePyBotService: def _handle_job_failure(self, job: dict[str, Any], err: Exception) -> None: job_id = job["id"] job_num = job.get("num", "?") - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) error_text = str(err).strip() or err.__class__.__name__ user_error_text = self._user_error_text(err) retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable @@ -1733,13 +1827,12 @@ class ShinePyBotService: will_retry = False if will_retry: - self._safe_send( - chat_id, + self._set_job_status_text( + job, f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.", - reply_to=message_id, ) else: - self._safe_send(chat_id, f"{user_error_text}\nЗадача #{job_num} остановлена.", reply_to=message_id) + self._set_job_status_text(job, f"{user_error_text}\nЗадача #{job_num} остановлена.") def _user_error_text(self, err: Exception) -> str: if isinstance(err, VoiceTranscriptionError): @@ -1951,6 +2044,83 @@ class ShinePyBotService: print(f"[py-bot] sendMessage error: {e}", flush=True) return None + def _safe_edit(self, chat_id: int | str, message_id: int | None, text: str) -> bool: + text = (text or "").strip() + if not text or not message_id: + return False + if len(text) > 3900: + text = text[:3900] + "\n...[обрезано]" + resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id + try: + self.telegram.edit_message_text(resolved_chat_id, message_id, text) + return True + except Exception as e: + error_text = str(e) + if "message is not modified" in error_text: + return True + migrate_to_chat_id = self._extract_migrate_to_chat_id(error_text) + if migrate_to_chat_id is not None: + if isinstance(resolved_chat_id, int): + self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "edit_message_error") + try: + self.telegram.edit_message_text(migrate_to_chat_id, message_id, text) + return True + except Exception as retry_error: + print(f"[py-bot] editMessageText retry after migration error: {retry_error}", flush=True) + return False + print(f"[py-bot] editMessageText error: {e}", flush=True) + return False + + def _ensure_job_status_message(self, job_id: str, chat_id: int, reply_to_message_id: int, text: str) -> int | None: + text = (text or "").strip() + if not text: + return None + with self.queue_lock: + target = next((j for j in self.queue if j.get("id") == job_id), None) + if target: + existing_id = target.get("status_message_id") + existing_text = (target.get("status_message_text") or "").strip() + else: + existing_id = None + existing_text = "" + if existing_id and existing_text == text and self._safe_edit(chat_id, int(existing_id), text): + return int(existing_id) + if existing_id and self._safe_edit(chat_id, int(existing_id), text): + with self.queue_lock: + target = next((j for j in self.queue if j.get("id") == job_id), None) + if target: + target["status_message_text"] = text + target["updated_at"] = now_iso() + self._persist_queue() + return int(existing_id) + message_id = self._safe_send(chat_id, text, reply_to=reply_to_message_id) + if message_id is not None: + with self.queue_lock: + target = next((j for j in self.queue if j.get("id") == job_id), None) + if target: + target["status_message_id"] = message_id + target["status_message_text"] = text + target["updated_at"] = now_iso() + self._persist_queue() + return message_id + + def _set_job_status_text(self, job: dict[str, Any], text: str) -> None: + if (job.get("chat_type") or "") != "private": + self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) + return + if not self._single_status_message_enabled(job.get("username") or ""): + self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) + return + message_id = self._ensure_job_status_message( + job["id"], + int(job["chat_id"]), + int(job["message_id"]), + text, + ) + if message_id is not None: + job["status_message_id"] = message_id + job["status_message_text"] = text + def _request_deferred_restart(self) -> None: if self.restart_requested: return diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainWsEndpoint.java b/SHiNE-server/src/main/java/server/ws/BlockchainWsEndpoint.java index cf26ecb..8f464e2 100644 --- a/SHiNE-server/src/main/java/server/ws/BlockchainWsEndpoint.java +++ b/SHiNE-server/src/main/java/server/ws/BlockchainWsEndpoint.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.JsonInboundProcessor; +import java.nio.channels.ClosedChannelException; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; @@ -131,9 +132,19 @@ public class BlockchainWsEndpoint { @OnWebSocketError public void onError(Throwable cause) { + if (isNormalClosedChannel(cause)) { + log.info("WS channel already closed during normal shutdown: {}", cause.toString()); + return; + } log.error("WS error", cause); } + private boolean isNormalClosedChannel(Throwable cause) { + if (cause == null) return false; + if (cause instanceof ClosedChannelException) return true; + return String.valueOf(cause).contains("ClosedChannelException"); + } + private void trySendJsonError() { if (session != null && session.isOpen()) { String resp = "{\"op\":null,\"requestId\":null,\"status\":500," @@ -152,4 +163,4 @@ public class BlockchainWsEndpoint { }); } } -} \ No newline at end of file +} diff --git a/VERSION.properties b/VERSION.properties index d11cd10..e7cac24 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.129 -server.version=1.2.121 +client.version=1.2.130 +server.version=1.2.122 diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index 5eb1ab9..e96a85d 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -46,10 +46,10 @@ export function render({ navigate }) { advanced.className = 'card stack'; advanced.innerHTML = ` Расширенные -

Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.

-

Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).

-

Для тесто оставьте пустой пароль.

-

Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

+

Схема derivation ключей: логин нормализуется как `trim().toLowerCase()`. При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.

+

Если пароль пустой — используется прежний детерминированный режим совместимости.

+

Для тестов можно оставить пустой пароль.

+

Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

`; const status = document.createElement('p'); diff --git a/shine-UI/js/pages/registration-draft-keys-view.js b/shine-UI/js/pages/registration-draft-keys-view.js index c1c1b4d..1400049 100644 --- a/shine-UI/js/pages/registration-draft-keys-view.js +++ b/shine-UI/js/pages/registration-draft-keys-view.js @@ -100,15 +100,14 @@ export function render({ navigate }) { card.append(warning); - // Секрет (root key seed) + // Master secret let secretB58 = ''; try { - const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64); - secretB58 = bytesToBase58(rootSeed32); + secretB58 = bytesToBase58(base64ToBytes(keyBundle.masterSecretB64)); } catch { secretB58 = '(не удалось извлечь)'; } - card.append(makeSecretField({ label: 'Секрет (root seed, base58, 32 байта)', value: secretB58 })); + card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 })); // Root key const rootSep = document.createElement('p'); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 65afa51..9ba1891 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -750,7 +750,12 @@ export class AuthService { if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' }); const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key'); - const result = { rootPair, blockchainPair, devicePair }; + const result = { + masterSecretB64: bytesToBase64(masterSecret), + rootPair, + blockchainPair, + devicePair, + }; this.passwordKeyBundleCache.set(cacheKey, result); if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' }); return result;