Обновить Telegram-бота, документацию и связанные доработки
This commit is contained in:
parent
ce5c348023
commit
c5ec32f87a
255
DAO_запуск/README.md
Normal file
255
DAO_запуск/README.md
Normal file
@ -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. после этого делать синхронизацию, архив и восстановление.
|
||||||
@ -39,6 +39,10 @@
|
|||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||||
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи.
|
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
|
|
||||||
|
### DAO-запуск
|
||||||
|
|
||||||
|
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
|
||||||
|
|
||||||
### Дальнее будущее
|
### Дальнее будущее
|
||||||
|
|
||||||
- Сейчас задач нет.
|
- Сейчас задач нет.
|
||||||
|
|||||||
@ -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** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
|
||||||
|
- **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 + сервер работают.
|
||||||
@ -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
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
## Краткое описание
|
||||||
|
В `SHiNE-agent-bot-coder` для личных чатов добавлен режим одного редактируемого статусного сообщения. Бот принимает запрос, обновляет это сообщение по этапам обработки и в конце превращает его в финальный текстовый ответ. При длинном ответе допускается ещё одно дополнительное текстовое сообщение с продолжением. Голосовой ответ остаётся отдельным сообщением.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. Отправить в личный чат короткий текстовый запрос и убедиться, что бот не шлёт цепочку промежуточных сообщений, а редактирует одно сообщение до финального ответа.
|
||||||
|
2. Отправить в личный чат `voice` или `audio` и убедиться, что в том же сообщении последовательно видны этапы распознавания и выполнения.
|
||||||
|
3. Проверить длинный ответ, который не помещается в один Telegram message: должно получиться не больше двух текстовых сообщений.
|
||||||
|
4. Проверить, что `voice`-ответ приходит отдельным новым сообщением после текстового.
|
||||||
|
5. Проверить, что в `@shine_writing` по-прежнему логируются только итоговые `вопрос -> ответ`, без промежуточных статусов.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- В личке основная переписка стала чище: промежуточные этапы живут в одном редактируемом сообщении.
|
||||||
|
- При длинном ответе бот не разбрасывает ответ на много сообщений.
|
||||||
|
- Канал `@shine_writing` работает по старой схеме без лишнего шума.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
`pending`
|
||||||
@ -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`
|
||||||
@ -4,9 +4,12 @@
|
|||||||
* Результат сохраняется в NVS (внутренняя flash ESP32).
|
* Результат сохраняется в NVS (внутренняя flash ESP32).
|
||||||
*
|
*
|
||||||
* Алгоритм совпадает с JS crypto-utils.js:
|
* Алгоритм совпадает с JS crypto-utils.js:
|
||||||
* salt = SHA256("shine-auth-v2|login=<login>|suffix=master.secret")[0:16]
|
* loginNorm = trim(lowercase(login))
|
||||||
* passBytes = UTF8("<login>\n<password>")
|
* salt = SHA256("shine-auth-v2|login=<loginNorm>|suffix=master.secret")[0:16]
|
||||||
|
* passBytes = UTF8("<loginNorm>\n<password>")
|
||||||
* secret = Argon2id(passBytes, salt, t=2, m=65536 KB, p=1, dkLen=32)
|
* 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))
|
* keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i))
|
||||||
* suffixes = ["root.key", "bch.key", "dev.key"]
|
* suffixes = ["root.key", "bch.key", "dev.key"]
|
||||||
*
|
*
|
||||||
@ -105,6 +108,7 @@ static char gLogin[64] = {};
|
|||||||
static char gPass[64] = {};
|
static char gPass[64] = {};
|
||||||
static uint8_t gSecret[32];
|
static uint8_t gSecret[32];
|
||||||
static char gSecretHex[65];
|
static char gSecretHex[65];
|
||||||
|
static char gInputStatus[96] = {};
|
||||||
static bool gKbNums = false;
|
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);
|
mbedtls_sha256_free(&ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void trimInPlace(char *text){
|
||||||
|
if(!text)return;
|
||||||
|
size_t len=strlen(text);
|
||||||
|
size_t start=0;
|
||||||
|
while(start<len&&(text[start]==' '||text[start]=='\t'||text[start]=='\n'||text[start]=='\r'))start++;
|
||||||
|
size_t end=len;
|
||||||
|
while(end>start&&(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
|
// BASE58
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@ -473,6 +532,13 @@ static void argon2Init(const char *login,const char *password){
|
|||||||
#undef B2LE32
|
#undef B2LE32
|
||||||
b2_final(&gB2S,gH0);
|
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);
|
uint8_t input[72];memcpy(input,gH0,64);
|
||||||
for(uint32_t i=0;i<2;i++){
|
for(uint32_t i=0;i<2;i++){
|
||||||
input[64]=i;input[65]=0;input[66]=0;input[67]=0;
|
input[64]=i;input[65]=0;input[66]=0;input[67]=0;
|
||||||
@ -541,6 +607,13 @@ static void argon2Finalize(){
|
|||||||
Serial.println("=== END ===");
|
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
|
// 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';}
|
else{strncpy(show,buf,67);show[67]='\0';}
|
||||||
gfx->setTextSize(2);gfx->setTextColor(C_TEXT);gfx->setCursor(30,78);gfx->print(show);
|
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);
|
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();
|
drawKeyboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -628,6 +704,7 @@ static void drawProgressHeader(){
|
|||||||
// нарисовать пустую полосу один раз
|
// нарисовать пустую полосу один раз
|
||||||
int bx=20,by=90,bw=DISP_W-40,bh=36;
|
int bx=20,by=90,bw=DISP_W-40,bh=36;
|
||||||
uiRect(bx,by,bw,bh,C_BAR_BG,C_SEP,4);
|
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(){
|
static void drawProgress(){
|
||||||
@ -639,7 +716,7 @@ static void drawProgress(){
|
|||||||
int filled=(int)((uint64_t)bw*gDone/TOTAL_FILLS);
|
int filled=(int)((uint64_t)bw*gDone/TOTAL_FILLS);
|
||||||
if(gDone>0&&filled==0)filled=1; // at least 1px once started
|
if(gDone>0&&filled==0)filled=1; // at least 1px once started
|
||||||
if(filled>gBarFilledPx){
|
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;
|
gBarFilledPx=filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -725,6 +802,8 @@ static void drawResultContent(){
|
|||||||
d.line(" RESULT",C_GREEN,2);
|
d.line(" RESULT",C_GREEN,2);
|
||||||
snprintf(buf,sizeof(buf),"Login: %s",gLogin);
|
snprintf(buf,sizeof(buf),"Login: %s",gLogin);
|
||||||
d.line(buf,C_TEXT,2);
|
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.gap(4);
|
||||||
d.line("Master secret (base58):",C_HINT,2);
|
d.line("Master secret (base58):",C_HINT,2);
|
||||||
d.longval(gSecretB58,C_GREEN);
|
d.longval(gSecretB58,C_GREEN);
|
||||||
@ -758,7 +837,6 @@ static void drawResultBtnBar(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void drawResultFull(){
|
static void drawResultFull(){
|
||||||
gScrollY=0;
|
|
||||||
drawResultContent();
|
drawResultContent();
|
||||||
drawResultBtnBar();
|
drawResultBtnBar();
|
||||||
}
|
}
|
||||||
@ -790,27 +868,53 @@ static void handleTap(int tx,int ty){
|
|||||||
char *buf=isPass?gPass:gLogin;
|
char *buf=isPass?gPass:gLogin;
|
||||||
char c=kbTouch(tx,ty);
|
char c=kbTouch(tx,ty);
|
||||||
if(!c)return;
|
if(!c)return;
|
||||||
if(c=='\x03'){gKbNums=!gKbNums;drawInputScreen(isPass?"Password:":"Login:",buf,isPass);return;}
|
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';}
|
if(c=='\x08'){int n=strlen(buf);if(n>0)buf[n-1]='\0';clearInputStatus();}
|
||||||
else if(c=='\x0D'){
|
else if(c=='\x0D'){
|
||||||
if(!strlen(buf))return;
|
|
||||||
if(gState==ST_LOGIN){
|
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;
|
gState=ST_PASS;drawInputScreen("Password:",gPass,true);return;
|
||||||
} else {
|
} else {
|
||||||
|
clearInputStatus();
|
||||||
gState=ST_RUNNING;
|
gState=ST_RUNNING;
|
||||||
gfx->fillScreen(C_BG);drawProgressHeader();
|
gfx->fillScreen(C_BG);drawProgressHeader();
|
||||||
SD_MMC.remove(SD_MEM_FILE);
|
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){
|
if(!gSdFile){
|
||||||
gfx->setTextSize(2);gfx->setTextColor(C_RED);
|
gfx->setTextSize(2);gfx->setTextColor(C_RED);
|
||||||
gfx->setCursor(20,200);gfx->println("SD open failed");
|
gfx->setCursor(20,200);gfx->println("SD open failed");
|
||||||
gState=ST_ERROR;return;
|
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);
|
argon2Init(gLogin,gPass);
|
||||||
drawProgress();return;
|
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);
|
drawInputScreen(isPass?"Password:":"Login:",buf,isPass);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -875,6 +979,25 @@ void setup(){
|
|||||||
while(true)delay(1000);
|
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 буферы
|
// PSRAM буферы
|
||||||
gBufPrev=(uint8_t*)ps_malloc(A2_BLKSZ);gBufRef=(uint8_t*)ps_malloc(A2_BLKSZ);
|
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);
|
gBufOut=(uint8_t*)ps_malloc(A2_BLKSZ);gBufZero=(uint8_t*)ps_calloc(1,A2_BLKSZ);
|
||||||
|
|||||||
@ -1 +1,7 @@
|
|||||||
# -
|
# SHiNE
|
||||||
|
|
||||||
|
## План запуска DAO
|
||||||
|
|
||||||
|
План запуска DAO зафиксирован в [DAO_запуск/README.md](DAO_запуск/README.md).
|
||||||
|
|
||||||
|
Это рабочий список задач по `этап1` и `этап2`. Дальше ведём его как основной чек-лист запуска DAO и отмечаем в нём выполненные пункты по мере готовности.
|
||||||
|
|||||||
@ -34,8 +34,10 @@
|
|||||||
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
||||||
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
|
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
|
||||||
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
|
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
|
||||||
|
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
|
||||||
|
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
|
||||||
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
||||||
- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой.
|
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
|
||||||
|
|
||||||
## Правила голосовой версии ответа
|
## Правила голосовой версии ответа
|
||||||
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
- обрабатывает задачи строго последовательно;
|
- обрабатывает задачи строго последовательно;
|
||||||
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||||
|
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
|
||||||
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
- при рестарте восстанавливает незавершённые задачи;
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||||
@ -60,6 +61,13 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
|
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
|
||||||
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
|
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
|
||||||
|
|
||||||
|
## Статусы в личке
|
||||||
|
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
|
||||||
|
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
|
||||||
|
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
|
||||||
|
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
|
||||||
|
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
|
||||||
|
|
||||||
## Запуск как systemd-сервис
|
## Запуск как systemd-сервис
|
||||||
Файлы для установки:
|
Файлы для установки:
|
||||||
- `scripts/systemd/shine-agent-bot-coder.service`
|
- `scripts/systemd/shine-agent-bot-coder.service`
|
||||||
@ -77,6 +85,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
|
|
||||||
## Telegram-команды
|
## Telegram-команды
|
||||||
- `/status` — активная задача и размер очереди.
|
- `/status` — активная задача и размер очереди.
|
||||||
|
- `/settings` — текущие пользовательские настройки и команды для их изменения.
|
||||||
- `/queue` — список задач в очереди.
|
- `/queue` — список задач в очереди.
|
||||||
- `/stop` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
@ -85,5 +94,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
||||||
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
|
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
|
||||||
|
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
|
||||||
|
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
|
||||||
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
|
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
|
||||||
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
|
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
|
||||||
|
|||||||
@ -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)]
|
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]:
|
def split_text_for_tts(text: str, chunk_size: int) -> list[str]:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@ -266,6 +283,10 @@ class TelegramApi:
|
|||||||
payload["reply_to_message_id"] = reply_to_message_id
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
return self.call("sendMessage", payload=payload, timeout=30)
|
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(
|
def send_voice(
|
||||||
self,
|
self,
|
||||||
chat_id: int | str,
|
chat_id: int | str,
|
||||||
@ -690,6 +711,8 @@ class ShinePyBotService:
|
|||||||
user_settings["voice_replies_enabled"] = True
|
user_settings["voice_replies_enabled"] = True
|
||||||
if not isinstance(user_settings.get("voice_rewrite_enabled"), bool):
|
if not isinstance(user_settings.get("voice_rewrite_enabled"), bool):
|
||||||
user_settings["voice_rewrite_enabled"] = True
|
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
|
return user_settings
|
||||||
|
|
||||||
def _voice_replies_enabled(self, username: str) -> bool:
|
def _voice_replies_enabled(self, username: str) -> bool:
|
||||||
@ -706,6 +729,13 @@ class ShinePyBotService:
|
|||||||
self._user_settings(username)["voice_rewrite_enabled"] = enabled
|
self._user_settings(username)["voice_rewrite_enabled"] = enabled
|
||||||
self._persist_state()
|
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:
|
def _remember_private_chat(self, username: str, chat_id: int) -> None:
|
||||||
uname = normalize_username(username)
|
uname = normalize_username(username)
|
||||||
if not uname:
|
if not uname:
|
||||||
@ -1088,6 +1118,14 @@ class ShinePyBotService:
|
|||||||
with self.queue_lock:
|
with self.queue_lock:
|
||||||
self.queue.append(job)
|
self.queue.append(job)
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
|
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)
|
self._safe_send(chat_id, f"Принял задачу #{job['num']}", reply_to=message_id)
|
||||||
|
|
||||||
def _enqueue_voice_job(
|
def _enqueue_voice_job(
|
||||||
@ -1140,6 +1178,14 @@ class ShinePyBotService:
|
|||||||
with self.queue_lock:
|
with self.queue_lock:
|
||||||
self.queue.append(job)
|
self.queue.append(job)
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
|
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)
|
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]:
|
def _build_job_base(self, chat_id: int, message_id: int, username: str, history_file: str) -> dict[str, Any]:
|
||||||
@ -1172,6 +1218,8 @@ class ShinePyBotService:
|
|||||||
"created_at": now_iso(),
|
"created_at": now_iso(),
|
||||||
"updated_at": now_iso(),
|
"updated_at": now_iso(),
|
||||||
"active_since": None,
|
"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:
|
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"):
|
if command in ("/start", "/help"):
|
||||||
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
|
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
|
||||||
return
|
return
|
||||||
|
if command == "/settings":
|
||||||
|
self._safe_send(chat_id, self._settings_text(username), reply_to=message_id)
|
||||||
|
return
|
||||||
if command == "/status":
|
if command == "/status":
|
||||||
self._safe_send(chat_id, self._status_text(username), reply_to=message_id)
|
self._safe_send(chat_id, self._status_text(username), reply_to=message_id)
|
||||||
return
|
return
|
||||||
@ -1317,6 +1368,16 @@ class ShinePyBotService:
|
|||||||
self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username)
|
self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username)
|
||||||
self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id)
|
self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id)
|
||||||
return
|
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":
|
if command == "/new":
|
||||||
archived = self._rotate_history("command_new", username)
|
archived = self._rotate_history("command_new", username)
|
||||||
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
||||||
@ -1383,15 +1444,12 @@ class ShinePyBotService:
|
|||||||
lines = [
|
lines = [
|
||||||
"Доступные команды:",
|
"Доступные команды:",
|
||||||
"/status — активная задача и размер очереди",
|
"/status — активная задача и размер очереди",
|
||||||
|
"/settings — текущие настройки и команды для их изменения",
|
||||||
"/queue — список задач в очереди",
|
"/queue — список задач в очереди",
|
||||||
"/tasks — список ваших задач и предложений",
|
"/tasks — список ваших задач и предложений",
|
||||||
"/stop — остановить текущую задачу",
|
"/stop — остановить текущую задачу",
|
||||||
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
||||||
"/new — архивировать историю и начать новую",
|
"/new — архивировать историю и начать новую",
|
||||||
"/voice_on — включить озвучивание финальных ответов",
|
|
||||||
"/voice_off — выключить озвучивание финальных ответов",
|
|
||||||
"/voice_rewrite_on — включить адаптацию текста перед озвучкой",
|
|
||||||
"/voice_rewrite_off — выключить адаптацию текста перед озвучкой",
|
|
||||||
"/help — эта справка",
|
"/help — эта справка",
|
||||||
]
|
]
|
||||||
if is_owner:
|
if is_owner:
|
||||||
@ -1400,15 +1458,37 @@ class ShinePyBotService:
|
|||||||
lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас")
|
lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас")
|
||||||
return "\n".join(lines)
|
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:
|
def _status_text(self, username: str) -> str:
|
||||||
with self.queue_lock:
|
with self.queue_lock:
|
||||||
active = next((j for j in self.queue if j.get("status") == "active"), None)
|
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")
|
pending = sum(1 for j in self.queue if j.get("status") == "pending")
|
||||||
voice_status = "включено" if self._voice_replies_enabled(username) else "выключено"
|
voice_status = "включено" if self._voice_replies_enabled(username) else "выключено"
|
||||||
rewrite_status = "включена" if self._voice_rewrite_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 = (
|
settings_text = (
|
||||||
f"Голосовые ответы: {voice_status}\n"
|
f"Голосовые ответы: {voice_status}\n"
|
||||||
f"Адаптация текста перед озвучкой: {rewrite_status}"
|
f"Адаптация текста перед озвучкой: {rewrite_status}\n"
|
||||||
|
f"Режим одного сообщения в личке: {single_message_status}"
|
||||||
)
|
)
|
||||||
restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else ""
|
restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else ""
|
||||||
if not active:
|
if not active:
|
||||||
@ -1505,25 +1585,41 @@ class ShinePyBotService:
|
|||||||
chat_id = int(job["chat_id"])
|
chat_id = int(job["chat_id"])
|
||||||
message_id = int(job["message_id"])
|
message_id = int(job["message_id"])
|
||||||
history_path = Path(job["history_file"])
|
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:
|
try:
|
||||||
if job.get("type") == "voice":
|
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(
|
recognized = self._transcribe_voice_job(
|
||||||
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
|
job["text"] = recognized
|
||||||
self._append_history(history_path, "voice_transcription", {"jobId": job_id, "jobNum": job_num, "text": recognized})
|
self._append_history(history_path, "voice_transcription", {"jobId": job_id, "jobNum": job_num, "text": recognized})
|
||||||
preview = recognized.strip()
|
preview = recognized.strip()
|
||||||
if len(preview) > 1200:
|
if len(preview) > 800:
|
||||||
preview = preview[:1200] + " ...[обрезано]"
|
preview = preview[:800].rstrip() + " ...[обрезано]"
|
||||||
self._safe_send(chat_id, f"#{job_num}: распознано:\n{preview}", reply_to=message_id)
|
self._set_job_status_text(
|
||||||
self._safe_send(chat_id, f"#{job_num}: распознано, отправляю в Codex.", reply_to=message_id)
|
job,
|
||||||
|
f"Задача #{job_num} в работе.\nСтатус: voice распознан, отправляю в Codex.\n\nТекст:\n{preview}",
|
||||||
|
)
|
||||||
|
|
||||||
prompt = self._build_prompt(job)
|
prompt = self._build_prompt(job)
|
||||||
self._append_history(history_path, "codex_request", {"jobId": job_id, "prompt": prompt})
|
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)
|
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):
|
for chunk in split_long_text(answer):
|
||||||
self._safe_send(chat_id, chunk, reply_to=message_id)
|
self._safe_send(chat_id, chunk, reply_to=message_id)
|
||||||
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
||||||
@ -1531,12 +1627,11 @@ class ShinePyBotService:
|
|||||||
self._send_task_center_reminder(job)
|
self._send_task_center_reminder(job)
|
||||||
if self._voice_replies_enabled(job.get("username") or ""):
|
if self._voice_replies_enabled(job.get("username") or ""):
|
||||||
self._send_voice_reply_for_answer(job, answer, history_path, job_id)
|
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)
|
self._mark_job_done(job_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.stop_current_job:
|
if self.stop_current_job:
|
||||||
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
|
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._mark_job_removed(job_id)
|
||||||
self.stop_current_job = False
|
self.stop_current_job = False
|
||||||
return
|
return
|
||||||
@ -1584,8 +1679,10 @@ class ShinePyBotService:
|
|||||||
f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}"
|
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] = []
|
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:
|
with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp:
|
||||||
output_file = Path(tmp.name)
|
output_file = Path(tmp.name)
|
||||||
|
|
||||||
@ -1624,7 +1721,7 @@ class ShinePyBotService:
|
|||||||
note = self._extract_codex_user_note(line)
|
note = self._extract_codex_user_note(line)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if note and note != last_user_note and now - last_user_note_at > 8:
|
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 = note
|
||||||
last_user_note_at = now
|
last_user_note_at = now
|
||||||
last_job_message_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")
|
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:
|
if now - codex_started_at >= 120 and now - last_job_message_at >= 120:
|
||||||
elapsed = self._format_duration(int(now - codex_started_at))
|
elapsed = self._format_duration(int(now - codex_started_at))
|
||||||
self._safe_send(
|
self._set_job_status_text(
|
||||||
chat_id,
|
job,
|
||||||
f"#{job_num}: задача ещё выполняется, работает уже {elapsed}. От Codex давно нет сообщений.",
|
f"Задача #{job_num} в работе.\nСтатус: выполняется уже {elapsed}, от Codex давно нет новых сообщений.",
|
||||||
reply_to=message_id,
|
|
||||||
)
|
)
|
||||||
last_job_message_at = now
|
last_job_message_at = now
|
||||||
self.last_heartbeat_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:
|
def _handle_job_failure(self, job: dict[str, Any], err: Exception) -> None:
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
job_num = job.get("num", "?")
|
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__
|
error_text = str(err).strip() or err.__class__.__name__
|
||||||
user_error_text = self._user_error_text(err)
|
user_error_text = self._user_error_text(err)
|
||||||
retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable
|
retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable
|
||||||
@ -1733,13 +1827,12 @@ class ShinePyBotService:
|
|||||||
will_retry = False
|
will_retry = False
|
||||||
|
|
||||||
if will_retry:
|
if will_retry:
|
||||||
self._safe_send(
|
self._set_job_status_text(
|
||||||
chat_id,
|
job,
|
||||||
f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.",
|
f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.",
|
||||||
reply_to=message_id,
|
|
||||||
)
|
)
|
||||||
else:
|
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:
|
def _user_error_text(self, err: Exception) -> str:
|
||||||
if isinstance(err, VoiceTranscriptionError):
|
if isinstance(err, VoiceTranscriptionError):
|
||||||
@ -1951,6 +2044,83 @@ class ShinePyBotService:
|
|||||||
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
||||||
return None
|
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:
|
def _request_deferred_restart(self) -> None:
|
||||||
if self.restart_requested:
|
if self.restart_requested:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
|||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
|
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
|
||||||
|
|
||||||
|
import java.nio.channels.ClosedChannelException;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@ -131,9 +132,19 @@ public class BlockchainWsEndpoint {
|
|||||||
|
|
||||||
@OnWebSocketError
|
@OnWebSocketError
|
||||||
public void onError(Throwable cause) {
|
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);
|
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() {
|
private void trySendJsonError() {
|
||||||
if (session != null && session.isOpen()) {
|
if (session != null && session.isOpen()) {
|
||||||
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
|
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.129
|
client.version=1.2.130
|
||||||
server.version=1.2.121
|
server.version=1.2.122
|
||||||
|
|||||||
@ -46,10 +46,10 @@ export function render({ navigate }) {
|
|||||||
advanced.className = 'card stack';
|
advanced.className = 'card stack';
|
||||||
advanced.innerHTML = `
|
advanced.innerHTML = `
|
||||||
<summary>Расширенные</summary>
|
<summary>Расширенные</summary>
|
||||||
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
|
<p class="meta-muted">Схема derivation ключей: логин нормализуется как `trim().toLowerCase()`. При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</p>
|
||||||
<p class="meta-muted">Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
|
<p class="meta-muted">Если пароль пустой — используется прежний детерминированный режим совместимости.</p>
|
||||||
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
|
<p class="meta-muted">Для тестов можно оставить пустой пароль.</p>
|
||||||
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const status = document.createElement('p');
|
const status = document.createElement('p');
|
||||||
|
|||||||
@ -100,15 +100,14 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
card.append(warning);
|
card.append(warning);
|
||||||
|
|
||||||
// Секрет (root key seed)
|
// Master secret
|
||||||
let secretB58 = '';
|
let secretB58 = '';
|
||||||
try {
|
try {
|
||||||
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64);
|
secretB58 = bytesToBase58(base64ToBytes(keyBundle.masterSecretB64));
|
||||||
secretB58 = bytesToBase58(rootSeed32);
|
|
||||||
} catch {
|
} catch {
|
||||||
secretB58 = '(не удалось извлечь)';
|
secretB58 = '(не удалось извлечь)';
|
||||||
}
|
}
|
||||||
card.append(makeSecretField({ label: 'Секрет (root seed, base58, 32 байта)', value: secretB58 }));
|
card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 }));
|
||||||
|
|
||||||
// Root key
|
// Root key
|
||||||
const rootSep = document.createElement('p');
|
const rootSep = document.createElement('p');
|
||||||
|
|||||||
@ -750,7 +750,12 @@ export class AuthService {
|
|||||||
|
|
||||||
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
|
if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' });
|
||||||
const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.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);
|
this.passwordKeyBundleCache.set(cacheKey, result);
|
||||||
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' });
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user