diff --git a/.gitignore b/.gitignore index 914dba7..5a1214c 100644 --- a/.gitignore +++ b/.gitignore @@ -78,8 +78,18 @@ shine-solana/shine/scripts/**/TEMP_*.md # Локальные артефакты и внешние материалы ESP32-подпроекта ESP32/**/.git/ ESP32/**/.idea/ +ESP32-wallet/.idea/ ESP32/**/.arduino-build/ ESP32/**/official-demo/ +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/ +ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/** +!ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo/examples/Arduino-v3.3.5/libraries/lv_conf.h ESP32/**/original-firmware/*.bin ESP32/**/original-firmware/*.bin.sha256 ESP32/**/*.elf diff --git a/AGENTS.md b/AGENTS.md index c1bc31e..8bb574b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,12 +24,12 @@ - Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`. - Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`. -## ESP32 UI сабсервера +## ESP32 UI homeserver - Для UI-скетча устройства `ESP32-S3-Touch-AMOLED-2.16` документ-спецификация и Arduino-скетч должны всегда оставаться синхронными. - Актуальный документ по экранной логике, состояниям, кнопкам, полям, статусам и ограничениям UI считать источником истины для скетча. - При изменении документа обязательно в том же наборе изменений приводить в соответствие скетч. - При изменении скетча обязательно в том же наборе изменений обновлять документ, если поменялись экраны, тексты, переходы, статусы, кнопки, поля или поведение. -- Для нового ESP32 UI-прототипа сабсервера использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде. +- Для нового ESP32 UI-прототипа homeserver использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде. ## Solana-модуль - В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`. diff --git a/DAO_запуск/README.md b/DAO_запуск/README.md index c739f28..ac7fd93 100644 --- a/DAO_запуск/README.md +++ b/DAO_запуск/README.md @@ -155,11 +155,11 @@ - это обязательный шаг перед переходом от "собрали" к "доверяем". -### 3. Устройство на ESP32 как сабсервер с ключами +### 3. Устройство на ESP32 как homeserver с ключами Что сделать: -- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами; +- дописать прошивку, чтобы устройство могло выступать homeserver с ключами; - дать ему возможность регистрироваться и подключаться к серверу; - определить, какие операции устройство подписывает и где хранит ключевой материал. diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index 73fe70f..65f4024 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -2,7 +2,7 @@ Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую. -Здесь четыре метода: +Здесь четыре базовых метода обычной авторизации: - `AuthChallenge` - `CreateAuthSession` @@ -17,8 +17,35 @@ - на втором шаге клиент присылает подписанный ответ; - сервер сверяет актуальные публичные ключи и только потом проверяет подпись. +Новые поля этого раздела: + +- `sessionType` — числовой код типа сессии; +- `clientPlatform` — свободная строка платформы клиента. + +Текущие поддерживаемые коды `sessionType`: + +- `1` — обычный клиент; +- `50` — кошелёк; +- `100` — homeserver. + +Правило проверки `sessionType`: + +1. если в `Solana PDA` нет записи для `sessionKey`, сервер принимает `sessionType`, присланный клиентом; +2. если запись в `PDA` есть, `sessionType` в запросе должен совпадать с `session_type` из `PDA`; +3. при несовпадении сервер возвращает `460 / SESSION_TYPE_MISMATCH`. + Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов. +Отдельно появился новый серверный сценарий pairing через доверенный homeserver/ESP. Он не заменяет обычный вход и описан в: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +Кратко: + +- `AuthChallenge/CreateAuthSession` и `SessionChallenge/SessionLogin` остаются каноническими потоками обычной авторизации; +- pairing через ESP идёт отдельными `op` и только подготавливает безопасное добавление новой сессии; +- решение об одобрении pairing принимает любая уже авторизованная доверенная сессия пользователя. + ## 1. Поток авторизации Поддерживаются два сценария: @@ -94,6 +121,8 @@ ed25519/BASE64_PUBLIC_KEY "authNonce": "nonce", "deviceKey": "BASE64_DEVICE_PUBLIC_KEY", "signatureB64": "BASE64_SIGNATURE", + "sessionType": 1, + "clientPlatform": "Web", "clientInfo": "Android 15; Pixel 9" } } @@ -153,6 +182,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} - `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`. - `422 / DEVICE_KEY_NOT_ACTUAL` — `deviceKey` не совпадает с актуальной версией на сервере. - `422 / BAD_SIGNATURE` — подпись не прошла проверку. +- `460 / SESSION_TYPE_MISMATCH` — `sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. +- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. - `501 / DB_ERROR_SESSION_CREATE` — ошибка БД при создании записи активной сессии. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. @@ -208,6 +239,8 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} "sessionKey": "ed25519/BASE64_PUBLIC_KEY", "timeMs": 1774600010456, "signatureB64": "BASE64_SIGNATURE", + "sessionType": 1, + "clientPlatform": "Web", "clientInfo": "Android 15; Pixel 9" } } @@ -258,12 +291,40 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` не поддерживается текущим сервером. - `400 / BAD_BASE64` — неверный Base64 в `sessionKey` или `signatureB64`. - `422 / BAD_SIGNATURE` — подпись не прошла проверку. +- `460 / SESSION_TYPE_MISMATCH` — `sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. +- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. - `501 / DB_ERROR_USER_LOOKUP` — ошибка БД при чтении пользователя для этой сессии. - `422 / USER_NOT_FOUND_FOR_SESSION` — пользователь, которому принадлежит сессия, не найден. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. --- +## 6. Pairing через homeserver/ESP + +Новые `op`, относящиеся к этому сценарию: + +- `GetTrustedDeviceLoginSettings` +- `UpsertTrustedDeviceLoginSettings` +- `StartTrustedDeviceLogin` +- `ListTrustedDeviceLoginRequests` +- `ApproveTrustedDeviceLogin` +- `RejectTrustedDeviceLogin` +- `CancelTrustedDeviceLogin` +- `GetTrustedDeviceLoginStatus` + +В этом потоке: + +- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`; +- пароль проверяется сервером только как фильтр; +- решение об одобрении принимает уже авторизованная доверенная сессия пользователя; +- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей. + +Точные форматы этих операций см. в `03_Session_Management_API.md` и в протокольном документе: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +--- + ## 6. Пример ошибки ```json diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index ae98b9d..e5941e0 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -7,6 +7,20 @@ - `ListSessions` — получить список активных сессий пользователя; - `CloseActiveSession` — закрыть одну из активных сессий. +Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя: + +- `GetTrustedDeviceLoginSettings` +- `UpsertTrustedDeviceLoginSettings` +- `ListTrustedDeviceLoginRequests` +- `ApproveTrustedDeviceLogin` +- `RejectTrustedDeviceLogin` +- `CancelTrustedDeviceLogin` + +Анонимное новое устройство работает с двумя связанными операциями: + +- `StartTrustedDeviceLogin` +- `GetTrustedDeviceLoginStatus` + Логика раздела такая: - сначала пользователь проходит `SessionLogin`; @@ -42,6 +56,9 @@ "sessions": [ { "sessionId": "sess_7c5e5c4b", + "sessionType": 1, + "clientPlatform": "Web", + "onlineOnThisServer": true, "clientInfoFromClient": "Android 15; Pixel 9", "clientInfoFromRequest": "UA=Java-http-client/17.0.18; remote=127.0.0.1", "geo": "RU/Moscow", @@ -58,6 +75,20 @@ - `501 / DB_ERROR_LIST_SESSIONS` — ошибка БД при чтении списка активных сессий. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера. +### Поля одной сессии в `ListSessions` + +- `sessionId` — идентификатор активной сессии; +- `sessionType` — числовой код типа сессии: + - `1` — клиент; + - `50` — кошелёк; + - `100` — homeserver; +- `clientPlatform` — строка платформы, как её прислал клиент; +- `onlineOnThisServer` — `true`, если эта сессия сейчас держит живое WebSocket-подключение именно к данному серверу; +- `clientInfoFromClient` — краткая строка клиента; +- `clientInfoFromRequest` — строка, собранная сервером из запроса; +- `geo` — страна/город или fallback-строка; +- `lastAuthenticatedAtMs` — время последней успешной авторизации этой сессии. + --- ## 2. `CloseActiveSession` @@ -134,3 +165,320 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор. Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON. + +--- + +## 5. TrustedDeviceLogin через доверенную сессию + +Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя. + +### 5.1. `GetTrustedDeviceLoginSettings` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "GetTrustedDeviceLoginSettings", + "requestId": "trusted-login-get-001", + "payload": { + } +} +``` + +### Успешный ответ + +```json +{ + "op": "GetTrustedDeviceLoginSettings", + "requestId": "trusted-login-get-001", + "status": 200, + "ok": true, + "payload": { + "enabled": true, + "hasPassword": false + } +} +``` + +Если отдельной записи настроек на сервере ещё нет, сервер считает состояние по умолчанию таким: + +- `enabled = true` +- `hasPassword = false` + +### Ошибки + +- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. + +### 5.2. `UpsertTrustedDeviceLoginSettings` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "UpsertTrustedDeviceLoginSettings", + "requestId": "esp-set-001", + "payload": { + "enabled": true, + "passwordHash": "sha256$0123abcd..." + } +} +``` + +Если вход через доверенное устройство должен работать **без доп. пароля**, клиент включает его с пустым `passwordHash`. + +Если `enabled = false`, сервер автоматически удаляет пароль и запрещает вход через другое устройство. + +Формат непустого `passwordHash`: + +```text +sha256$ +``` + +### Успешный ответ + +```json +{ + "op": "UpsertTrustedDeviceLoginSettings", + "requestId": "esp-set-001", + "status": 200, + "ok": true, + "payload": { + "enabled": true, + "hasPassword": true + } +} +``` + +### Ошибки + +- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. + +### 5.3. `StartTrustedDeviceLogin` + +Эта операция доступна без уже существующей пользовательской сессии. + +### Запрос + +```json +{ + "op": "StartTrustedDeviceLogin", + "requestId": "esp-start-001", + "payload": { + "login": "alice", + "passwordHash": "sha256$0123abcd...", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1 + } +} +``` + +Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`. + +Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. + +TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам. + +### Успешный ответ + +```json +{ + "op": "StartTrustedDeviceLogin", + "requestId": "esp-start-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "created", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "expiresAtMs": 1781441990538, + "trustedSessionOnline": true + } +} +``` + +### Ошибки + +- `400 / EMPTY_LOGIN` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `400 / BAD_SESSION_TYPE` +- `400 / BAD_PAYLOAD_TYPE` +- `422 / PAIRING_NOT_AVAILABLE` +- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля. +- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. +- `429 / PAIRING_RATE_LIMITED` + +### 5.4. `ListTrustedDeviceLoginRequests` + +Доступно для любой уже авторизованной доверенной сессии пользователя. +Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают. + +### Успешный ответ + +```json +{ + "op": "ListTrustedDeviceLoginRequests", + "requestId": "esp-list-001", + "status": 200, + "ok": true, + "payload": { + "requests": [ + { + "pairingId": "base64url", + "state": "created", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1, + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "createdAtMs": 1781441810538, + "expiresAtMs": 1781441990538, + "deliveredToHomeserver": true + } + ] + } +} +``` + +### Ошибки + +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.5. `ApproveTrustedDeviceLogin` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "ApproveTrustedDeviceLogin", + "requestId": "esp-approve-001", + "payload": { + "pairingId": "base64url", + "encryptedPayload": "BASE64_OR_OTHER_OPAQUE_PAYLOAD" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "ApproveTrustedDeviceLogin", + "requestId": "esp-approve-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_ENCRYPTED_PAYLOAD` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_USER` +- `422 / PAIRING_NOT_PENDING` +- `422 / PAIRING_EXPIRED` +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.6. `RejectTrustedDeviceLogin` + +Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`. + +### 5.7. `GetTrustedDeviceLoginStatus` + +Операция для нового устройства. + +### Запрос + +```json +{ + "op": "GetTrustedDeviceLoginStatus", + "requestId": "esp-status-001", + "payload": { + "pairingId": "base64url" + } +} +``` + +### Успешный ответ после approve + +```json +{ + "op": "GetTrustedDeviceLoginStatus", + "requestId": "esp-status-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "payloadType": 1, + "encryptedPayload": "AQIDBA==", + "expiresAtMs": 1781441990538 + } +} +``` + +### Возможные `state` + +- `created` +- `approved` +- `rejected` +- `canceled` +- `expired` + +### 5.8. `CancelTrustedDeviceLogin` + +Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL. + +### Запрос + +```json +{ + "op": "CancelTrustedDeviceLogin", + "requestId": "esp-cancel-001", + "payload": { + "pairingId": "base64url", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "CancelTrustedDeviceLogin", + "requestId": "esp-cancel-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "canceled" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_REQUESTER` +- `422 / PAIRING_NOT_PENDING` diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 0033b48..95fa4d7 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -19,6 +19,14 @@ | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию | +| `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство | +| `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией | +| `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства | +| `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | +| `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | +| `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | +| `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства | +| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | @@ -54,5 +62,6 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md index a67c961..0cbac5e 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md @@ -1,8 +1,10 @@ # API для разработчиков: DM, push и сигналы звонков -Документ описывает WebSocket-операции для подписанных личных сообщений, WebPush и realtime-сигналов звонков. +Документ описывает публичные операции, связанные с личными сообщениями, WebPush и сигналами звонков. -Логика личных сообщений дополнительно описана в `Dev_Docs/Personal_Messages/README.md`; этот файл фиксирует именно публичные `op`, поля запросов и поля ответов. +Подробная логика DM и бинарного формата: + +- `Dev_Docs/Personal_Messages/README.md` ## 1. `UpsertPushToken` @@ -40,11 +42,9 @@ } ``` ---- - ## 2. `SendTestWebPush` -Требует авторизации. Если `login` передан, он должен совпадать с логином текущей сессии. +Требует авторизации. ### Запрос @@ -61,65 +61,18 @@ } ``` -### Успешный ответ +## 3. `SendMessagePair` и `ReceiveOutcomingMessage` -```json -{ - "op": "SendTestWebPush", - "requestId": "push-test-001", - "status": 200, - "ok": true, - "payload": { - "targetLogin": "alice", - "attemptedSessions": 1, - "sessionsWithPushConfig": 1, - "delivered": 1, - "failed": 0, - "sentAtMs": 1774700000123 - } -} -``` +`ReceiveOutcomingMessage` — алиас `SendMessagePair`. ---- +### Назначение -## 3. `SendDirectMessage` +Передаёт пару signed DM-блоков: -Отправляет один подписанный DM-пакет. +- `incomingBlobB64` — блок `type=1` или `type=3` +- `outgoingBlobB64` — блок `type=2` или `type=4` -### Запрос - -```json -{ - "op": "SendDirectMessage", - "requestId": "dm-001", - "payload": { - "blobB64": "BASE64_SIGNED_DM_PACKET" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "SendDirectMessage", - "requestId": "dm-001", - "status": 200, - "ok": true, - "payload": { - "messageId": "dm-1", - "deliveredWsSessions": 1, - "deliveredWebPushSessions": 0, - "sessionNotFound": false - } -} -``` - ---- - -## 4. `SendMessagePair` и `ReceiveOutcomingMessage` - -`ReceiveOutcomingMessage` сейчас является алиасом `SendMessagePair` и использует тот же request/handler. +Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`. ### Запрос @@ -143,20 +96,31 @@ "status": 200, "ok": true, "payload": { - "baseKey": "base-key", - "incomingKey": "incoming-key", - "outgoingKey": "outgoing-key", + "baseKey": "from|to|time|nonce", + "incomingKey": "from|to|time|nonce|1", + "outgoingKey": "from|to|time|nonce|2", "deliveredWsSessions": 1, "deliveredWebPushSessions": 0 } } ``` ---- +### Ошибки -## 5. `ReceiveIncomingMessage` +- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` +- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён +- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` +- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0` +- `404 / USER_NOT_FOUND` — один из логинов не найден +- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку -Принимает входящий подписанный DM-блок. +## 4. `ReceiveIncomingMessage` + +Принимает только один входящий signed DM-блок. + +### Назначение + +Используется там, где нужно принять только incoming-вариант сообщения. ### Запрос @@ -170,28 +134,9 @@ } ``` -### Успешный ответ +## 5. `AckSessionDelivery` -```json -{ - "op": "ReceiveIncomingMessage", - "requestId": "dm-in-001", - "status": 200, - "ok": true, - "payload": { - "messageKey": "incoming-key", - "baseKey": "base-key", - "deliveredWsSessions": 1, - "deliveredWebPushSessions": 0 - } -} -``` - ---- - -## 6. `AckSessionDelivery` - -Требует авторизации. Подтверждает доставку сообщения в текущую сессию. +Требует авторизации. Подтверждает доставку в текущую сессию. ### Запрос @@ -200,107 +145,46 @@ "op": "AckSessionDelivery", "requestId": "ack-001", "payload": { - "messageKey": "incoming-key" + "messageKey": "from|to|time|nonce|1" } } ``` -### Успешный ответ +## 6. Событие `SignedMessageArrived` + +Сервер присылает его по WebSocket в активные сессии адресата. + +### Payload события ```json { - "op": "AckSessionDelivery", - "requestId": "ack-001", - "status": 200, - "ok": true, - "payload": { - "messageKey": "incoming-key" - } + "messageKey": "from|to|time|nonce|1", + "baseKey": "from|to|time|nonce", + "fromLogin": "alice", + "toLogin": "bob", + "targetLogin": "bob", + "messageType": 1, + "timeMs": 1774700000123, + "nonce": 123456789, + "blobB64": "BASE64_SIGNED_BLOCK", + "backlog": false } ``` ---- +Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. ## 7. `CallInviteBroadcast` -Требует авторизации. Отправляет приглашение к звонку на активные сессии пользователя `toLogin`. - -### Запрос - -```json -{ - "op": "CallInviteBroadcast", - "requestId": "call-invite-001", - "payload": { - "toLogin": "bob", - "callId": "call-1", - "type": 100 - } -} -``` - -### Успешный ответ - -```json -{ - "op": "CallInviteBroadcast", - "requestId": "call-invite-001", - "status": 200, - "ok": true, - "payload": { - "callId": "call-1", - "deliveredWsSessions": 1, - "deliveredFcmSessions": 0, - "deliveredWebPushSessions": 0 - } -} -``` - ---- +Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. ## 8. `CallSignalToSession` -Требует авторизации. Отправляет сигнал звонка в конкретную сессию получателя. +Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -### Запрос +## 9. Замечания -```json -{ - "op": "CallSignalToSession", - "requestId": "call-signal-001", - "payload": { - "toLogin": "bob", - "targetSessionId": "SESSION_ID", - "callId": "call-1", - "type": 101, - "data": "{\"sdp\":\"...\"}" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "CallSignalToSession", - "requestId": "call-signal-001", - "status": 200, - "ok": true, - "payload": { - "delivered": true - } -} -``` - -Если целевая сессия не найдена или доставка не удалась, сервер может вернуть `404`. - -## Типовые ошибки - -- `422 / NOT_AUTHENTICATED` — требуется авторизация. -- `400 / BAD_FIELDS` — не заполнены обязательные поля. -- `404 / USER_NOT_FOUND` — пользователь не найден. -- `404 / SESSION_NOT_FOUND` — сессия не найдена. -- `422 / BAD_SIGNATURE` — подпись DM не прошла проверку. -- `422 / BAD_DEVICE_KEY` — некорректный device key отправителя. -- `422 / BAD_TIME_WINDOW` — время подписанного сообщения вне допустимого окна. -- `422 / REPLAY` — повторное сообщение заблокировано. +- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2` +- контентные DM `type=1/2` используют `SHiNE_DM` +- сервер хранит только последнюю версию контентного сообщения по `messageKey` +- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0` +- HTTP endpoints для DM-файлов сейчас отсутствуют diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index e81b0e3..f841e89 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -37,7 +37,7 @@ - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. -- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи. +- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. ### DAO-запуск diff --git a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md similarity index 81% rename from Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md rename to Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md index 46bd937..b8f92d3 100644 --- a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_саб_серверы_в_pda.md +++ b/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md @@ -1,4 +1,4 @@ -# Сессионные саб-серверы в PDA пользователя +# Сессионные homeserver-ы в PDA пользователя - Статус: `future` @@ -10,15 +10,15 @@ после завершения первого этапа по пользовательским сессиям - Основание: - Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. + Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. ## Зачем нужна фича -У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. +У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. Это нужно, чтобы: -- хранить несколько саб-серверов у одного пользователя одновременно; +- хранить несколько homeserver-ов у одного пользователя одновременно; - различать обычные клиентские сессии и серверные сессии по явному типу; - дать расширяемый формат записи с версией; - использовать единый подход для DM, звонков и внутренних команд между сессиями. @@ -35,18 +35,18 @@ Предварительные значения: - тип `1` - обычная пользовательская сессия; -- тип `100` - саб-сервер пользователя; +- тип `100` - homeserver пользователя; - версия `1` - первая рабочая версия формата записи сессии. На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`. -Важно: саб-серверов у одного пользователя может быть несколько. +Важно: homeserver-ов у одного пользователя может быть несколько. ## Архитектурный принцип Внутренний протокол взаимодействия должен оставаться транспортным. -То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен: +То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен: - доставлять сообщения между сессиями; - доставлять сигналы звонков между сессиями; @@ -60,7 +60,7 @@ - Вызов звонка уже рассылается по нескольким активным сессиям пользователя. - Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя. -Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера". +Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а". ## Что нужно сделать при возврате к задаче @@ -77,7 +77,7 @@ - правила удаления и обновления записи; - правила ротации `sessionPubKey`. 6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`. -7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. +7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. 8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов. 9. После реализации обязательно обновить документацию. @@ -101,5 +101,5 @@ Продолжать после завершения первой части: 1. описать минимальный формат записи пользовательской сессии; -2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии; +2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии; 3. затем уже проектировать операции регистрации, обновления и отключения таких сессий. diff --git a/Dev_Docs/Keys/DERIVATION.md b/Dev_Docs/Keys/DERIVATION.md new file mode 100644 index 0000000..b342522 --- /dev/null +++ b/Dev_Docs/Keys/DERIVATION.md @@ -0,0 +1,154 @@ +# Деривация секрета и ключей SHiNE (формулы) + +> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.** +> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся +> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт. +> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула +> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь +> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. +> Роли и назначение ключей описаны отдельно в `Dev_Docs/Keys/README.md` (архитектура). +> Здесь — только механика. Документ намеренно краткий. + +--- + +## 1. Секрет (masterSecret) + +`masterSecret` — 32 байта. Два источника: + +**А. Из пароля пользователя (основной путь, UI).** + +``` +login = trim(lowercase(login)) +salt = SHA-256("shine-auth-v2|login=" + login + "|suffix=master.secret")[0..16) // первые 16 байт +material = utf8(login + "\n" + password) +masterSecret(32) = Argon2id(material, salt, t=2, m=65536 KiB, p=1, dkLen=32) +``` + +- Параметры Argon2id фиксированы: `t=2`, `m=65536` (64 МиБ), `p=1`, `dkLen=32`. +- Логин входит и в соль, и в начало `material` (склейка через `\n`). +- Пустой пароль **запрещён**: легаси-fallback без Argon2 удалён, `deriveMasterSecretFromPassword` бросает ошибку на пустом пароле, а форма регистрации в UI блокирует пустой пароль (`register-view.js`). + +**Б. Случайный (прошивка ESP32, новый аккаунт без пароля).** + +``` +masterSecret(32) = 32 случайных байта (esp_random) // хранится на устройстве как base58 +``` + +Дальше деривация ключей одинакова независимо от источника секрета. + +--- + +## 2. Производные ключи + +Все ключи выводятся из `masterSecret` по **одной формуле**, отличается только суффикс: + +``` +material = base64_std(masterSecret) + "|" + <суффикс> +seed(32) = SHA-256(material) +(pub, priv) = Ed25519_keypair_from_seed(seed) +``` + +- `base64_std` — стандартный base64 (не url-safe). +- Разделитель — символ `|`. +- Суффиксы значимы байт-в-байт (регистр и точки важны). + +| Ключ | Суффикс | Назначение (кратко) | +|------|---------|---------------------| +| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | +| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | +| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. | +| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | + +Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`. + +--- + +## 3. Solana-ключ + +Отдельного «солана-ключа» нет. На Solana работают два ключа: + +- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`. + Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. + Пополнять SOL нужно именно на этот адрес. +- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. + +Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 +(«create/update оплачиваются с `device_key`», «root_key — не fee payer»). + +Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью +(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый +кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`. + +--- + +## 4. Ключи homeserver + +У пользователя может быть несколько homeserver-ов. Каждый имеет **своё имя** и **свой приватный ключ**, +выведенный из секрета по той же формуле с именованным суффиксом: + +``` +suffix = "homeserver.key:" + <имя homeserver> // имя по умолчанию: "homeserver1" +material = base64_std(masterSecret) + "|" + suffix +seed(32) = SHA-256(material) +(pub, priv) = Ed25519_keypair_from_seed(seed) +``` + +Пример для двух homeserver-ов: + +``` +homeserver.key:home-a -> ключ A +homeserver.key:home-b -> ключ B +``` + +Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как +`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13). + +> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`. +> Термин «саб-сервер» по проекту заменяется на «homeserver». + +--- + +## 5. Где это в коде + +### Деривация секрета и ключей (UI, каноническая) +- `shine-UI/js/services/crypto-utils.js` + - секрет из пароля: `makeArgon2Salt`, `deriveMasterSecretArgon2id`, `deriveMasterSecretFromPassword` (~129–218); + - ключ из секрета: `deriveEd25519FromMasterSecret` (~220). +- `shine-UI/js/services/auth-service.js` — набор root/bch/dev из `masterSecret` (~732–758). +- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). + +### Solana-ключ / адрес кошелька (UI) +- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113). +- `shine-UI/js/pages/topup-view.js` — `deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`. + Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён. + +### Деривация ключей (прошивка ESP32) +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` + - основной скетч ESP32-проекта `SHiNE`; `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829); + - регистрация/подпись Solana: `registerHomeserverOnSolana` (~1182), `signMessageEd25519` (~1147). +- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino` + - старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики. + +### Формат PDA (куда попадают ключи) +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` + — `RootKeyBlock` §6, `DeviceKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. + +### Сервер (тестовый seed) +- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) — + выводит ключи как `Ed25519(SHA-256(base64(SHA-256(password)) + suffix))`, **без** Argon2 и **без** разделителя `|`. + Это **не баг**, а точное повторение легаси-пути UI `derivePasswordSeed` (для пустого пароля), у которого тоже нет `|`. + С современным путём `masterSecret`-bundle (Argon2 + `base64(secret)|suffix`) он **не совпадает** by design. + Если потребуется, чтобы seed совпадал с реальными клиентами на Argon2 — нужно отдельно портировать + Argon2id+masterSecret в Java (на сервере Argon2 сейчас нет). Простое добавление `|` было бы **неверным**: + сломало бы совпадение с легаси-путём и всё равно не дало бы совпадения с Argon2-путём. + +--- + +## 6. Правило синхронизации (обязательно) + +1. Этот документ — источник истины по деривации секрета и ключей. +2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа, + разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес — + **обязательно** отражать здесь в том же изменении. +3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма. +4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости. diff --git a/Dev_Docs/Keys/README.md b/Dev_Docs/Keys/README.md index 0e12835..863a9da 100644 --- a/Dev_Docs/Keys/README.md +++ b/Dev_Docs/Keys/README.md @@ -8,7 +8,7 @@ В SHiNE у пользователя есть несколько уровней ключей: -- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ. +- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`). - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. @@ -28,9 +28,9 @@ - управление остальными ключами; - подтверждение операций, которые должны иметь максимальный уровень доверия. -В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя. +`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. -На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`. +Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. ## `blockchain key` @@ -158,6 +158,7 @@ Self-message - это сообщение пользователя самому ## Связанные документы +- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. diff --git a/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md b/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md deleted file mode 100644 index 06596de..0000000 --- a/Dev_Docs/Pending_Features/2026-05-28_0020_wallet_shine_blockchain_limit_sync.md +++ /dev/null @@ -1,43 +0,0 @@ -# Кошелёк: лимит/закрепление блокчейна Сияния - -- статус: `pending` - -## Кратко что сделано - -- На экране `Кошелёк -> Блокчейн Сияния` добавлены 2 слоя данных: - - фактическое состояние цепочки на сервере (`кол-во блоков`, `размер`, `крайний блок`, `hash`, `размер крайнего блока`); - - закреплённое состояние в Solana PDA (`лимит`, `использовано`, `остаток`, `крайний блок`, `hash`). -- Добавлены действия: - - `Закрепить в Solana` — обновляет PDA до текущего состояния серверной цепочки; - - `Увеличить лимит` — увеличивает `paid_limit_bytes` в PDA с учётом цены из economy PDA. -- Если `rootKey`/`blockchainKey` не сохранены локально, экран запрашивает пароль, восстанавливает ключи через стандартную derivation-логику и предлагает сохранить их в зашифрованный контейнер. - -## Что проверять вручную - -1. Открыть `Кошелёк -> Блокчейн Сияния` под авторизованным пользователем. -2. Проверить, что в блоке "Фактическое состояние на сервере" отображаются: - - число блоков; - - размер цепочки; - - номер/хэш крайнего блока; - - размер крайнего блока. -3. Проверить, что в блоке "Закреплено в Solana" отображаются: - - лимит; - - израсходовано; - - остаток; - - номер/хэш крайнего закреплённого блока. -4. Нажать `Закрепить в Solana` и убедиться, что: - - приходит успешная транзакция; - - после обновления Solana-показатели подтягиваются до серверных (или максимально близко по актуальному состоянию). -5. Нажать `Увеличить лимит`, ввести значение кратное шагу, подтвердить списание и проверить: - - лимит увеличился; - - отображение цены/списания соответствует economy PDA. -6. Повторить пункты 4-5 в сценарии, когда `rootKey`/`blockchainKey` не сохранены, и проверить: - - появляется запрос пароля; - - после ввода пароля операции выполняются; - - предложение сохранить ключи показывается. - -## Ожидаемый результат - -- Экран корректно разделяет "фактическое состояние на сервере" и "закреплённое в Solana". -- Обе операции (`Закрепить в Solana`, `Увеличить лимит`) выполняются без ошибок при валидных данных. -- Восстановление ключей через пароль работает, а без нужных ключей операция не выполняется молча. diff --git a/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md b/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md deleted file mode 100644 index b85cc6b..0000000 --- a/Dev_Docs/Pending_Features/2026-05-29_2255_озвучивание_ответов_агента.md +++ /dev/null @@ -1,29 +0,0 @@ -# Озвучивание ответов агента - -## Что сделано - -В локальный Telegram-бот-сервис агента-кодера добавлены персональные настройки озвучивания финальных ответов: - -- `/voice_on` включает озвучивание для текущего Telegram-пользователя; -- `/voice_off` выключает озвучивание для текущего Telegram-пользователя; -- `/voice_status` показывает текущее состояние; -- если озвучивание включено, после текстового финального ответа сервис генерирует voice-файл через OpenAI TTS и отправляет его в Telegram; -- длинные ответы делятся на несколько фрагментов озвучки. - -## Что проверять - -1. Перезапустить `shine-agent-bot-coder`. -2. Отправить `/voice_status` и убедиться, что по умолчанию озвучивание выключено. -3. Отправить `/voice_on`. -4. Дать простую задачу агенту и проверить, что пришёл полный текстовый ответ и voice-файл с тем же ответом. -5. Отправить `/voice_off`. -6. Дать ещё одну простую задачу и проверить, что приходит только текст. -7. При возможности проверить второго whitelist-пользователя: его настройка должна быть независимой. - -## Ожидаемый результат - -Настройка хранится персонально по username и сохраняется после перезапуска сервиса. При включённой настройке Telegram получает текстовый ответ и дополнительное voice-сообщение с озвучкой. При выключенной настройке поведение остаётся прежним. - -## Статус - -pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md b/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md deleted file mode 100644 index e1c8c9b..0000000 --- a/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md +++ /dev/null @@ -1,19 +0,0 @@ -# Голосовая адаптация ответов Telegram-бота - -## Краткое описание -Добавлены персональные настройки голосовых ответов и адаптации текста перед озвучкой. Если голосовые ответы включены, сервис перед TTS может отдельно прогонять финальный текст через OpenAI-модель и отправлять более короткую голосовую версию в исходный чат, личный чат пользователя и общий чат `@shine_writing`, если эти чаты доступны и отличаются. - -## Что проверить -- Команды `/voice_on`, `/voice_off`, `/voice_status` для конкретного пользователя. -- Команды `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status` для конкретного пользователя. -- Команда `/status` показывает очередь, голосовые ответы и адаптацию текста перед озвучкой. -- При включённых голосовых ответах после задачи приходит текстовый ответ и voice-ответ. -- При включённой адаптации voice-ответ короче и без длинных технических строк. -- При задаче из личного чата voice дополнительно появляется в общем чате `@shine_writing`. -- При задаче из общего чата voice дополнительно появляется в личном чате пользователя, если сервис уже знает его личный chat_id. - -## Ожидаемый результат -Текстовый ответ остаётся полным. Голосовая версия приходит отдельно, звучит короче и естественнее, а персональные настройки одного пользователя не меняют поведение других пользователей. - -## Статус -pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_1015_understand-anything-lab.md b/Dev_Docs/Pending_Features/2026-05-30_1015_understand-anything-lab.md deleted file mode 100644 index 05fd282..0000000 --- a/Dev_Docs/Pending_Features/2026-05-30_1015_understand-anything-lab.md +++ /dev/null @@ -1,24 +0,0 @@ -# Эксперимент Understand Anything - -## Краткое описание - -Добавлена изолированная лаборатория для проверки `Lum1104/Understand-Anything` без подключения к сборке, деплою и рабочему коду SHiNE. - -## Что проверять - -- Установить Node.js 22+ и pnpm 10+. -- Запустить `./tools/understand-anything-lab/install_codex_skills.sh`. -- Перезапустить Codex-сессию. -- Выполнить `/understand --language ru` в корне проекта. -- После генерации выполнить `/understand-dashboard` и проверить, что граф открывается и помогает ориентироваться по серверным, UI, Solana и агентским папкам. - -## Ожидаемый результат - -- В проекте появляется локальная папка `.understand-anything/` с графом знаний. -- Dashboard открывается и показывает интерактивный граф проекта. -- Основные процессы сборки и деплоя SHiNE не меняются. - -## Статус - -`pending` - diff --git a/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md b/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md deleted file mode 100644 index 9b8d539..0000000 --- a/Dev_Docs/Pending_Features/2026-05-30_1756_центр_задач_telegram_агента.md +++ /dev/null @@ -1,24 +0,0 @@ -# Центр задач Telegram-агента - -## Краткое описание - -Добавлена первая версия центра задач и предложений внутри `SHiNE-agent-bot-coder`. - -Бот хранит задачи и предложения в JSON-файле данных сервиса, умеет показывать список через `/tasks`, создавать задачи для игроков по фразе Айдара, принимать предложения игроков по префиксу `предложение:`, менять статусы и добавлять короткие напоминания после ответов. - -## Что проверять - -- Айдар пишет `/tasks` и видит текущий список задач и предложений без уже закрытого предложения от Димы. -- Айдар пишет `поставь задачу Милане: проверить описание SHiNE` и задача появляется в списке Миланы. -- Милана пишет `/tasks` и видит назначенную задачу. -- Игрок пишет `предложение: ...`, после чего предложение появляется у Айдара. -- Айдар меняет статус фразами вида `одобрить TC-XXXX`, `доработать TC-XXXX`, `закрыть TC-XXXX`, где `TC-XXXX` - ID существующей задачи или предложения. -- После обычного ответа бота Айдару или игроку появляется короткое напоминание, если у пользователя есть активные задачи. - -## Ожидаемый результат - -Задачи и предложения сохраняются между перезапусками сервиса, статусы меняются корректно, напоминания не мешают основному ответу Codex. - -## Статус - -pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md b/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md deleted file mode 100644 index 6c38c59..0000000 --- a/Dev_Docs/Pending_Features/2026-05-30_1807_рестарты_и_voice_telegram_агента.md +++ /dev/null @@ -1,31 +0,0 @@ -# Рестарты и voice-настройки Telegram-агента - -## Краткое описание - -Добавлена первая версия безопасного рестарта Telegram-агента: - -- `/restart` и `/restart_service` ставят отложенный рестарт после текущей задачи и до взятия следующей; -- `/restart_hard`, `/restart_now`, `/restart_force` выполняют жёсткий рестарт сразу; -- команды рестарта доступны только Айдару; -- voice-ответы включены по умолчанию для новых пользователей; -- адаптация текста перед озвучкой стала ближе к исходному ответу и не должна менять смысл; -- скрыты отдельные команды статуса voice-функций из справки, состояние показывается через `/status`. - -## Что проверить - -1. Отправить `/restart` во время активной задачи игрока или Айдара. -2. Убедиться, что активная задача завершается, после чего сервис перезапускается до следующей задачи. -3. Отправить `/restart_hard` и убедиться, что сервис перезапускается сразу. -4. Проверить, что игрок не может выполнить команды рестарта. -5. Проверить `/status`: он показывает очередь и состояния голосовых функций. -6. Проверить нового пользователя: voice-ответы должны быть включены по умолчанию. -7. Проверить текстовый запрос пользователя с включённым voice: после текстового ответа должен прийти voice-файл. -8. Проверить, что адаптированная озвучка не превращается в другой ответ, а только убирает длинные технические строки. - -## Ожидаемый результат - -Сервис можно обновлять без потери текущей задачи через отложенный рестарт. Жёсткий рестарт остаётся аварийной командой Айдара. Voice-ответы работают для текстовых и голосовых запросов, а голосовая версия остаётся близкой к текстовой. - -## Статус - -pending diff --git a/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md b/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md deleted file mode 100644 index da25e36..0000000 --- a/Dev_Docs/Pending_Features/2026-05-30_1907_кнопки_вкладки_каналы.md +++ /dev/null @@ -1,26 +0,0 @@ -# Кнопки вкладки «Каналы» - -## Что сделано - -Доработана верхняя панель вкладки «Каналы»: -- при открытии нижней кнопкой «Каналы» показывается режим «Все каналы»; -- в режиме «Все каналы» справа доступны кнопка «Мои каналы» и иконка поиска канала; -- в режиме «Мои каналы» доступен переход обратно во «Все каналы», а справа показывается плюсик создания канала. - -## Что проверить - -1. Открыть вкладку «Каналы» через нижнюю навигацию. -2. Убедиться, что открыт режим «Все каналы», а плюсик создания канала не отображается. -3. Нажать иконку поиска в режиме «Все каналы». -4. Убедиться, что открывается текущий сценарий поиска каналов. -5. Нажать «Мои каналы». -6. Убедиться, что справа появился плюсик создания канала. -7. Нажать «Все каналы» или стрелку назад и проверить возврат к режиму «Все каналы». - -## Ожидаемый результат - -Кнопки верхней панели соответствуют активному режиму: поиск в «Все каналы», создание только в «Мои каналы». - -## Статус - -pending diff --git a/Dev_Docs/Pending_Features/2026-06-03_0013_длинные_voice_audio_telegram_бота.md b/Dev_Docs/Pending_Features/2026-06-03_0013_длинные_voice_audio_telegram_бота.md deleted file mode 100644 index 5ec4a9c..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_0013_длинные_voice_audio_telegram_бота.md +++ /dev/null @@ -1,13 +0,0 @@ -# Длинные voice/audio в Telegram-боте агента - -- краткое описание фичи: - Бот теперь умеет обрабатывать длинные voice/audio аккуратнее: учитывает лимит Telegram Bot API на скачивание слишком больших файлов, поддерживает альтернативный `TELEGRAM_API_BASE_URL` для локального `telegram-bot-api`, локально пережимает длинное аудио через `ffmpeg`, режет на куски и отправляет их в OpenAI transcription последовательно. -- что именно проверять: - 1. Короткий `voice` по-прежнему распознаётся без заметной задержки. - 2. Длинный `audio/voice`, который помещается в скачивание Telegram, успешно пережимается, режется на части и даёт цельную расшифровку. - 3. Очень большой файл через обычный `https://api.telegram.org` даёт понятное сообщение про лимит Telegram. - 4. После переключения на локальный `telegram-bot-api` такой же большой файл начинает скачиваться и распознаваться. -- ожидаемый результат: - Бот не падает на длинных аудио, даёт либо расшифровку, либо понятное объяснение, какой именно лимит мешает и что нужно включить. -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-03_0040_диагностика_больших_voice_audio.md b/Dev_Docs/Pending_Features/2026-06-03_0040_диагностика_больших_voice_audio.md deleted file mode 100644 index 8cea128..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_0040_диагностика_больших_voice_audio.md +++ /dev/null @@ -1,14 +0,0 @@ -# Диагностика больших voice/audio в Telegram-боте - -- краткое описание фичи: - - Бот при большом voice/audio больше не отказывается заранее по метаданным Telegram. Теперь он сначала сообщает, что пробует скачать файл, затем отдельно сообщает об успешном скачивании и только после этого переходит к подготовке аудио и распознаванию через OpenAI. -- что именно проверять: - - Отправить в бота большой `voice` или `audio`, который раньше попадал под ранний отказ. - - Проверить, что сначала приходит сообщение о попытке скачать большой файл. - - Проверить два сценария: - - скачивание удалось: бот пишет об успешной загрузке и продолжает распознавание; - - скачивание не удалось: бот пишет именно о неудачном скачивании из Telegram, без ложной привязки к ошибке OpenAI. -- ожидаемый результат: - - Пользователь видит понятную поэтапную диагностику: попытка скачивания, результат скачивания и только потом следующий этап обработки. -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md b/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md deleted file mode 100644 index c3dc4bc..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md +++ /dev/null @@ -1,24 +0,0 @@ -# Перенос server UI в shine-UI - -- краткое описание фичи: - Веб-панель управления серверной Solana PDA перенесена в `shine-UI/` как отдельные страницы. - Новая точка входа: `shine-UI/server-ui.html`. - Общая логика работы с PDA вынесена в единый модуль `shine-UI/js/services/shine-user-pda-service.js`. - -- что именно проверять: - 1. Открытие `shine-UI/server-ui.html` и переходы на страницы создания и обновления PDA. - 2. Генерацию ключей из логина и пароля на странице создания. - 3. Ручной ввод base58-ключей и регистрацию серверного PDA. - 4. Загрузку существующей серверной PDA на странице обновления. - 5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа. - 6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль. - 7. То, что актуальной точкой входа остаётся `shine-UI/server-ui.html`. - -- ожидаемый результат: - 1. Новые страницы открываются без JS-ошибок. - 2. Создание серверной PDA проходит через общий модуль и пишет актуальный формат. - 3. Обновление серверной PDA переиспользует существующую подпись LastBlockState и не требует blockchain-ключ. - 4. Клиентский UI не ломается после перевода общего PDA-слоя на новый формат. - -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-03_1521_кнопка_настроить_сервер_и_devnet_topup.md b/Dev_Docs/Pending_Features/2026-06-03_1521_кнопка_настроить_сервер_и_devnet_topup.md deleted file mode 100644 index b23d816..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_1521_кнопка_настроить_сервер_и_devnet_topup.md +++ /dev/null @@ -1,20 +0,0 @@ -# Кнопка настройки сервера и DEVNET topup - -- краткое описание фичи: - На экране `entry-settings-view` добавлена кнопка `Настроить свой сервер`, открывающая `server-ui.html` в новой вкладке. - На страницах серверного UI добавлена кнопка открытия `devnet-topup-view` в новой вкладке с автоматической передачей `wallet` из device-адреса. - -- что именно проверять: - 1. На странице настроек входа есть кнопка `Настроить свой сервер`. - 2. Кнопка открывает `shine-UI/server-ui.html` в новой вкладке. - 3. На страницах `create-server-pda.html` и `update-server-pda.html` есть кнопка `Открыть пополнение DEVNET`. - 4. Если device public key заполнен, новая вкладка открывает `devnet-topup-view?wallet=...` с правильным адресом. - 5. Если device-адрес не введён, серверный UI показывает понятную ошибку и не открывает пустую ссылку. - -- ожидаемый результат: - 1. Переход в серверный UI с клиентской страницы настроек работает. - 2. Пополнение devnet из серверного UI открывается сразу на нужный адрес. - 3. Основной клиентский UI и серверные страницы не получают JS-ошибок при загрузке. - -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-03_1610_fix_devnet_topup_и_пароль_autofill.md b/Dev_Docs/Pending_Features/2026-06-03_1610_fix_devnet_topup_и_пароль_autofill.md deleted file mode 100644 index 2a54d1c..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_1610_fix_devnet_topup_и_пароль_autofill.md +++ /dev/null @@ -1,15 +0,0 @@ -# Фикс DEVNET topup и автоподстановки пароля - -- статус: pending -- кратко: исправлена ширина экрана `devnet-topup-view` после успешного пополнения и отключена нежелательная автоподстановка пароля в server UI и на экранах входа/регистрации. - -## Что проверять -- Открыть страницу пополнения DEVNET, выполнить пополнение и убедиться, что после появления `Signature` экран не расширяется по ширине. -- Проверить, что кнопки на странице пополнения остаются аккуратными и не разъезжаются. -- Открыть `server-ui/update-server-pda.html`, загрузить PDA и убедиться, что поле пароля остаётся пустым. -- Проверить обычные экраны входа и регистрации: поле пароля не должно самопроизвольно заполняться длинной строкой. - -## Ожидаемый результат -- Длинная transaction signature переносится по строкам внутри прежней ширины экрана. -- Кнопки сохраняют компактный mobile-first layout. -- Поля пароля пустые, пока пользователь сам ничего не вводил. diff --git a/Dev_Docs/Pending_Features/2026-06-03_1648_диагностика_server_pda_и_device_balance.md b/Dev_Docs/Pending_Features/2026-06-03_1648_диагностика_server_pda_и_device_balance.md deleted file mode 100644 index 4cabd96..0000000 --- a/Dev_Docs/Pending_Features/2026-06-03_1648_диагностика_server_pda_и_device_balance.md +++ /dev/null @@ -1,17 +0,0 @@ -# Диагностика ключей server PDA и баланс device - -- статус: pending -- кратко: на странице обновления server PDA добавлена сверка ожидаемых ключей с уже загруженной PDA, предупреждение о неверном пароле, кнопка показа баланса device-аккаунта и уточнение, что create/update оплачиваются с deviceKey. - -## Что проверять -- На `update-server-pda.html` загрузить существующую PDA и убедиться, что видны ожидаемые `root/blockchain/device` public key. -- Ввести правильный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи совпадают. -- Ввести неверный пароль и нажать `Сгенерировать`: должно появиться сообщение, что ключи не совпали и пароль, вероятно, неверный. -- На `create-server-pda.html` и `update-server-pda.html` нажать `Показать / обновить баланс device` и убедиться, что баланс читается по текущему `devPub`. -- Повторить `update_user_pda` после увеличения `heap frame` и проверить, ушла ли ошибка `memory allocation failed`. - -## Ожидаемый результат -- Пользователь видит, какие именно public key должны получиться для загруженной PDA. -- Ошибка неправильного пароля выявляется до отправки транзакции. -- Баланс device-кошелька читается прямо со страницы. -- Если проблема `OOM` была только в размере heap frame/compute budget клиента, `update_user_pda` начинает проходить. diff --git a/Dev_Docs/Pending_Features/2026-06-04_1433_lazy_import_solana_pda_актуальный_формат.md b/Dev_Docs/Pending_Features/2026-06-04_1433_lazy_import_solana_pda_актуальный_формат.md deleted file mode 100644 index 817e5c0..0000000 --- a/Dev_Docs/Pending_Features/2026-06-04_1433_lazy_import_solana_pda_актуальный_формат.md +++ /dev/null @@ -1,15 +0,0 @@ -# Lazy-import Solana PDA: актуальный формат - -- Краткое описание: - Серверный Java lazy-import пользователя из `shine_users` обновлён под актуальный формат `user_pda`. Убран RPC-фильтр по размеру PDA, добавлен разбор нового `ServerProfileBlock` (`block_type = 30`) без сохранения server-only полей в `solana_users`. -- Что проверять: - 1. Взять логин пользователя, который существует в Solana PDA, но отсутствует в локальной таблице `solana_users`. - 2. Выполнить вход этим логином через сервер. - 3. Убедиться, что lazy-import подтянул пользователя из Solana. - 4. Убедиться, что запись в `solana_users` создана с полями `login`, `blockchain_name`, `solana_key`, `blockchain_key`, `device_key`. - 5. Убедиться, что отсутствие/наличие server-полей в PDA не ломает импорт. -- Ожидаемый результат: - 1. Пользователь успешно находится и импортируется из Solana PDA независимо от фактического размера PDA. - 2. Новый `ServerProfileBlock` не ломает парсер. - 3. В БД не появляются лишние server-only поля. -- Статус: `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-04_2315_pure_rust_solana_users_and_login_guard.md b/Dev_Docs/Pending_Features/2026-06-04_2315_pure_rust_solana_users_and_login_guard.md deleted file mode 100644 index 5d3fd19..0000000 --- a/Dev_Docs/Pending_Features/2026-06-04_2315_pure_rust_solana_users_and_login_guard.md +++ /dev/null @@ -1,33 +0,0 @@ -# Pure Rust `shine_users` и `shine_login_guard` - -Статус: `pending` - -## Что сделано - -- `shine_login_guard` переписан без Anchor на чистый Rust/Solana SDK. -- `shine_users` переписан без Anchor на чистый Rust/Solana SDK. -- Для `shine_users` введён новый instruction ABI без Anchor discriminator'ов. -- Для `shine_users` используются новые seed'ы: - - `user_login=` для `user_pda` - - `shine_users_economy_config` для economy PDA -- Формат блоков PDA синхронизирован: - - `SessionsBlock = 50` - - `TrustedStateBlock = 70` -- UI JS-модуль и Java lazy-import обновлены под новые seeds/ABI/коды блоков. - -## Что проверить руками - -1. В обычном UI выполнить регистрацию нового пользователя в Solana. -2. Проверить, что после регистрации читается новая `user_pda`. -3. В server UI выполнить создание server PDA. -4. В server UI выполнить update server PDA. -5. Проверить, что после update растёт `record_number`. -6. Проверить, что lazy-import на сервере читает новый формат PDA без ошибок. -7. Проверить, что старые Anchor discriminator'ы больше нигде не требуются. - -## Ожидаемый результат - -- Регистрация и update работают на новых чисто-rust программах. -- UI не использует старый Anchor ABI. -- Серверный Java parser читает новый формат PDA. -- Ошибок `out of memory` и anchor-specific падений больше нет. 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 deleted file mode 100644 index fb9357d..0000000 --- a/Dev_Docs/Pending_Features/2026-06-05_1240_esp32_argon2_ui_совместимость.md +++ /dev/null @@ -1,25 +0,0 @@ -# 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 deleted file mode 100644 index 54c18d2..0000000 --- a/Dev_Docs/Pending_Features/2026-06-05_1735_редактируемый_статус_telegram_бота.md +++ /dev/null @@ -1,17 +0,0 @@ -## Краткое описание -В `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 deleted file mode 100644 index ca2e0eb..0000000 --- a/Dev_Docs/Pending_Features/2026-06-06_1324_settings_telegram_агента.md +++ /dev/null @@ -1,24 +0,0 @@ -## Краткое описание -В локальный 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/Dev_Docs/Pending_Features/2026-06-06_1659_shine_payments_e2e_перепись_и_q3.md b/Dev_Docs/Pending_Features/2026-06-06_1659_shine_payments_e2e_перепись_и_q3.md deleted file mode 100644 index 1436bc6..0000000 --- a/Dev_Docs/Pending_Features/2026-06-06_1659_shine_payments_e2e_перепись_и_q3.md +++ /dev/null @@ -1,210 +0,0 @@ -# Shine Payments: e2e после переписи без Anchor и добавления Q3 - -## Краткое описание - -Нужно вручную и через вспомогательные CLI-проверки подтвердить, что программа `shine_payments` после: - -- переписи на чистый `solana_program`; -- отказа от `programs/common`; -- добавления очереди `Q3`; -- обновления HTML UI; - -корректно работает на devnet с новым `program id`. - -Отличие от финального боевого сценария: - -- вместо DAO-механики используется обычный кошелёк `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P`, которому даны права DAO на изменение коэффициента и выдачу лимитов менеджеру. - -## Что именно проверять - -### 1. Подготовка окружения - -Проверить и зафиксировать: - -- новый keypair программы `shine_payments`; -- новый `program id`; -- обновление `program id` в HTML UI и связанных настройках; -- наличие deploy authority, которой можно закрыть старый buffer/programdata, если это технически доступно; -- адреса тестовых кошельков: - - DAO/базовый кошелёк; - - менеджер; - - покупатель 1; - - покупатель 2; - - получатели выплат. - -### 2. Очистка/смена старой программы - -Проверить один из сценариев: - -- если возможно, закрыть старый `program buffer/programdata` текущими ключами; -- если закрытие невозможно или нецелесообразно, зафиксировать это и продолжить с новым `program id`. - -Отдельно проверить, что старые PDA предыдущей версии не используются новой программой. - -### 3. Деплой и init новой программы - -Проверить: - -- `cargo build-sbf` проходит; -- новая программа деплоится на devnet; -- `init` выполняется один раз на пустых PDA; -- после `init` читаются: - - `config`; - - `coef_limit`; - - `queues`; - - `inflow_vault`. - -Сразу после `init` запросить состояние очередей и зафиксировать, что: - -- `Q1`, `Q2`, `Q3` пустые; -- `tickets_total = 0`; -- `tickets_paid = 0`; -- все суммы равны `0`. - -### 4. Проверка покупки билета - -На минимальных суммах проверить: - -1. покупку через `buy_ticket_usd`; -2. покупку через `buy_ticket_sol`; -3. при необходимости ещё один вызов базового `buy_ticket`. - -После каждой покупки: - -- запросить состояние `Q1`; -- убедиться, что создался следующий ticket; -- проверить рост: - - `q1_tickets_total`; - - `q1_sum_total_usd_cents`; -- убедиться, что деньги покупки ушли в `dao_wallet`, а не в `inflow_vault`. - -### 5. Проверка DAO-управления - -Проверить: - -1. изменение коэффициента через `update_coef_limit`; -2. повторный запрос `coef_limit` и подтверждение нового значения; -3. выдачу менеджеру прав через `grant_manager_limits`: - - отдельно под `Q1`; - - отдельно под `Q2`; - - отдельно под `Q3`. - -После выдачи лимитов: - -- считать `manager_allowance_pda`; -- убедиться, что лимиты записаны отдельно по трём очередям. - -### 6. Проверка manager_add_ticket - -На минимальных суммах создать менеджерские тикеты: - -1. один ticket в `Q1`; -2. один ticket в `Q2`; -3. один ticket в `Q3`. - -После каждого добавления: - -- запросить состояние очередей; -- проверить рост счётчиков и сумм именно у нужной очереди; -- проверить уменьшение соответствующего manager allowance. - -### 7. Проверка приоритета очередей - -Подтвердить очередность `step_payout`: - -1. сначала выплачивается `Q1`; -2. затем `Q2`; -3. затем `Q3`. - -Для этого: - -- между шагами регулярно читать `queues`; -- фиксировать, какой именно ticket был следующим к выплате; -- убедиться, что при наличии pending в `Q1` программа не уходит в `Q2` или `Q3`. - -### 8. Проверка частичных выплат - -Перед выплатами пополнять `inflow_vault` только минимально достаточными суммами. - -Нужно проверить: - -1. частичную серию выплат, когда часть тикетов ещё остаётся pending; -2. дополнительную покупку билета в промежутке между выплатами; -3. повторную проверку приоритета после появления нового билета в `Q1`. - -После каждого `step_payout`: - -- запрашивать состояние очередей; -- проверять: - - рост `tickets_paid`; - - рост `sum_paid_usd_cents`; - - `is_paid = true` у погашенного ticket; - - правильный DAO multiplier: - - `Q1 -> 1x`; - - `Q2 -> 2x`; - - `Q3 -> 3x`. - -### 9. Проверка финального добора - -После частичных выплат: - -- купить ещё один билет; -- допополнить `inflow_vault`; -- выполнить оставшиеся `step_payout` до полного погашения всех трёх очередей. - -В конце: - -- все pending ticket должны отсутствовать; -- все суммы paid должны совпасть с total по каждой очереди; -- если вызвать `step_payout` на пустых очередях, доступный остаток `inflow_vault` должен уйти в `dao_wallet`. - -### 10. Финальный возврат лампортов - -После завершения теста вернуть все доступные остатки, которые можно вернуть текущими полномочиями, на базовый кошелёк: - -- `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` - -Отдельно зафиксировать: - -- что именно удалось вернуть; -- что именно нельзя вернуть без специальной инструкции закрытия или без deploy authority. - -## Ожидаемый результат - -- `buy_ticket_usd` и `buy_ticket_sol` создают ticket без ошибок чтения state; -- `Q3` работает наравне с `Q2`, но с третьим приоритетом; -- DAO может менять коэффициент и выдавать лимиты; -- менеджер может создавать билеты во все три очереди; -- `step_payout` соблюдает порядок `Q1 -> Q2 -> Q3`; -- DAO-множитель на выплатах равен `1x/2x/3x` для `Q1/Q2/Q3`; -- HTML UI и on-chain программа используют один и тот же актуальный `program id`; -- остатки средств после теста по максимуму возвращены на базовый DAO-кошелёк. - -## Статус - -- `done` - -## Итог выполнения - -- новый `shine_payments` задеплоен в devnet с `program id`: - - `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` -- старый `shine_payments`: - - `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` - - закрыт, лампорты возвращены на базовый DAO-кошелёк -- HTML UI переведён на новый `program id` -- подтверждены: - - `init` - - `buy_ticket_usd` - - `buy_ticket_sol` - - `grant_manager_limits` - - `manager_add_ticket` для `Q1/Q2/Q3` - - `change_ticket_recipient` - - `update_coef_limit` - - `step_payout` по порядку `Q1 -> Q2 -> Q3` - - повторный возврат приоритета в `Q1` после новой покупки -- итоговые агрегаты очередей: - - `Q1 total=4, paid=4, sum_total=780, sum_paid=780` - - `Q2 total=1, paid=1, sum_total=60, sum_paid=60` - - `Q3 total=1, paid=1, sum_total=70, sum_paid=70` -- временные тестовые кошельки собраны обратно в базовый DAO-кошелёк -- в `inflow_vault` остался только rent-минимум PDA diff --git a/Dev_Docs/Pending_Features/2026-06-07_1345_клиентская_solana_регистрация_no_anchor.md b/Dev_Docs/Pending_Features/2026-06-07_1345_клиентская_solana_регистрация_no_anchor.md deleted file mode 100644 index e7d6526..0000000 --- a/Dev_Docs/Pending_Features/2026-06-07_1345_клиентская_solana_регистрация_no_anchor.md +++ /dev/null @@ -1,30 +0,0 @@ -# Клиентская Solana-регистрация после ухода от Anchor - -## Краткое описание - -Исправлен рассинхрон обычного клиентского UI с no-Anchor ABI программ: - -- `shine_login_guard` -- `shine_users` - -Исправлены клиентские вызовы: - -1. Solana-предпроверка логина в обычном UI. -2. `init_users_economy_config` в обычном UI. - -## Что проверять - -1. На странице регистрации проверка свободного логина не выдаёт `InvalidInstructionData`. -2. Для свободного обычного логина отображается корректный статус без fallback-предупреждения про недоступную Solana-предпроверку. -3. Регистрация пользователя через обычный UI проходит до конца. -4. Страница `Solana: init регистрации` в обычном UI отправляет корректную транзакцию и не падает из-за старого Anchor discriminator. - -## Ожидаемый результат - -1. `shine_login_guard` принимает клиентский precheck. -2. `init_users_economy_config` из обычного UI совместим с текущей программой `shine_users`. -3. Обычный клиентский UI ведёт себя так же, как серверный UI, там где используется общий no-Anchor путь. - -## Статус - -- `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md deleted file mode 100644 index e3b8f5a..0000000 --- a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md +++ /dev/null @@ -1,26 +0,0 @@ -# ESP32 UI-прототип сабсервера SHiNE - -- краткое описание фичи: - для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч сабсервера `SHiNE` с хранением данных в `NVS`, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. Логика PIN в коде сохранена, но вход по PIN во временной сборке отключён, чтобы не блокировать проверку остальных экранов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`. - -- что именно проверять: - 1. Прошить режим `subserver-ui` и дождаться открытия главного экрана без PIN. - 2. Проверить, что текст в заголовках, кнопках и статусах отображается читаемо; в текущей временной сборке допускается ASCII-транслитерация русского текста. - 3. Открыть `Настройки` и убедиться, что показывается пометка о временно отключённом входе по PIN. - 4. Открыть `Подключение -> Wi-Fi`, ввести `SSID` и пароль, нажать `Проверить`, дождаться реального подключения, затем перезагрузить устройство и проверить, что значения сохранились. - 5. Открыть `Подключение -> Серверы`, проверить или изменить `API/RPC/WS`, нажать `Проверить` и убедиться, что показываются реальные статусы доступности, затем перезагрузить устройство и проверить сохранение значений. - 6. Открыть `Аккаунт`, ввести логин, имя сабсервера и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. - 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. - 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. - 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной. - 10. Выполнить регистрацию и убедиться, что статус меняется на `Сабсервер активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. - 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства. - 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. - 13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. - 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. - -- ожидаемый результат: - новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации сабсервера. - -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md deleted file mode 100644 index 66e4021..0000000 --- a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md +++ /dev/null @@ -1,13 +0,0 @@ -# ESP32 авто-прошивка shine_subserver_ui - -- краткое описание фичи: - добавлен исполняемый скрипт `flash_shine_subserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_subserver_ui` без ручного указания `PORT`. -- что именно проверять: - 1. Подключить плату `ESP32` по USB. - 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`. - 3. Запустить `./flash_shine_subserver_ui.sh`. - 4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload. -- ожидаемый результат: - скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_subserver_ui`. -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1240_esp32_text_render_test.md b/Dev_Docs/Pending_Features/2026-06-08_1240_esp32_text_render_test.md deleted file mode 100644 index b4f8376..0000000 --- a/Dev_Docs/Pending_Features/2026-06-08_1240_esp32_text_render_test.md +++ /dev/null @@ -1,14 +0,0 @@ -# ESP32 тест рендера текста - -- краткое описание фичи: - добавлен отдельный диагностический скетч `text_render_test`, который показывает один экран с несколькими вариантами вывода текста: встроенный шрифт `Arduino_GFX`, `U8g2` ASCII, `U8g2` кириллица и кнопки с подписями. Скрипт нужен для изоляции проблемы, когда на экране видны только цветные кнопки и блоки, но не видно ни одной буквы. -- что именно проверять: - 1. Прошить режим `text-test`. - 2. Проверить, виден ли заголовок `TEXT TEST 123`. - 3. Проверить, видны ли строки `A`, `B`, `C`, `D`. - 4. Проверить, видны ли подписи на трёх нижних кнопках: `BTN 1`, `abc123`, `Русский`. - 5. Сравнить, какой из способов вывода реально отображается, а какой нет. -- ожидаемый результат: - хотя бы один вариант вывода текста становится видим на экране, что позволяет локализовать проблему до конкретного шрифта или способа рендера. -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md b/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md deleted file mode 100644 index f29c8d7..0000000 --- a/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md +++ /dev/null @@ -1,12 +0,0 @@ -# ESP32 PIN-клавиатура: подписи кнопок - -- краткое описание фичи: - в UI-скетче `shine_subserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи. -- что именно проверять: - 1. Включить устройство и дождаться экрана ввода PIN. - 2. Убедиться, что на всех серых кнопках видны цифры `0-9`, `Отмена` и `OK`. - 3. Открыть другие экраны с кнопками (`Главный экран`, `Wi-Fi`, `Серверы`, `Настройки`) и убедиться, что подписи отображаются и не уезжают за границы кнопок. -- ожидаемый результат: - подписи кнопок стабильно видны сразу после старта, текст визуально центрирован, пустых серых кнопок без цифр и названий нет. -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md b/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md deleted file mode 100644 index 222ebf9..0000000 --- a/Dev_Docs/Pending_Features/2026-06-08_1315_esp32_test_sketches_folder.md +++ /dev/null @@ -1,13 +0,0 @@ -# ESP32 папка тестовых скетчей - -- краткое описание фичи: - добавлена отдельная папка `test_sketches/` с изолированными диагностическими скетчами для экрана `ESP32-S3-Touch-AMOLED-2.16`: тест рендера текста через `Arduino_GFX`, тест геометрии кнопок и минимальный тест `LVGL`. -- что именно проверять: - 1. Запустить `./burn.sh gfx-text-test` и убедиться, что прошивается тест текста из новой папки. - 2. Запустить `./burn.sh gfx-layout-test` и проверить нижние ряды кнопок. - 3. Запустить `./burn.sh lvgl-basic-test` и проверить, что `LVGL` показывает текст и кнопки. - 4. Убедиться, что новая папка не мешает сборке `subserver-ui`. -- ожидаемый результат: - тестовые скетчи лежат отдельно от основного UI, шьются отдельными режимами и позволяют быстро проверять разные гипотезы по экрану без правок в `shine_subserver_ui`. -- статус: - pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1355_esp32_lvgl_interaction_test.md b/Dev_Docs/Pending_Features/2026-06-08_1355_esp32_lvgl_interaction_test.md deleted file mode 100644 index f1a593b..0000000 --- a/Dev_Docs/Pending_Features/2026-06-08_1355_esp32_lvgl_interaction_test.md +++ /dev/null @@ -1,14 +0,0 @@ -# ESP32 LVGL interaction test - -- краткое описание фичи: - добавлен отдельный скетч `lvgl_interaction_test` на `LVGL`: экран с 9 кнопками, touch-вводом и нижней статусной строкой. При нажатии на кнопку на экране и в `Serial` показывается, какая именно кнопка нажата и сколько нажатий уже было. -- что именно проверять: - 1. Прошить режим `lvgl-interaction-test`. - 2. Убедиться, что виден заголовок, подзаголовок, 9 кнопок и нижняя статусная панель. - 3. Поочерёдно нажать разные кнопки. - 4. Проверить, что нижняя строка меняется на `Pressed: + + + + + + + +
+
Войти через другое устройство
+ + + + +

+ Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве + и получает только wallet-session без передачи постоянных ключей. +

+
+ + + + + + + + + + diff --git a/SHiNE-browser-plugin-wallet/popup.js b/SHiNE-browser-plugin-wallet/popup.js new file mode 100644 index 0000000..336090e --- /dev/null +++ b/SHiNE-browser-plugin-wallet/popup.js @@ -0,0 +1,360 @@ +const els = { + serverLoginInfo: document.querySelector('#server-login-info'), + serverAddress: document.querySelector('#server-address'), + loginInput: document.querySelector('#login-input'), + usePassword: document.querySelector('#use-password'), + passwordField: document.querySelector('#password-field'), + passwordInput: document.querySelector('#password-input'), + startBtn: document.querySelector('#start-btn'), + pairingCard: document.querySelector('#pairing-card'), + shortCode: document.querySelector('#short-code'), + pairingHint: document.querySelector('#pairing-hint'), + pairingExpire: document.querySelector('#pairing-expire'), + cancelBtn: document.querySelector('#cancel-btn'), + status: document.querySelector('#status'), + sessionCard: document.querySelector('#session-card'), + sessionLogin: document.querySelector('#session-login'), + sessionId: document.querySelector('#session-id'), + sessionType: document.querySelector('#session-type'), + deviceKeyShort: document.querySelector('#device-key-short'), + resumeBtn: document.querySelector('#resume-btn'), + refreshDevicesBtn: document.querySelector('#refresh-devices-btn'), + disconnectBtn: document.querySelector('#disconnect-btn'), + signingCard: document.querySelector('#signing-card'), + signKeySelect: document.querySelector('#sign-key-select'), + deviceSelect: document.querySelector('#device-select'), + homeserverList: document.querySelector('#homeserver-list'), + prepareSignBtn: document.querySelector('#prepare-sign-btn'), + connectionPill: document.querySelector('#connection-pill'), +}; + +let state = { + settings: { + serverLogin: 'shineupme', + serverHttp: 'https://shineup.me', + serverUrl: 'wss://shineup.me/ws', + login: '', + }, + pairing: { + active: false, + pairingId: '', + expiresAtMs: 0, + }, + session: null, + connectionOnline: false, + status: { + text: '', + kind: 'info', + }, +}; + +let refreshTimer = 0; +let saveSettingsTimer = 0; + +function setStatus(message, kind = 'info') { + els.status.textContent = String(message || ''); + els.status.className = `status ${kind === 'error' ? 'error' : 'info'}`; + els.status.classList.toggle('hidden', !message); +} + +function setConnectedPill(connected) { + els.connectionPill.textContent = connected ? 'online' : 'offline'; + els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline'; +} + +function formatRemaining(ms) { + const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000)); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${minutes} мин ${seconds} сек`; +} + +function shortKey(value, size = 10) { + const raw = String(value || '').trim(); + return raw ? `${raw.slice(0, size)}...` : '—'; +} + +function renderHomeserverList(items = []) { + els.homeserverList.innerHTML = ''; + if (!items.length) { + const empty = document.createElement('p'); + empty.className = 'muted small'; + empty.textContent = 'В PDA пока нет опубликованных homeserver-сессий.'; + els.homeserverList.append(empty); + return; + } + items.forEach((item) => { + const row = document.createElement('div'); + row.className = 'summary-row device-row'; + const label = document.createElement('span'); + label.textContent = `${item.sessionName} (${shortKey(item.sessionPubKeyBase58, 8)})`; + const badge = document.createElement('strong'); + const stateValue = String(item.onlineState || 'unknown'); + badge.textContent = stateValue; + badge.className = `device-state device-state-${stateValue}`; + row.append(label, badge); + els.homeserverList.append(row); + }); +} + +function applyState(nextState) { + state = nextState || state; + const loginValue = String(state?.settings?.login || ''); + const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim(); + const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim(); + if (loginValue && resolvedServerLogin && resolvedServerAddress) { + els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`; + els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`; + } else { + els.serverLoginInfo.textContent = 'Сервер SHiNE: —'; + els.serverAddress.textContent = 'Адрес: —'; + } + if (document.activeElement !== els.loginInput) { + els.loginInput.value = loginValue; + } + setConnectedPill(!!state?.connectionOnline); + setStatus(state?.status?.text || '', state?.status?.kind || 'info'); + + const session = state?.session; + const walletProfile = state?.walletProfile; + const signing = state?.signing || {}; + if (session) { + els.sessionCard.classList.remove('hidden'); + els.sessionLogin.textContent = session.login || '—'; + els.sessionId.textContent = session.sessionId || '—'; + els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—'); + els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || ''); + els.signingCard.classList.remove('hidden'); + } else { + els.sessionCard.classList.add('hidden'); + els.sessionLogin.textContent = '—'; + els.sessionId.textContent = '—'; + els.sessionType.textContent = 'wallet'; + els.deviceKeyShort.textContent = '—'; + els.signingCard.classList.add('hidden'); + } + + const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : []; + els.signKeySelect.innerHTML = ''; + signKeyOptions.forEach((item) => { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.label; + option.selected = item.id === signing.selectedKeyId; + els.signKeySelect.append(option); + }); + + const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : []; + els.deviceSelect.innerHTML = ''; + homeservers.forEach((item) => { + const option = document.createElement('option'); + option.value = item.sessionName; + option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`; + option.selected = item.sessionName === signing.selectedDeviceName; + els.deviceSelect.append(option); + }); + renderHomeserverList(homeservers); + els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName; + + const pairing = state?.pairing || {}; + if (pairing.active) { + els.pairingCard.classList.remove('hidden'); + const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000'); + els.shortCode.dataset.shortCode = shortCode; + els.shortCode.textContent = shortCode; + els.pairingHint.textContent = pairing.trustedSessionOnline + ? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.' + : 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.'; + const leftMs = Number(pairing.expiresAtMs || 0) - Date.now(); + els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.'; + els.startBtn.disabled = true; + } else { + els.pairingCard.classList.add('hidden'); + els.shortCode.textContent = '0000000'; + delete els.shortCode.dataset.shortCode; + els.pairingExpire.textContent = ''; + els.startBtn.disabled = false; + } +} + +function normalizeError(response, fallback) { + return response?.error || fallback || 'Unknown error'; +} + +function sendMessage(type, payload = {}) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed')); + return; + } + if (!response?.ok) { + reject(new Error(normalizeError(response, 'Wallet operation failed'))); + return; + } + if (response?.state) applyState(response.state); + resolve(response); + }); + }); +} + +async function refreshState() { + const response = await sendMessage('wallet:getState'); + applyState(response.state); +} + +async function saveSettings() { + await sendMessage('wallet:saveSettings', { + login: String(els.loginInput.value || '').trim(), + }); +} + +async function resolveServerInfo() { + const login = String(els.loginInput.value || '').trim(); + if (!login) { + await sendMessage('wallet:saveSettings', { login: '' }); + return; + } + try { + await sendMessage('wallet:resolveServerInfo', { login }); + } catch (error) { + setStatus(error.message || 'Не удалось определить сервер SHiNE по PDA.', 'error'); + } +} + +function scheduleSaveSettings() { + if (saveSettingsTimer) { + window.clearTimeout(saveSettingsTimer); + } + saveSettingsTimer = window.setTimeout(() => { + saveSettingsTimer = 0; + void saveSettings(); + }, 250); +} + +async function startPairing() { + const login = String(els.loginInput.value || '').trim(); + if (!login) { + setStatus('Введите логин.', 'error'); + return; + } + setStatus('Создаём wallet-session заявку...', 'info'); + els.startBtn.disabled = true; + try { + const response = await sendMessage('wallet:startPairing', { + login, + usePassword: !!els.usePassword.checked, + password: String(els.passwordInput.value || ''), + }); + applyState(response.state); + } catch (error) { + els.startBtn.disabled = false; + setStatus(error.message || 'Не удалось начать pairing.', 'error'); + } +} + +async function cancelPairing() { + try { + await sendMessage('wallet:cancelPairing'); + } catch (error) { + setStatus(error.message || 'Не удалось отменить pairing.', 'error'); + } +} + +async function resumeSession() { + setStatus('Проверяем сохранённую wallet-session...', 'info'); + try { + await sendMessage('wallet:resumeSession'); + } catch (error) { + setStatus(error.message || 'Не удалось восстановить session.', 'error'); + } +} + +async function disconnectSession() { + try { + await sendMessage('wallet:disconnectSession'); + } catch (error) { + setStatus(error.message || 'Не удалось удалить session.', 'error'); + } +} + +async function refreshDevices() { + setStatus('Обновляем trusted homeserver-устройства...', 'info'); + try { + await sendMessage('wallet:refreshWalletDevices'); + } catch (error) { + setStatus(error.message || 'Не удалось обновить список устройств.', 'error'); + } +} + +async function updateSigningSelection() { + try { + await sendMessage('wallet:updateSigningSelection', { + selectedKeyId: String(els.signKeySelect.value || '').trim(), + selectedDeviceName: String(els.deviceSelect.value || '').trim(), + }); + } catch (error) { + setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error'); + } +} + +async function prepareSignSignal() { + setStatus('Готовим каркас запроса подписи...', 'info'); + try { + await sendMessage('wallet:prepareSignSignal'); + } catch (error) { + setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error'); + } +} + +function startUiRefreshLoop() { + stopUiRefreshLoop(); + refreshTimer = window.setInterval(() => { + void refreshState(); + }, 1000); +} + +function stopUiRefreshLoop() { + if (refreshTimer) { + window.clearInterval(refreshTimer); + refreshTimer = 0; + } +} + +function bindUi() { + els.usePassword.addEventListener('change', () => { + els.passwordField.classList.toggle('hidden', !els.usePassword.checked); + if (!els.usePassword.checked) { + els.passwordInput.value = ''; + } + }); + els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); }); + els.loginInput.addEventListener('change', () => { + void saveSettings(); + void resolveServerInfo(); + }); + els.startBtn.addEventListener('click', () => { void startPairing(); }); + els.cancelBtn.addEventListener('click', () => { void cancelPairing(); }); + els.resumeBtn.addEventListener('click', () => { void resumeSession(); }); + els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); }); + els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); }); + els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); }); + els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); }); + els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); }); +} + +async function init() { + bindUi(); + await refreshState(); + startUiRefreshLoop(); +} + +window.addEventListener('beforeunload', () => { + stopUiRefreshLoop(); + if (saveSettingsTimer) { + window.clearTimeout(saveSettingsTimer); + saveSettingsTimer = 0; + } +}); + +void init(); diff --git a/SHiNE-browser-plugin-wallet/settings.gradle b/SHiNE-browser-plugin-wallet/settings.gradle new file mode 100644 index 0000000..c9ec0cd --- /dev/null +++ b/SHiNE-browser-plugin-wallet/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'SHiNE-browser-plugin-wallet' diff --git a/SHiNE-promo-solana-devnet/.gitignore b/SHiNE-promo-solana-devnet/.gitignore deleted file mode 100644 index a420490..0000000 --- a/SHiNE-promo-solana-devnet/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.gradle -/build -.idea -out -*.log - -config/devnet-wallet.json - -!gradle/wrapper/gradle-wrapper.jar diff --git a/SHiNE-promo-solana-devnet/README.md b/SHiNE-promo-solana-devnet/README.md deleted file mode 100644 index 9c907ba..0000000 --- a/SHiNE-promo-solana-devnet/README.md +++ /dev/null @@ -1,198 +0,0 @@ -# SHiNE-promo-solana-devnet - -Временное промо-приложение для тестеров Web3-социальной сети SHiNE / «Сияние» в Solana Devnet. - -Основной сценарий: -- приложение SHiNE открывает страницу вида `/?wallet=SOLANA_PUBLIC_KEY`; -- пользователь вводит имя и промокод; -- backend проверяет промокод и отправляет **реальную** devnet-транзакцию на `0.1 SOL`; -- использованный промокод фиксируется в файле и больше не может быть применён. - -## Стек - -- Java 21 -- Spring Boot -- Gradle -- Thymeleaf + HTML/CSS/JS (server-side UI) - -## Локальный запуск - -1. Скопируйте пример настроек: - -```bash -cp config/application.example.properties src/main/resources/application.properties -``` - -2. Положите реальный keypair-файл Solana CLI формата в: - -```text -config/devnet-wallet.json -``` - -3. Запустите: - -```bash -./gradlew bootRun -``` - -Приложение будет доступно на `http://localhost:8021`. - -## Как указать порт - -По умолчанию используется: - -```properties -server.port=8021 -``` - -Изменить можно: -- в `src/main/resources/application.properties`; -- или через параметр запуска: - -```bash -./gradlew bootRun --args='--server.port=8090' -``` - -## Настройка Solana RPC Devnet - -Параметр: - -```properties -solana.rpc.url=https://api.devnet.solana.com -``` - -Для другого RPC достаточно заменить URL в properties. - -## Настройка devnet keypair - -- Файл должен быть в формате Solana keypair JSON: массив из 64 чисел (`0..255`). -- Пример лежит в `config/devnet-wallet.example.json`. -- Рабочий файл: `config/devnet-wallet.json`. - -Важно: настоящий keypair **нельзя коммитить в GitHub**. -Он добавлен в `.gitignore`. - -## Где лежат промокоды - -Файл: - -```text -data/promo-codes.txt -``` - -## Где лежит файл использованных промокодов - -Файл: - -```text -data/promo-used.txt -``` - -## Формат `promo-codes.txt` - -- одна строка = один промокод; -- пустые строки игнорируются; -- строки с `#` игнорируются как комментарии; -- промокод должен соответствовать regex: `[a-z0-9]{8}`. - -## Формат `promo-used.txt` - -Каждая запись в формате: - -```text -promoCode | wallet | name | yyyy.MM.dd HH:mm | signature -``` - -Пример: - -```text -aidar2km | 8xF...abc | Иван Петров | 2026.04.27 18:45 | 5xTxSignature... -``` - -## Пример URL - -```text -http://localhost:8021/?wallet=8zYQ...DevnetAddress -``` - -## Пример API-запроса - -```bash -curl -X POST http://localhost:8021/api/promo/top-up \ - -H "Content-Type: application/json" \ - -d '{ - "wallet":"SOLANA_PUBLIC_KEY", - "name":"Иван Петров", - "promoCode":"aidar2km" - }' -``` - -## Проверка транзакции в Solana Explorer Devnet - -Explorer URL формируется по шаблону: - -```text -https://explorer.solana.com/tx/{signature}?cluster=devnet -``` - -## Сборка jar через Gradle - -```bash -./gradlew clean build -``` - -JAR: - -```text -build/libs/SHiNE-promo-solana-devnet.jar -``` - -## Запуск jar - -```bash -java -jar build/libs/SHiNE-promo-solana-devnet.jar -``` - -## Health endpoint - -```text -GET /health -``` - -Ответ: - -```json -{ - "status": "ok", - "app": "SHiNE-promo-solana-devnet" -} -``` - -## Логи - -- логируется старт приложения; -- логируются успешные пополнения; -- логируются ошибки транзакций; -- приватный ключ не логируется. - -## Gradle-задачи для серверного деплоя - -В `build.gradle` добавлены задачи: - -- `buildServerBundle` — готовит bundle в `build/server-bundle`; -- `deployToServer` — копирует JAR и `application.properties` на сервер и перезапускает systemd-сервис. - -Запуск: - -```bash -./gradlew deployToServer -``` - -Переопределение хоста/пути: - -```bash -./gradlew deployToServer \ - -PdeployHost=user@10.147.20.7 \ - -PdeployPath=/home/user/docker/SHiNE-promo-solana-devnet \ - -PdeployService=SHiNE-promo-solana-devnet -``` diff --git a/SHiNE-promo-solana-devnet/build.gradle b/SHiNE-promo-solana-devnet/build.gradle deleted file mode 100644 index eb35d19..0000000 --- a/SHiNE-promo-solana-devnet/build.gradle +++ /dev/null @@ -1,103 +0,0 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.3.6' - id 'io.spring.dependency-management' version '1.1.7' -} - -group = 'ru.shine' -version = '0.1.0' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'com.mmorrell:solanaj:1.20.4' - - testImplementation 'org.springframework.boot:spring-boot-starter-test' -} - -tasks.named('test') { - useJUnitPlatform() -} - -tasks.named('bootJar') { - archiveFileName = 'SHiNE-promo-solana-devnet.jar' -} - -// ------------------------------------------------------------ -// ДЕПЛОЙ ВРЕМЕННОГО СЕРВИСА "SHiNE-promo-solana-devnet" -// -// Назначение сервиса: -// - это отдельный продукт для тестовой раздачи SOL в devnet; -// - нужен для упрощённого онбординга тестовых пользователей; -// - работает как самостоятельное Spring Boot приложение. -// -// Почему отдельный деплой: -// - сервис изолирован от основного SHiNE-сервера; -// - его можно обновлять/перезапускать независимо; -// - жизненный цикл временного сервиса не должен ломать основной прод. -// -// ВАЖНО: -// - деплой по умолчанию выполняется ЧЕРЕЗ ДОМЕН (shineup.me), а не по IP; -// - целевая папка на сервере: /home/player/SHiNE/SHiNE-promo-solana-devnet; -// - целевой systemd-сервис: SHiNE-promo-solana-devnet. -// ------------------------------------------------------------ -def deployHost = project.findProperty('deployHost') ?: 'root@shineup.me' -def deployPath = project.findProperty('deployPath') ?: '/home/player/SHiNE/SHiNE-promo-solana-devnet' -def remoteServiceName = project.findProperty('deployService') ?: 'SHiNE-promo-solana-devnet' - -tasks.register('buildServerBundle', Copy) { - // Сборка минимального серверного бандла: - // 1) fat-jar приложения - // 2) шаблон application.properties (чтобы был эталон конфигурации) - dependsOn tasks.named('bootJar') - from(tasks.named('bootJar').flatMap { it.archiveFile }) { - rename { 'SHiNE-promo-solana-devnet.jar' } - } - from('config/application.example.properties') { - rename { 'application.properties' } - } - into(layout.buildDirectory.dir('server-bundle')) -} - -tasks.register('deployToServerMkdir', Exec) { - // Шаг 1: гарантируем существование целевой директории на удалённом сервере. - dependsOn tasks.named('buildServerBundle') - commandLine 'bash', '-lc', "ssh ${deployHost} 'mkdir -p ${deployPath}'" -} - -tasks.register('deployToServerJar', Exec) { - // Шаг 2: загружаем исполняемый jar. - dependsOn tasks.named('deployToServerMkdir') - commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/SHiNE-promo-solana-devnet.jar').get().asFile.absolutePath} ${deployHost}:${deployPath}/" -} - -tasks.register('deployToServerConfig', Exec) { - // Шаг 3: загружаем конфиг-шаблон. - // На проде при необходимости его можно заменить на рабочий конфиг с секретами. - dependsOn tasks.named('deployToServerJar') - commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/application.properties').get().asFile.absolutePath} ${deployHost}:${deployPath}/" -} - -tasks.register('deployToServerRestart', Exec) { - // Шаг 4: перезапускаем systemd-сервис и показываем статус. - // Если сервис не поднялся, ошибка будет видна сразу в этом шаге. - dependsOn tasks.named('deployToServerConfig') - commandLine 'bash', '-lc', "ssh ${deployHost} 'sudo systemctl restart ${remoteServiceName} && sudo systemctl --no-pager status ${remoteServiceName}'" -} - -tasks.register('deployToServer') { - // Единая точка входа для деплоя временного сервиса. - // Запуск: ./gradlew deployToServer - dependsOn tasks.named('deployToServerRestart') -} diff --git a/SHiNE-promo-solana-devnet/config/application.example.properties b/SHiNE-promo-solana-devnet/config/application.example.properties deleted file mode 100644 index 944690a..0000000 --- a/SHiNE-promo-solana-devnet/config/application.example.properties +++ /dev/null @@ -1,15 +0,0 @@ -server.port=8021 - -solana.rpc.url=https://api.devnet.solana.com -solana.sender.keypair-file=./config/devnet-wallet.json - -promo.transfer.amount-sol=0.1 -promo.codes.file=./data/promo-codes.txt -promo.used.file=./data/promo-used.txt - -promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet - -# Вечный промокод для временной раздачи в devnet. -# Если enabled=true, код можно использовать неограниченно (он не "сгорает"). -promo.eternal-code.enabled=true -promo.eternal-code.value=0000 diff --git a/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json b/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json deleted file mode 100644 index 61463f7..0000000 --- a/SHiNE-promo-solana-devnet/config/devnet-wallet.example.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - 151, 22, 193, 47, 88, 234, 15, 201, 42, 19, 180, 76, 211, 5, 164, 91, - 207, 135, 44, 173, 61, 242, 14, 99, 158, 208, 39, 117, 10, 226, 95, 132, - 56, 72, 164, 209, 41, 189, 76, 239, 121, 18, 66, 213, 30, 145, 201, 9, - 177, 53, 120, 87, 200, 65, 33, 251, 102, 74, 186, 44, 160, 7, 92, 137 -] diff --git a/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service b/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service deleted file mode 100644 index c75f976..0000000 --- a/SHiNE-promo-solana-devnet/deploy/SHiNE-promo-solana-devnet.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=SHiNE Promo Solana Devnet -After=network.target - -[Service] -Type=simple -User=player -Group=player -WorkingDirectory=/home/player/SHiNE/SHiNE-promo-solana-devnet -ExecStart=/usr/bin/java -jar /home/player/SHiNE/SHiNE-promo-solana-devnet/SHiNE-promo-solana-devnet.jar --spring.config.location=/home/player/SHiNE/SHiNE-promo-solana-devnet/application.properties -Restart=always -RestartSec=5 -SuccessExitStatus=143 - -[Install] -WantedBy=multi-user.target diff --git a/SHiNE-promo-solana-devnet/settings.gradle b/SHiNE-promo-solana-devnet/settings.gradle deleted file mode 100644 index 5c50eed..0000000 --- a/SHiNE-promo-solana-devnet/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'SHiNE-promo-solana-devnet' \ No newline at end of file diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java deleted file mode 100644 index 989d6dd..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/ShinePromoSolanaDevnetApplication.java +++ /dev/null @@ -1,30 +0,0 @@ -package ru.shine.promo; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; -import ru.shine.promo.config.AppProperties; - -@SpringBootApplication -public class ShinePromoSolanaDevnetApplication { - - private static final Logger log = LoggerFactory.getLogger(ShinePromoSolanaDevnetApplication.class); - - public static void main(String[] args) { - SpringApplication.run(ShinePromoSolanaDevnetApplication.class, args); - } - - @Bean - CommandLineRunner startupLogger(AppProperties appProperties) { - return args -> log.info( - "SHiNE promo app started. RPC: {}, promoCodesFile: {}, usedFile: {}, transferAmountSol: {}", - appProperties.getSolanaRpcUrl(), - appProperties.getPromoCodesFile(), - appProperties.getPromoUsedFile(), - appProperties.getPromoTransferAmountSol() - ); - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java deleted file mode 100644 index a7b3781..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/config/AppProperties.java +++ /dev/null @@ -1,77 +0,0 @@ -package ru.shine.promo.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; -import java.math.RoundingMode; - -@Component -public class AppProperties { - - private static final long LAMPORTS_PER_SOL = 1_000_000_000L; - - @Value("${solana.rpc.url}") - private String solanaRpcUrl; - - @Value("${solana.sender.keypair-file}") - private String solanaSenderKeypairFile; - - @Value("${promo.transfer.amount-sol}") - private BigDecimal promoTransferAmountSol; - - @Value("${promo.codes.file}") - private String promoCodesFile; - - @Value("${promo.used.file}") - private String promoUsedFile; - - @Value("${promo.explorer.tx-url-template}") - private String promoExplorerTxUrlTemplate; - - @Value("${promo.eternal-code.enabled:false}") - private boolean promoEternalCodeEnabled; - - @Value("${promo.eternal-code.value:0000}") - private String promoEternalCodeValue; - - public String getSolanaRpcUrl() { - return solanaRpcUrl; - } - - public String getSolanaSenderKeypairFile() { - return solanaSenderKeypairFile; - } - - public BigDecimal getPromoTransferAmountSol() { - return promoTransferAmountSol; - } - - public String getPromoCodesFile() { - return promoCodesFile; - } - - public String getPromoUsedFile() { - return promoUsedFile; - } - - public String getPromoExplorerTxUrlTemplate() { - return promoExplorerTxUrlTemplate; - } - - public boolean isPromoEternalCodeEnabled() { - return promoEternalCodeEnabled; - } - - public String getPromoEternalCodeValue() { - if (promoEternalCodeValue == null) { - return ""; - } - return promoEternalCodeValue.trim().toLowerCase(); - } - - public long getPromoTransferAmountLamports() { - BigDecimal lamports = promoTransferAmountSol.multiply(BigDecimal.valueOf(LAMPORTS_PER_SOL)); - return lamports.setScale(0, RoundingMode.UNNECESSARY).longValueExact(); - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java deleted file mode 100644 index 18d6ee5..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoApiController.java +++ /dev/null @@ -1,136 +0,0 @@ -package ru.shine.promo.controller; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; -import ru.shine.promo.config.AppProperties; -import ru.shine.promo.dto.PromoRequest; -import ru.shine.promo.dto.PromoResponse; -import ru.shine.promo.service.PromoCodeService; -import ru.shine.promo.service.PromoException; -import ru.shine.promo.service.PromoTransferService; -import ru.shine.promo.service.UsedPromoStorageService; - -import java.util.Map; - -@RestController -public class PromoApiController { - - private static final Logger log = LoggerFactory.getLogger(PromoApiController.class); - - private final AppProperties appProperties; - private final PromoCodeService promoCodeService; - private final PromoTransferService promoTransferService; - private final UsedPromoStorageService usedPromoStorageService; - - public PromoApiController( - AppProperties appProperties, - PromoCodeService promoCodeService, - PromoTransferService promoTransferService, - UsedPromoStorageService usedPromoStorageService - ) { - this.appProperties = appProperties; - this.promoCodeService = promoCodeService; - this.promoTransferService = promoTransferService; - this.usedPromoStorageService = usedPromoStorageService; - } - - @PostMapping("/api/promo/top-up") - public ResponseEntity topUp(@RequestBody PromoRequest request) { - String wallet = trimToEmpty(request == null ? null : request.getWallet()); - String name = trimToEmpty(request == null ? null : request.getName()); - String promoCode = promoCodeService.normalizePromoCode(request == null ? null : request.getPromoCode()); - - if (wallet.isEmpty()) { - return error(HttpStatus.BAD_REQUEST, "Пустой wallet"); - } - if (!promoTransferService.isSolanaWalletValid(wallet)) { - return error(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса"); - } - if (name.isEmpty()) { - return error(HttpStatus.BAD_REQUEST, "Пустое имя"); - } - if (name.length() < 2 || name.length() > 120) { - return error(HttpStatus.BAD_REQUEST, "Имя должно быть длиной от 2 до 120 символов"); - } - if (promoCode.isEmpty()) { - return error(HttpStatus.BAD_REQUEST, "Пустой промокод"); - } - boolean eternalPromo = promoCodeService.isEternalPromoCode(promoCode); - if (!eternalPromo && !promoCodeService.isPromoCodeFormatValid(promoCode)) { - return error(HttpStatus.BAD_REQUEST, "Неверный формат промокода"); - } - - try { - if (!eternalPromo && !promoCodeService.promoCodeExists(promoCode)) { - return error(HttpStatus.BAD_REQUEST, "Промокод не найден"); - } - - String signature = usedPromoStorageService.executeLocked(() -> { - if (!eternalPromo && usedPromoStorageService.isPromoUsed(promoCode)) { - throw new PromoException(HttpStatus.CONFLICT, "Промокод уже использован"); - } - - String txSignature = promoTransferService.sendPromoTransfer(wallet); - // Для вечного промокода ведём только лог использования, но не блокируем повторное применение. - usedPromoStorageService.appendUsedPromo(promoCode, wallet, name, txSignature); - return txSignature; - }); - - String explorerUrl = promoTransferService.buildExplorerUrl(signature); - String amount = appProperties.getPromoTransferAmountSol().stripTrailingZeros().toPlainString(); - - log.info( - "Promo top-up success: wallet={}, name={}, promoCode={}, signature={}", - wallet, - name, - promoCode, - signature - ); - - PromoResponse response = PromoResponse.success( - "Тестовое пополнение выполнено", - wallet, - name, - amount, - signature, - explorerUrl - ); - return ResponseEntity.ok(response); - } catch (PromoException e) { - if (e.getStatus().is5xxServerError()) { - log.error("Top-up failed: {}", e.getMessage(), e); - } else { - log.warn("Top-up rejected: {}", e.getMessage()); - } - return error(e.getStatus(), e.getMessage()); - } catch (Exception e) { - log.error("Unexpected top-up error", e); - return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера"); - } - } - - @GetMapping("/health") - public Map health() { - return Map.of( - "status", "ok", - "app", "SHiNE-promo-solana-devnet" - ); - } - - private ResponseEntity error(HttpStatus status, String message) { - return ResponseEntity.status(status).body(PromoResponse.error(message)); - } - - private String trimToEmpty(String value) { - if (value == null) { - return ""; - } - return value.trim().replaceAll("\\s{2,}", " "); - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java deleted file mode 100644 index f9e38d1..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/controller/PromoPageController.java +++ /dev/null @@ -1,16 +0,0 @@ -package ru.shine.promo.controller; - -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@Controller -public class PromoPageController { - - @GetMapping("/") - public String index(@RequestParam(name = "wallet", required = false) String wallet, Model model) { - model.addAttribute("wallet", wallet == null ? "" : wallet.trim()); - return "index"; - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java deleted file mode 100644 index c0ef86a..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoRequest.java +++ /dev/null @@ -1,32 +0,0 @@ -package ru.shine.promo.dto; - -public class PromoRequest { - - private String wallet; - private String name; - private String promoCode; - - public String getWallet() { - return wallet; - } - - public void setWallet(String wallet) { - this.wallet = wallet; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getPromoCode() { - return promoCode; - } - - public void setPromoCode(String promoCode) { - this.promoCode = promoCode; - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java deleted file mode 100644 index ebd7835..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/dto/PromoResponse.java +++ /dev/null @@ -1,69 +0,0 @@ -package ru.shine.promo.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public class PromoResponse { - - private boolean success; - private String message; - private String wallet; - private String name; - private String amountSol; - private String signature; - private String explorerUrl; - - public static PromoResponse success( - String message, - String wallet, - String name, - String amountSol, - String signature, - String explorerUrl - ) { - PromoResponse response = new PromoResponse(); - response.success = true; - response.message = message; - response.wallet = wallet; - response.name = name; - response.amountSol = amountSol; - response.signature = signature; - response.explorerUrl = explorerUrl; - return response; - } - - public static PromoResponse error(String message) { - PromoResponse response = new PromoResponse(); - response.success = false; - response.message = message; - return response; - } - - public boolean isSuccess() { - return success; - } - - public String getMessage() { - return message; - } - - public String getWallet() { - return wallet; - } - - public String getName() { - return name; - } - - public String getAmountSol() { - return amountSol; - } - - public String getSignature() { - return signature; - } - - public String getExplorerUrl() { - return explorerUrl; - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java deleted file mode 100644 index f14b4f8..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoCodeService.java +++ /dev/null @@ -1,77 +0,0 @@ -package ru.shine.promo.service; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import ru.shine.promo.config.AppProperties; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.regex.Pattern; - -@Service -public class PromoCodeService { - - private static final Logger log = LoggerFactory.getLogger(PromoCodeService.class); - private static final Pattern PROMO_CODE_PATTERN = Pattern.compile("^[a-z0-9]{8}$"); - - private final AppProperties appProperties; - - public PromoCodeService(AppProperties appProperties) { - this.appProperties = appProperties; - } - - public String normalizePromoCode(String rawPromoCode) { - if (rawPromoCode == null) { - return ""; - } - return rawPromoCode.trim().toLowerCase(); - } - - public boolean isPromoCodeFormatValid(String promoCode) { - return PROMO_CODE_PATTERN.matcher(promoCode).matches(); - } - - public boolean isEternalPromoCode(String promoCode) { - if (!appProperties.isPromoEternalCodeEnabled()) { - return false; - } - String configured = appProperties.getPromoEternalCodeValue(); - return !configured.isEmpty() && configured.equals(promoCode); - } - - public boolean promoCodeExists(String promoCode) { - Set codes = readPromoCodesFromFile(); - return codes.contains(promoCode); - } - - private Set readPromoCodesFromFile() { - Path file = Path.of(appProperties.getPromoCodesFile()); - if (!Files.exists(file)) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов"); - } - - try { - Set result = new LinkedHashSet<>(); - for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) { - String line = row.trim().toLowerCase(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - if (!isPromoCodeFormatValid(line)) { - log.warn("Skipped invalid promo code row in {}: {}", file, line); - continue; - } - result.add(line); - } - return result; - } catch (IOException e) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов", e); - } - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java deleted file mode 100644 index a28db39..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoException.java +++ /dev/null @@ -1,22 +0,0 @@ -package ru.shine.promo.service; - -import org.springframework.http.HttpStatus; - -public class PromoException extends RuntimeException { - - private final HttpStatus status; - - public PromoException(HttpStatus status, String message) { - super(message); - this.status = status; - } - - public PromoException(HttpStatus status, String message, Throwable cause) { - super(message, cause); - this.status = status; - } - - public HttpStatus getStatus() { - return status; - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java deleted file mode 100644 index dda24a2..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/PromoTransferService.java +++ /dev/null @@ -1,139 +0,0 @@ -package ru.shine.promo.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.p2p.solanaj.core.Account; -import org.p2p.solanaj.core.PublicKey; -import org.p2p.solanaj.core.Transaction; -import org.p2p.solanaj.programs.SystemProgram; -import org.p2p.solanaj.rpc.RpcClient; -import org.p2p.solanaj.rpc.RpcException; -import org.p2p.solanaj.rpc.types.config.Commitment; -import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import ru.shine.promo.config.AppProperties; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collections; -import java.util.Locale; - -@Service -public class PromoTransferService { - - private static final Logger log = LoggerFactory.getLogger(PromoTransferService.class); - - private final AppProperties appProperties; - private final ObjectMapper objectMapper = new ObjectMapper(); - - public PromoTransferService(AppProperties appProperties) { - this.appProperties = appProperties; - } - - public boolean isSolanaWalletValid(String wallet) { - if (wallet == null || wallet.isBlank()) { - return false; - } - try { - PublicKey publicKey = new PublicKey(wallet.trim()); - return publicKey.toByteArray().length == PublicKey.PUBLIC_KEY_LENGTH; - } catch (Exception e) { - return false; - } - } - - public String sendPromoTransfer(String recipientWallet) { - PublicKey recipient = parseWalletOrThrow(recipientWallet); - Account sender = readSenderAccount(); - long lamports = appProperties.getPromoTransferAmountLamports(); - - RpcClient rpcClient = new RpcClient(appProperties.getSolanaRpcUrl()); - - try { - long senderBalance = rpcClient.getApi().getBalance(sender.getPublicKey(), Commitment.CONFIRMED); - if (senderBalance < lamports) { - throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя"); - } - - Transaction transaction = new Transaction() - .addInstruction(SystemProgram.transfer(sender.getPublicKey(), recipient, lamports)); - - RpcSendTransactionConfig config = RpcSendTransactionConfig.builder() - .encoding(RpcSendTransactionConfig.Encoding.base64) - .skipPreFlight(false) - .maxRetries(3) - .build(); - - return rpcClient.getApi().sendTransaction( - transaction, - Collections.singletonList(sender), - null, - config - ); - } catch (PromoException e) { - throw e; - } catch (RpcException e) { - String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT); - if (message.contains("insufficient")) { - throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя", e); - } - if (message.contains("rpc")) { - throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка RPC Solana", e); - } - log.error("Solana transfer failed: {}", e.getMessage(), e); - throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e); - } catch (Exception e) { - log.error("Unexpected Solana transfer error: {}", e.getMessage(), e); - throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e); - } - } - - public String buildExplorerUrl(String signature) { - return String.format(appProperties.getPromoExplorerTxUrlTemplate(), signature); - } - - private PublicKey parseWalletOrThrow(String wallet) { - if (!isSolanaWalletValid(wallet)) { - throw new PromoException(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса"); - } - return new PublicKey(wallet.trim()); - } - - private Account readSenderAccount() { - Path keypairFile = Path.of(appProperties.getSolanaSenderKeypairFile()); - if (!Files.exists(keypairFile)) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Файл keypair отправителя не найден"); - } - - try { - int[] keyArray = objectMapper.readValue(Files.readString(keypairFile), int[].class); - if (keyArray.length != 64) { - throw new PromoException( - HttpStatus.INTERNAL_SERVER_ERROR, - "Некорректный формат keypair отправителя: ожидается массив из 64 чисел" - ); - } - - byte[] secret = new byte[64]; - for (int i = 0; i < keyArray.length; i++) { - int value = keyArray[i]; - if (value < 0 || value > 255) { - throw new PromoException( - HttpStatus.INTERNAL_SERVER_ERROR, - "Некорректный формат keypair отправителя: числа должны быть в диапазоне 0..255" - ); - } - secret[i] = (byte) value; - } - - return new Account(secret); - } catch (PromoException e) { - throw e; - } catch (IOException e) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения keypair отправителя", e); - } - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java b/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java deleted file mode 100644 index f27acaf..0000000 --- a/SHiNE-promo-solana-devnet/src/main/java/ru/shine/promo/service/UsedPromoStorageService.java +++ /dev/null @@ -1,101 +0,0 @@ -package ru.shine.promo.service; - -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import ru.shine.promo.config.AppProperties; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.locks.ReentrantLock; - -@Service -public class UsedPromoStorageService { - - private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ROOT); - - private final AppProperties appProperties; - private final ReentrantLock promoLock = new ReentrantLock(true); - - public UsedPromoStorageService(AppProperties appProperties) { - this.appProperties = appProperties; - } - - public T executeLocked(LockedOperation operation) { - promoLock.lock(); - try { - return operation.run(); - } finally { - promoLock.unlock(); - } - } - - public boolean isPromoUsed(String promoCode) { - Path usedFile = Path.of(appProperties.getPromoUsedFile()); - if (!Files.exists(usedFile)) { - return false; - } - - try { - List rows = Files.readAllLines(usedFile, StandardCharsets.UTF_8); - for (String row : rows) { - String line = row.trim(); - if (line.isEmpty() || line.startsWith("#")) { - continue; - } - - String[] parts = line.split("\\|"); - if (parts.length == 0) { - continue; - } - - String usedCode = parts[0].trim().toLowerCase(Locale.ROOT); - if (usedCode.equals(promoCode)) { - return true; - } - } - return false; - } catch (IOException e) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла использованных промокодов", e); - } - } - - public void appendUsedPromo(String promoCode, String wallet, String name, String signature) { - Path usedFile = Path.of(appProperties.getPromoUsedFile()); - try { - Path parent = usedFile.toAbsolutePath().getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - if (!Files.exists(usedFile)) { - Files.createFile(usedFile); - } - - String timestamp = LocalDateTime.now().format(DATE_TIME_FORMAT); - String line = String.format( - Locale.ROOT, - "%s | %s | %s | %s | %s%n", - promoCode, - wallet, - name, - timestamp, - signature - ); - - Files.writeString(usedFile, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND); - } catch (IOException e) { - throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка записи файла использованных промокодов", e); - } - } - - @FunctionalInterface - public interface LockedOperation { - T run(); - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/resources/application.properties b/SHiNE-promo-solana-devnet/src/main/resources/application.properties deleted file mode 100644 index ef6e660..0000000 --- a/SHiNE-promo-solana-devnet/src/main/resources/application.properties +++ /dev/null @@ -1,17 +0,0 @@ -server.port=8021 - -spring.application.name=SHiNE-promo-solana-devnet -spring.thymeleaf.cache=false - -solana.rpc.url=https://api.devnet.solana.com -solana.sender.keypair-file=./config/devnet-wallet.json - -promo.transfer.amount-sol=0.1 -promo.codes.file=./data/promo-codes.txt -promo.used.file=./data/promo-used.txt -promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet - -# Вечный промокод для временной раздачи в devnet. -# Если enabled=true, код можно использовать неограниченно (он не "сгорает"). -promo.eternal-code.enabled=true -promo.eternal-code.value=0000 diff --git a/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css b/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css deleted file mode 100644 index 1846927..0000000 --- a/SHiNE-promo-solana-devnet/src/main/resources/static/css/app.css +++ /dev/null @@ -1,200 +0,0 @@ -:root { - --bg-main: #070707; - --bg-card: #111217; - --bg-card-soft: #171a22; - --text-main: #f6f8fb; - --text-muted: #aeb4c1; - --accent: #51ffd3; - --accent-soft: rgba(81, 255, 211, 0.15); - --danger: #ff6f6f; - --border: rgba(255, 255, 255, 0.12); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif; - color: var(--text-main); - background: - radial-gradient(circle at 20% 5%, rgba(81, 255, 211, 0.08), transparent 34%), - radial-gradient(circle at 100% 0%, rgba(86, 135, 255, 0.12), transparent 40%), - var(--bg-main); - min-height: 100vh; -} - -.page { - min-height: calc(100vh - 48px); - display: grid; - place-items: center; - padding: 24px 14px; -} - -.card { - width: min(720px, 100%); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)); - border: 1px solid var(--border); - border-radius: 18px; - padding: 20px; - box-shadow: 0 12px 50px rgba(0, 0, 0, 0.45); -} - -.hero h1 { - margin: 0; - font-size: clamp(1.3rem, 3.8vw, 1.9rem); - line-height: 1.2; -} - -.subtitle { - color: var(--text-muted); - margin-top: 10px; - margin-bottom: 0; -} - -.lead { - margin-top: 18px; - line-height: 1.5; -} - -.warning { - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.03); - border-radius: 12px; - padding: 12px; - margin-top: 16px; - color: #f0f4ff; -} - -.promo-form { - display: grid; - gap: 10px; - margin-top: 18px; -} - -.promo-form label { - font-size: 0.95rem; - color: var(--text-muted); -} - -.promo-form input { - width: 100%; - background: var(--bg-card-soft); - color: var(--text-main); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 12px; - font-size: 1rem; -} - -.promo-form input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); -} - -.promo-form button { - margin-top: 8px; - border: none; - border-radius: 12px; - background: linear-gradient(140deg, #2dd4bf, #4cc9f0); - color: #0b1116; - font-weight: 700; - font-size: 1rem; - padding: 14px; - cursor: pointer; -} - -.promo-form button:disabled { - opacity: 0.7; - cursor: default; -} - -.status { - margin-top: 14px; - padding: 12px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--border); -} - -.status-error { - color: #ffd6d6; - border-color: rgba(255, 111, 111, 0.6); - background: rgba(255, 111, 111, 0.12); -} - -.success { - margin-top: 16px; - border: 1px solid rgba(81, 255, 211, 0.35); - background: rgba(81, 255, 211, 0.08); - border-radius: 12px; - padding: 14px; -} - -.success h2 { - margin-top: 0; - font-size: 1.12rem; -} - -.success p { - margin-top: 8px; - line-height: 1.45; -} - -.success ul { - padding-left: 18px; - margin: 10px 0; - display: grid; - gap: 6px; - word-break: break-word; -} - -.success a { - color: var(--accent); -} - -.faq { - margin-top: 20px; -} - -.faq h3 { - margin-bottom: 10px; -} - -.faq details { - background: rgba(255, 255, 255, 0.02); - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 12px; - margin-bottom: 8px; -} - -.faq summary { - cursor: pointer; - font-weight: 600; -} - -.faq p { - margin-bottom: 4px; - color: var(--text-muted); - line-height: 1.45; -} - -.page-footer { - text-align: center; - color: #727a88; - font-size: 0.8rem; - padding-bottom: 18px; -} - -.hidden { - display: none; -} - -@media (min-width: 768px) { - .card { - padding: 26px; - } -} diff --git a/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js b/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js deleted file mode 100644 index df32781..0000000 --- a/SHiNE-promo-solana-devnet/src/main/resources/static/js/app.js +++ /dev/null @@ -1,82 +0,0 @@ -(() => { - const form = document.getElementById("promoForm"); - const walletInput = document.getElementById("wallet"); - const nameInput = document.getElementById("name"); - const promoCodeInput = document.getElementById("promoCode"); - const submitButton = document.getElementById("submitButton"); - - const loadingState = document.getElementById("loadingState"); - const errorState = document.getElementById("errorState"); - const successState = document.getElementById("successState"); - - const successAmount = document.getElementById("successAmount"); - const successWallet = document.getElementById("successWallet"); - const successName = document.getElementById("successName"); - const successSignature = document.getElementById("successSignature"); - const successExplorerUrl = document.getElementById("successExplorerUrl"); - - const params = new URLSearchParams(window.location.search); - const walletFromQuery = (params.get("wallet") || "").trim(); - if (walletFromQuery && !walletInput.value.trim()) { - walletInput.value = walletFromQuery; - } - - form.addEventListener("submit", async (event) => { - event.preventDefault(); - hideMessages(); - toggleLoading(true); - - try { - const payload = { - wallet: walletInput.value.trim(), - name: nameInput.value.trim(), - promoCode: promoCodeInput.value.trim() - }; - - const response = await fetch("/api/promo/top-up", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(payload) - }); - - const result = await response.json(); - if (!result.success) { - showError(result.message || "Не удалось выполнить операцию"); - return; - } - - showSuccess(result); - } catch (error) { - showError("Ошибка сети или недоступен backend"); - } finally { - toggleLoading(false); - } - }); - - function toggleLoading(active) { - submitButton.disabled = active; - loadingState.classList.toggle("hidden", !active); - submitButton.textContent = active ? "Отправляем..." : "Пополнить тестовый счёт"; - } - - function hideMessages() { - errorState.classList.add("hidden"); - successState.classList.add("hidden"); - } - - function showError(message) { - errorState.textContent = message; - errorState.classList.remove("hidden"); - } - - function showSuccess(data) { - successAmount.textContent = data.amountSol || "0.1"; - successWallet.textContent = data.wallet || "—"; - successName.textContent = data.name || "—"; - successSignature.textContent = data.signature || "—"; - successExplorerUrl.href = data.explorerUrl || "#"; - successState.classList.remove("hidden"); - } -})(); diff --git a/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html b/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html deleted file mode 100644 index fb0c853..0000000 --- a/SHiNE-promo-solana-devnet/src/main/resources/templates/index.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - SHiNE / Сияние — тестовое пополнение - - - -
-
-
-

SHiNE / Сияние — тестовое пополнение

-

- Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE. -

-
- -

- Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для - тестирования функций SHiNE. -

- -
- Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для - проверки работы приложения. -
- -
- - - - - - - - - - -
- - - - - - -
-

FAQ

- -
- Что такое SHiNE / Сияние? -

- SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий - связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и - паролем на обычном сервере. -

-
- -
- Зачем нужен тестовый баланс? -

- В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать - реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас - всё работает в Solana Devnet, поэтому средства тестовые. -

-
- -
- Сколько стоит регистрация? -

- Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое - тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта. -

-
- -
- Можно ли пригласить друга? -

- Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых - SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения, - звонки и взаимодействие внутри социальной сети. -

-
- -
- Это настоящие деньги? -

- Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и - используются только для разработки и тестирования. -

-
- -
- Почему в Web3 действия платные? -

- В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой - инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей - прозрачностью: пользователь понимает, за что платит, а ключи остаются у него. -

-
- -
- Кто хранит мои ключи? -

- Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только - публичный адрес кошелька Solana Devnet. -

-
-
-
-
- -
SHiNE Devnet Promo · temporary testing page
- - - - diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b2b9f0b..8cdd221 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -178,6 +178,8 @@ public final class DatabaseInitializer { client_ip TEXT, client_info_from_client TEXT, client_info_from_request TEXT, + session_type INTEGER NOT NULL DEFAULT 1, + client_platform TEXT NOT NULL DEFAULT '', user_language TEXT, FOREIGN KEY (login) REFERENCES solana_users(login) ); @@ -188,6 +190,47 @@ public final class DatabaseInitializer { ON active_sessions (login); """); + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + // 3. users_params st.executeUpdate(""" CREATE TABLE IF NOT EXISTS users_params ( @@ -575,6 +618,7 @@ public final class DatabaseInitializer { time_ms INTEGER NOT NULL, nonce INTEGER NOT NULL, message_type INTEGER NOT NULL, + revision_time_ms INTEGER NOT NULL DEFAULT 0, raw_block BLOB NOT NULL, created_at_ms INTEGER NOT NULL, source_api TEXT NOT NULL, diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 4f3fbf2..91e8d26 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 3; + private static final int LATEST_SCHEMA_VERSION = 7; private final String jdbcUrl; @@ -86,6 +86,10 @@ public final class SqliteDbController { case 1 -> migrateToV1(); case 2 -> migrateToV2(); case 3 -> migrateToV3(); + case 4 -> migrateToV4(); + case 5 -> migrateToV5(); + case 6 -> migrateToV6(); + case 7 -> migrateToV7(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -168,6 +172,83 @@ public final class SqliteDbController { } } + private void migrateToV4() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureActiveSessionsSessionTypeColumn(c, st); + ensureActiveSessionsClientPlatformColumn(c, st); + setSchemaVersion(c, 4); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v4 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v4 failed", e); + } + } + + private void migrateToV5() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureEspPairingTables(st); + setSchemaVersion(c, 5); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v5 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v5 failed", e); + } + } + + private void migrateToV6() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSignedMessagesRevisionColumn(c, st); + setSchemaVersion(c, 6); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v6 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v6 failed", e); + } + } + + private void migrateToV7() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + dropDmFileTables(st); + setSchemaVersion(c, 7); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v7 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v7 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -235,6 +316,85 @@ public final class SqliteDbController { } } + private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException { + if (columnExists(c, "active_sessions", "session_type")) return; + st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1"); + } + + private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException { + if (columnExists(c, "active_sessions", "client_platform")) return; + st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); + } + + private static void ensureEspPairingTables(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + } + + private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) { + st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0"); + } + } + + private static void dropDmFileTables(Statement st) throws SQLException { + st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); + st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); + st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links"); + st.executeUpdate("DROP TABLE IF EXISTS dm_files"); + } + + private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { + try (Statement probe = c.createStatement(); + ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { + while (rs.next()) { + if (columnName.equalsIgnoreCase(rs.getString("name"))) { + return true; + } + } + return false; + } + } + private static void setSchemaVersion(Connection c, int version) throws SQLException { try (var ps = c.prepareStatement(""" INSERT INTO db_schema_version (id, schema_version, updated_at_ms) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java index 9495f80..885dc8e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java @@ -47,8 +47,10 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -64,7 +66,9 @@ public final class ActiveSessionsDAO { ps.setString(10, session.getClientIp()); ps.setString(11, session.getClientInfoFromClient()); ps.setString(12, session.getClientInfoFromRequest()); - ps.setString(13, session.getUserLanguage()); + ps.setInt(13, session.getSessionType()); + ps.setString(14, session.getClientPlatform()); + ps.setString(15, session.getUserLanguage()); ps.executeUpdate(); } } @@ -92,6 +96,8 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language FROM active_sessions WHERE session_id = ? @@ -127,6 +133,8 @@ public final class ActiveSessionsDAO { client_ip, client_info_from_client, client_info_from_request, + session_type, + client_platform, user_language FROM active_sessions WHERE login = ? COLLATE NOCASE @@ -179,6 +187,8 @@ public final class ActiveSessionsDAO { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage ) throws SQLException { @@ -189,6 +199,8 @@ public final class ActiveSessionsDAO { client_ip = ?, client_info_from_client = ?, client_info_from_request = ?, + session_type = ?, + client_platform = ?, user_language = ? WHERE session_id = ? """; @@ -198,8 +210,10 @@ public final class ActiveSessionsDAO { ps.setString(2, clientIp); ps.setString(3, clientInfoFromClient); ps.setString(4, clientInfoFromRequest); - ps.setString(5, userLanguage); - ps.setString(6, sessionId); + ps.setInt(5, sessionType); + ps.setString(6, clientPlatform); + ps.setString(7, userLanguage); + ps.setString(8, sessionId); ps.executeUpdate(); } } @@ -210,10 +224,12 @@ public final class ActiveSessionsDAO { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage ) throws SQLException { try (Connection c = db.getConnection()) { - updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage); + updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, sessionType, clientPlatform, userLanguage); } } @@ -268,6 +284,8 @@ public final class ActiveSessionsDAO { String clientIp = rs.getString("client_ip"); String clientInfoFromClient = rs.getString("client_info_from_client"); String clientInfoFromRequest = rs.getString("client_info_from_request"); + int sessionType = rs.getInt("session_type"); + String clientPlatform = rs.getString("client_platform"); String userLanguage = rs.getString("user_language"); return new ActiveSessionEntry( @@ -283,6 +301,8 @@ public final class ActiveSessionsDAO { clientIp, clientInfoFromClient, clientInfoFromRequest, + sessionType, + clientPlatform, userLanguage ); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java new file mode 100644 index 0000000..9c90c97 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -0,0 +1,265 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingRequestEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public final class EspPairingRequestsDAO { + + private static volatile EspPairingRequestsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingRequestsDAO() { } + + public static EspPairingRequestsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingRequestsDAO.class) { + if (instance == null) instance = new EspPairingRequestsDAO(); + } + } + return instance; + } + + public void insert(EspPairingRequestEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, entry); + } + } + + public void insert(Connection c, EspPairingRequestEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_requests ( + pairing_id, + login, + requester_session_key, + requester_session_type, + requester_client_platform, + payload_type, + status, + short_code, + fingerprint_b58, + encrypted_payload, + reject_reason, + approved_by_session_id, + created_at_ms, + expires_at_ms, + updated_at_ms, + delivered_to_homeserver + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getPairingId()); + ps.setString(2, entry.getLogin()); + ps.setString(3, entry.getRequesterSessionKey()); + ps.setInt(4, entry.getRequesterSessionType()); + ps.setString(5, entry.getRequesterClientPlatform()); + ps.setInt(6, entry.getPayloadType()); + ps.setString(7, entry.getStatus()); + ps.setString(8, entry.getShortCode()); + ps.setString(9, entry.getFingerprintB58()); + ps.setString(10, entry.getEncryptedPayload()); + ps.setString(11, entry.getRejectReason()); + ps.setString(12, entry.getApprovedBySessionId()); + ps.setLong(13, entry.getCreatedAtMs()); + ps.setLong(14, entry.getExpiresAtMs()); + ps.setLong(15, entry.getUpdatedAtMs()); + ps.setInt(16, entry.isDeliveredToHomeserver() ? 1 : 0); + ps.executeUpdate(); + } + } + + public EspPairingRequestEntry getByPairingId(String pairingId) throws SQLException { + try (Connection c = db.getConnection()) { + return getByPairingId(c, pairingId); + } + } + + public EspPairingRequestEntry getByPairingId(Connection c, String pairingId) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE pairing_id = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, pairingId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public List listActiveByLogin(String login, long nowMs) throws SQLException { + try (Connection c = db.getConnection()) { + return listActiveByLogin(c, login, nowMs); + } + } + + public List listActiveByLogin(Connection c, String login, long nowMs) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND expires_at_ms > ? + AND status = 'created' + ORDER BY created_at_ms DESC + """; + List list = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setLong(2, nowMs); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) list.add(mapRow(rs)); + } + } + return list; + } + + public int countRecentByLoginAndStatuses(String login, long sinceMs, String... statuses) throws SQLException { + try (Connection c = db.getConnection()) { + StringBuilder sql = new StringBuilder(""" + SELECT COUNT(*) + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND created_at_ms >= ? + AND status IN ( + """); + for (int i = 0; i < statuses.length; i++) { + if (i > 0) sql.append(", "); + sql.append("?"); + } + sql.append(")"); + try (PreparedStatement ps = c.prepareStatement(sql.toString())) { + ps.setString(1, login); + ps.setLong(2, sinceMs); + for (int i = 0; i < statuses.length; i++) { + ps.setString(3 + i, statuses[i]); + } + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt(1) : 0; + } + } + } + } + + public void updateDeliveryFlag(String pairingId, boolean delivered, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET delivered_to_homeserver = ?, updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setInt(1, delivered ? 1 : 0); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + + public void markApproved(String pairingId, String encryptedPayload, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'approved', + encrypted_payload = ?, + approved_by_session_id = ?, + reject_reason = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, encryptedPayload); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public void markRejected(String pairingId, String rejectReason, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'rejected', + reject_reason = ?, + approved_by_session_id = ?, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public void markCanceled(String pairingId, String rejectReason, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'canceled', + reject_reason = ?, + approved_by_session_id = NULL, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + + public int expirePending(long nowMs) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE esp_pairing_requests + SET status = 'expired', + updated_at_ms = ? + WHERE status = 'created' + AND expires_at_ms <= ? + """)) { + ps.setLong(1, nowMs); + ps.setLong(2, nowMs); + return ps.executeUpdate(); + } + } + + private interface PreparedStatementSetter { + void accept(PreparedStatement ps) throws SQLException; + } + + private void updateSimple(String pairingId, String sql, PreparedStatementSetter setter) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + setter.accept(ps); + ps.executeUpdate(); + } + } + + private static EspPairingRequestEntry mapRow(ResultSet rs) throws SQLException { + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(rs.getString("pairing_id")); + entry.setLogin(rs.getString("login")); + entry.setRequesterSessionKey(rs.getString("requester_session_key")); + entry.setRequesterSessionType(rs.getInt("requester_session_type")); + entry.setRequesterClientPlatform(rs.getString("requester_client_platform")); + entry.setPayloadType(rs.getInt("payload_type")); + entry.setStatus(rs.getString("status")); + entry.setShortCode(rs.getString("short_code")); + entry.setFingerprintB58(rs.getString("fingerprint_b58")); + entry.setEncryptedPayload(rs.getString("encrypted_payload")); + entry.setRejectReason(rs.getString("reject_reason")); + entry.setApprovedBySessionId(rs.getString("approved_by_session_id")); + entry.setCreatedAtMs(rs.getLong("created_at_ms")); + entry.setExpiresAtMs(rs.getLong("expires_at_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + entry.setDeliveredToHomeserver(rs.getInt("delivered_to_homeserver") != 0); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java new file mode 100644 index 0000000..873ba7d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java @@ -0,0 +1,101 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingSettingsEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class EspPairingSettingsDAO { + + private static volatile EspPairingSettingsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingSettingsDAO() { } + + public static EspPairingSettingsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingSettingsDAO.class) { + if (instance == null) instance = new EspPairingSettingsDAO(); + } + } + return instance; + } + + public void upsert(EspPairingSettingsEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + upsert(c, entry); + } + } + + public void upsert(Connection c, EspPairingSettingsEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_settings ( + login, + enabled, + password_hash, + ttl_seconds, + failed_attempts, + first_failed_at_ms, + blocked_until_ms, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(login) DO UPDATE SET + enabled = excluded.enabled, + password_hash = excluded.password_hash, + ttl_seconds = excluded.ttl_seconds, + failed_attempts = excluded.failed_attempts, + first_failed_at_ms = excluded.first_failed_at_ms, + blocked_until_ms = excluded.blocked_until_ms, + updated_at_ms = excluded.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getLogin()); + ps.setInt(2, entry.isEnabled() ? 1 : 0); + ps.setString(3, entry.getPasswordHash()); + ps.setInt(4, entry.getTtlSeconds()); + ps.setInt(5, entry.getFailedAttempts()); + ps.setLong(6, entry.getFirstFailedAtMs()); + ps.setLong(7, entry.getBlockedUntilMs()); + ps.setLong(8, entry.getUpdatedAtMs()); + ps.executeUpdate(); + } + } + + public EspPairingSettingsEntry getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + public EspPairingSettingsEntry getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms + FROM esp_pairing_settings + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + private static EspPairingSettingsEntry mapRow(ResultSet rs) throws SQLException { + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(rs.getString("login")); + entry.setEnabled(rs.getInt("enabled") != 0); + entry.setPasswordHash(rs.getString("password_hash")); + entry.setTtlSeconds(rs.getInt("ttl_seconds")); + entry.setFailedAttempts(rs.getInt("failed_attempts")); + entry.setFirstFailedAtMs(rs.getLong("first_failed_at_ms")); + entry.setBlockedUntilMs(rs.getLong("blocked_until_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index f0a46e6..01ffa31 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -8,6 +8,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public final class SignedMessagesV2DAO { @@ -30,36 +31,17 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT OR IGNORE INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getMessageKey()); - ps.setString(2, e.getBaseKey()); - ps.setString(3, e.getTargetLogin()); - ps.setString(4, e.getFromLogin()); - ps.setString(5, e.getToLogin()); - ps.setLong(6, e.getTimeMs()); - ps.setLong(7, e.getNonce()); - ps.setInt(8, e.getMessageType()); - ps.setBytes(9, e.getRawBlock()); - ps.setLong(10, e.getCreatedAtMs()); - ps.setString(11, e.getSourceApi()); - ps.setString(12, e.getOriginSessionId()); - ps.setString(13, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(14, null); - else ps.setInt(14, e.getReceiptRefType()); + bindSignedMessage(ps, e); return ps.executeUpdate() > 0; } } } - /** - * Атомарная вставка пары блоков: либо вставляются оба, либо не вставляется ни один. - * Возвращает true только если обе записи добавлены в БД. - * Если хотя бы одна запись уже существует (или конфликтует по уникальности), возвращает false. - */ public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); @@ -85,37 +67,45 @@ public final class SignedMessagesV2DAO { } } - private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { - String sql = """ - INSERT INTO signed_messages_v2 ( - message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getMessageKey()); - ps.setString(2, e.getBaseKey()); - ps.setString(3, e.getTargetLogin()); - ps.setString(4, e.getFromLogin()); - ps.setString(5, e.getToLogin()); - ps.setLong(6, e.getTimeMs()); - ps.setLong(7, e.getNonce()); - ps.setInt(8, e.getMessageType()); - ps.setBytes(9, e.getRawBlock()); - ps.setLong(10, e.getCreatedAtMs()); - ps.setString(11, e.getSourceApi()); - ps.setString(12, e.getOriginSessionId()); - ps.setString(13, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(14, null); - else ps.setInt(14, e.getReceiptRefType()); - return ps.executeUpdate(); - } - } + public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); + Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); + long currentRevision = Math.max( + currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE, + currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE + ); + long nextRevision = incoming.getRevisionTimeMs(); - private boolean isConstraintViolation(SQLException ex) { - String msg = String.valueOf(ex.getMessage()).toLowerCase(); - return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); + if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) { + c.rollback(); + return false; + } + if (currentRevision != Long.MIN_VALUE + && nextRevision == currentRevision + && hasSameRawBlock(c, incoming) + && hasSameRawBlock(c, outgoing)) { + c.rollback(); + return false; + } + + upsertMessage(c, incoming); + upsertMessage(c, outgoing); + resetDeliveryRows(c, incoming.getMessageKey()); + resetDeliveryRows(c, outgoing.getMessageKey()); + + c.commit(); + return true; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } } public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { @@ -123,7 +113,7 @@ public final class SignedMessagesV2DAO { String sql = """ SELECT message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, raw_block, created_at_ms, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type FROM signed_messages_v2 WHERE message_key = ? @@ -203,13 +193,13 @@ public final class SignedMessagesV2DAO { String sql = """ SELECT m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, - m.time_ms, m.nonce, m.message_type, m.raw_block, m.created_at_ms, + m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type FROM signed_messages_v2 m JOIN signed_message_session_delivery d ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 - ORDER BY m.time_ms ASC, m.created_at_ms ASC + ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -222,6 +212,106 @@ public final class SignedMessagesV2DAO { } } + private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException { + String sql = """ + INSERT INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_key) DO UPDATE SET + base_key = excluded.base_key, + target_login = excluded.target_login, + from_login = excluded.from_login, + to_login = excluded.to_login, + time_ms = excluded.time_ms, + nonce = excluded.nonce, + message_type = excluded.message_type, + revision_time_ms = excluded.revision_time_ms, + raw_block = excluded.raw_block, + created_at_ms = excluded.created_at_ms, + source_api = excluded.source_api, + origin_session_id = excluded.origin_session_id, + receipt_ref_base_key = excluded.receipt_ref_base_key, + receipt_ref_type = excluded.receipt_ref_type + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + ps.executeUpdate(); + } + } + + private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException { + String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return rs.getLong(1); + } + } + } + + private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException { + String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getMessageKey()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return false; + return Arrays.equals(rs.getBytes(1), entry.getRawBlock()); + } + } + } + + private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE signed_message_session_delivery + SET delivered = 0, delivered_at_ms = NULL + WHERE message_key = ? + """)) { + ps.setString(1, messageKey); + ps.executeUpdate(); + } + } + + private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { + String sql = """ + INSERT INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, + source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + return ps.executeUpdate(); + } + } + + private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException { + ps.setString(1, e.getMessageKey()); + ps.setString(2, e.getBaseKey()); + ps.setString(3, e.getTargetLogin()); + ps.setString(4, e.getFromLogin()); + ps.setString(5, e.getToLogin()); + ps.setLong(6, e.getTimeMs()); + ps.setLong(7, e.getNonce()); + ps.setInt(8, e.getMessageType()); + ps.setLong(9, e.getRevisionTimeMs()); + ps.setBytes(10, e.getRawBlock()); + ps.setLong(11, e.getCreatedAtMs()); + ps.setString(12, e.getSourceApi()); + ps.setString(13, e.getOriginSessionId()); + ps.setString(14, e.getReceiptRefBaseKey()); + if (e.getReceiptRefType() == null) ps.setObject(15, null); + else ps.setInt(15, e.getReceiptRefType()); + } + + private boolean isConstraintViolation(SQLException ex) { + String msg = String.valueOf(ex.getMessage()).toLowerCase(); + return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); + } + private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { SignedMessageV2Entry e = new SignedMessageV2Entry(); e.setMessageKey(rs.getString("message_key")); @@ -232,6 +322,7 @@ public final class SignedMessagesV2DAO { e.setTimeMs(rs.getLong("time_ms")); e.setNonce(rs.getLong("nonce")); e.setMessageType(rs.getInt("message_type")); + e.setRevisionTimeMs(rs.getLong("revision_time_ms")); e.setRawBlock(rs.getBytes("raw_block")); e.setCreatedAtMs(rs.getLong("created_at_ms")); e.setSourceApi(rs.getString("source_api")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java index 2a2bb07..9c40c60 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ActiveSessionEntry.java @@ -22,6 +22,8 @@ public class ActiveSessionEntry { private String clientIp; private String clientInfoFromClient; private String clientInfoFromRequest; + private int sessionType; + private String clientPlatform; private String userLanguage; public ActiveSessionEntry() { } @@ -38,6 +40,8 @@ public class ActiveSessionEntry { String clientIp, String clientInfoFromClient, String clientInfoFromRequest, + int sessionType, + String clientPlatform, String userLanguage) { this.sessionId = sessionId; this.login = login; @@ -51,6 +55,8 @@ public class ActiveSessionEntry { this.clientIp = clientIp; this.clientInfoFromClient = clientInfoFromClient; this.clientInfoFromRequest = clientInfoFromRequest; + this.sessionType = sessionType; + this.clientPlatform = clientPlatform; this.userLanguage = userLanguage; } @@ -90,6 +96,12 @@ public class ActiveSessionEntry { public String getClientInfoFromRequest() { return clientInfoFromRequest; } public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; } + public int getSessionType() { return sessionType; } + public void setSessionType(int sessionType) { this.sessionType = sessionType; } + + public String getClientPlatform() { return clientPlatform; } + public void setClientPlatform(String clientPlatform) { this.clientPlatform = clientPlatform; } + public String getUserLanguage() { return userLanguage; } public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java new file mode 100644 index 0000000..f78e7d2 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java @@ -0,0 +1,149 @@ +package shine.db.entities; + +public class EspPairingRequestEntry { + + private String pairingId; + private String login; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String status; + private String shortCode; + private String fingerprintB58; + private String encryptedPayload; + private String rejectReason; + private String approvedBySessionId; + private long createdAtMs; + private long expiresAtMs; + private long updatedAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public String getApprovedBySessionId() { + return approvedBySessionId; + } + + public void setApprovedBySessionId(String approvedBySessionId) { + this.approvedBySessionId = approvedBySessionId; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java new file mode 100644 index 0000000..50aaee3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java @@ -0,0 +1,77 @@ +package shine.db.entities; + +public class EspPairingSettingsEntry { + + private String login; + private boolean enabled; + private String passwordHash; + private int ttlSeconds; + private int failedAttempts; + private long firstFailedAtMs; + private long blockedUntilMs; + private long updatedAtMs; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public int getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public long getFirstFailedAtMs() { + return firstFailedAtMs; + } + + public void setFirstFailedAtMs(long firstFailedAtMs) { + this.firstFailedAtMs = firstFailedAtMs; + } + + public long getBlockedUntilMs() { + return blockedUntilMs; + } + + public void setBlockedUntilMs(long blockedUntilMs) { + this.blockedUntilMs = blockedUntilMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java index 5ded20b..59e86dc 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -9,6 +9,7 @@ public class SignedMessageV2Entry { private long timeMs; private long nonce; private int messageType; + private long revisionTimeMs; private byte[] rawBlock; private long createdAtMs; private String sourceApi; @@ -32,6 +33,8 @@ public class SignedMessageV2Entry { public void setNonce(long nonce) { this.nonce = nonce; } public int getMessageType() { return messageType; } public void setMessageType(int messageType) { this.messageType = messageType; } + public long getRevisionTimeMs() { return revisionTimeMs; } + public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; } public byte[] getRawBlock() { return rawBlock; } public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; } public long getCreatedAtMs() { return createdAtMs; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 1c2176d..ecf050a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -7,20 +7,36 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_CancelEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_GetTrustedDeviceLoginSettings_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; // --- NEW v2 session login --- import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_StartEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_Handler; // --- auth entities --- import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; // --- NEW v2 entities --- +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; @@ -121,6 +137,21 @@ public final class JsonHandlerRegistry { // --- login to existing session in 2 steps --- Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), Map.entry("SessionLogin", new Net_SessionLogin_Handler()), + Map.entry("UpsertEspPairingSettings", new Net_UpsertEspPairingSettings_Handler()), + Map.entry("StartEspPairing", new Net_StartEspPairing_Handler()), + Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()), + Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()), + Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), + Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()), + Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), + Map.entry("GetTrustedDeviceLoginSettings", new Net_GetTrustedDeviceLoginSettings_Handler()), + Map.entry("UpsertTrustedDeviceLoginSettings", new Net_UpsertEspPairingSettings_Handler()), + Map.entry("StartTrustedDeviceLogin", new Net_StartEspPairing_Handler()), + Map.entry("ListTrustedDeviceLoginRequests", new Net_ListEspPairingRequests_Handler()), + Map.entry("ApproveTrustedDeviceLogin", new Net_ApproveEspPairing_Handler()), + Map.entry("RejectTrustedDeviceLogin", new Net_RejectEspPairing_Handler()), + Map.entry("CancelTrustedDeviceLogin", new Net_CancelEspPairing_Handler()), + Map.entry("GetTrustedDeviceLoginStatus", new Net_GetEspPairingStatus_Handler()), // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), @@ -179,6 +210,21 @@ public final class JsonHandlerRegistry { // --- NEW v2 --- Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), Map.entry("SessionLogin", Net_SessionLogin_Request.class), + Map.entry("UpsertEspPairingSettings", Net_UpsertEspPairingSettings_Request.class), + Map.entry("StartEspPairing", Net_StartEspPairing_Request.class), + Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class), + Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class), + Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), + Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class), + Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), + Map.entry("GetTrustedDeviceLoginSettings", Net_GetTrustedDeviceLoginSettings_Request.class), + Map.entry("UpsertTrustedDeviceLoginSettings", Net_UpsertEspPairingSettings_Request.class), + Map.entry("StartTrustedDeviceLogin", Net_StartEspPairing_Request.class), + Map.entry("ListTrustedDeviceLoginRequests", Net_ListEspPairingRequests_Request.class), + Map.entry("ApproveTrustedDeviceLogin", Net_ApproveEspPairing_Request.class), + Map.entry("RejectTrustedDeviceLogin", Net_RejectEspPairing_Request.class), + Map.entry("CancelTrustedDeviceLogin", Net_CancelEspPairing_Request.class), + Map.entry("GetTrustedDeviceLoginStatus", Net_GetEspPairingStatus_Request.class), // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java new file mode 100644 index 0000000..f5fbb28 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/AuthSessionTypeSupport.java @@ -0,0 +1,46 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; + +import java.util.Base64; + +final class AuthSessionTypeSupport { + + static final int SESSION_TYPE_CLIENT = 1; + static final int SESSION_TYPE_WALLET = 50; + static final int SESSION_TYPE_HOMESERVER = 100; + static final int SESSION_TYPE_MISMATCH_STATUS = 460; + + private AuthSessionTypeSupport() {} + + static int normalizeRequestedSessionType(Integer rawType) { + return rawType == null ? SESSION_TYPE_CLIENT : rawType.intValue(); + } + + static boolean isSupportedSessionType(int sessionType) { + return sessionType == SESSION_TYPE_CLIENT + || sessionType == SESSION_TYPE_WALLET + || sessionType == SESSION_TYPE_HOMESERVER; + } + + static String normalizeClientPlatform(String clientPlatform) { + if (clientPlatform == null) return ""; + String trimmed = clientPlatform.trim(); + if (trimmed.length() <= 64) return trimmed; + return trimmed.substring(0, 64); + } + + static byte[] tryParseSessionPublicKey32(String sessionKeyApi) { + if (sessionKeyApi == null || sessionKeyApi.isBlank()) return null; + try { + return AuthKeyUtils.parseEd25519PublicKey(sessionKeyApi, "sessionKey"); + } catch (Exception ignored) { + try { + byte[] raw = Base64.getDecoder().decode(sessionKeyApi.trim()); + return raw.length == 32 ? raw : null; + } catch (Exception ignoredToo) { + return null; + } + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java new file mode 100644 index 0000000..56b484d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java @@ -0,0 +1,189 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import org.eclipse.jetty.websocket.api.Session; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import shine.db.entities.ActiveSessionEntry; +import utils.crypto.HashSHA256Util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; + +final class EspPairingSupport { + + static final int DEFAULT_TTL_SECONDS = 300; + static final int MIN_TTL_SECONDS = 60; + static final int MAX_TTL_SECONDS = 1800; + static final int REQUEST_RATE_LIMIT = 5; + static final long REQUEST_RATE_WINDOW_MS = 5 * 60_000L; + + static final int STATUS_PAIRING_REQUIRES_AUTH_SESSION = 463; + static final int STATUS_PAIRING_RATE_LIMIT = 429; + + static final String STATE_CREATED = "created"; + static final String STATE_APPROVED = "approved"; + static final String STATE_REJECTED = "rejected"; + static final String STATE_CANCELED = "canceled"; + static final String STATE_EXPIRED = "expired"; + static final String PASSWORD_HASH_PREFIX = "sha256$"; + static final String PASSWORD_HASH_VERSION = "shine-pairing"; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); + + private EspPairingSupport() {} + + static boolean isTrustedUserSession(ConnectionContext ctx) { + if (ctx == null || !ctx.isAuthenticatedUser()) return false; + ActiveSessionEntry activeSession = ctx.getActiveSession(); + return activeSession != null && activeSession.getSessionId() != null && !activeSession.getSessionId().isBlank(); + } + + static List findOnlineTrustedConnections(String login) { + List out = new ArrayList<>(); + for (ConnectionContext candidate : ActiveConnectionsRegistry.getInstance().getByLogin(login)) { + if (!isTrustedUserSession(candidate)) continue; + Session ws = candidate.getWsSession(); + if (ws == null || !ws.isOpen()) continue; + out.add(candidate); + } + return out; + } + + static int normalizeTtlSeconds(Integer raw) { + if (raw == null) return DEFAULT_TTL_SECONDS; + int value = raw; + if (value < MIN_TTL_SECONDS) return MIN_TTL_SECONDS; + if (value > MAX_TTL_SECONDS) return MAX_TTL_SECONDS; + return value; + } + + static int normalizePayloadType(Integer raw) { + if (raw == null) return 1; + return raw; + } + + static boolean isSupportedPayloadType(int payloadType) { + return payloadType >= 1 && payloadType <= 3; + } + + static String normalizeOpaqueHash(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 512) return value.substring(0, 512); + return value; + } + + static String normalizePasswordHash(String raw) { + String value = normalizeOpaqueHash(raw); + if (value == null) return null; + if (!value.regionMatches(true, 0, PASSWORD_HASH_PREFIX, 0, PASSWORD_HASH_PREFIX.length())) { + return null; + } + String hex = value.substring(PASSWORD_HASH_PREFIX.length()).trim().toLowerCase(Locale.ROOT); + if (hex.length() != 64) return null; + for (int i = 0; i < hex.length(); i++) { + char ch = hex.charAt(i); + boolean ok = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'); + if (!ok) return null; + } + return PASSWORD_HASH_PREFIX + hex; + } + + static String derivePasswordHash(String loginRaw, String passwordRaw) { + String login = loginRaw == null ? "" : loginRaw.trim().toLowerCase(Locale.ROOT); + String password = passwordRaw == null ? "" : passwordRaw; + String preimage = PASSWORD_HASH_VERSION + "|" + login + "|" + password; + byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8)); + return PASSWORD_HASH_PREFIX + toHexLower(digest); + } + + static String normalizeEncryptedPayload(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 32768) return value.substring(0, 32768); + return value; + } + + static String normalizeReason(String raw) { + if (raw == null) return ""; + String value = raw.trim(); + if (value.length() <= 160) return value; + return value.substring(0, 160); + } + + static PairingFingerprint deriveFingerprint(String login, + String requesterSessionKey, + int requesterSessionType, + String clientPlatform, + int payloadType, + long createdAtMs) throws Exception { + String canonical = (login == null ? "" : login.toLowerCase(Locale.ROOT).trim()) + + "|" + requesterSessionKey + + "|" + requesterSessionType + + "|" + (clientPlatform == null ? "" : clientPlatform.trim()) + + "|" + payloadType + + "|" + createdAtMs; + byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + long code = ((digest[0] & 0xFFL) << 24) + | ((digest[1] & 0xFFL) << 16) + | ((digest[2] & 0xFFL) << 8) + | (digest[3] & 0xFFL); + long shortCodeNum = code % 10_000_000L; + String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum); + return new PairingFingerprint(shortCode, toBase58(digest)); + } + + static String newPairingId() { + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + static String toBase58(byte[] input) { + if (input == null || input.length == 0) return ""; + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) zeros++; + byte[] copy = input.clone(); + byte[] tmp = new byte[copy.length * 2]; + int j = tmp.length; + int startAt = zeros; + while (startAt < copy.length) { + int mod = divmod58(copy, startAt); + if (copy[startAt] == 0) startAt++; + tmp[--j] = (byte) BASE58[mod]; + } + while (j < tmp.length && tmp[j] == BASE58[0]) j++; + while (--zeros >= 0) tmp[--j] = (byte) BASE58[0]; + return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII); + } + + private static int divmod58(byte[] number, int startAt) { + int remainder = 0; + for (int i = startAt; i < number.length; i++) { + int digit256 = number[i] & 0xFF; + int temp = remainder * 256 + digit256; + number[i] = (byte) (temp / 58); + remainder = temp % 58; + } + return remainder; + } + + private static String toHexLower(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >>> 4) & 0x0F, 16)); + sb.append(Character.forDigit(b & 0x0F, 16)); + } + return sb.toString(); + } + + record PairingFingerprint(String shortCode, String fingerprintB58) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java new file mode 100644 index 0000000..90c0719 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java @@ -0,0 +1,63 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_ApproveEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ApproveEspPairing_Request req = (Net_ApproveEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String encryptedPayload = EspPairingSupport.normalizeEncryptedPayload(req.getEncryptedPayload()); + if (encryptedPayload == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_ENCRYPTED_PAYLOAD", "Пустой encryptedPayload"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя подтверждать pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + if (row.getExpiresAtMs() <= now) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_EXPIRED", "Срок действия pairing-заявки истёк"); + } + + EspPairingRequestsDAO.getInstance().markApproved(pairingId, encryptedPayload, ctx.getSessionId(), now); + + Net_ApproveEspPairing_Response resp = new Net_ApproveEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_APPROVED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java new file mode 100644 index 0000000..67b14cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_CancelEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_CancelEspPairing_Request req = (Net_CancelEspPairing_Request) baseReq; + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!requesterSessionKey.equals(row.getRequesterSessionKey())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_REQUESTER", "Нельзя отменять pairing другого устройства"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markCanceled(pairingId, "canceled_by_requester", now); + + Net_CancelEspPairing_Response resp = new Net_CancelEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_CANCELED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index fb6759c..be89190 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -213,6 +213,19 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } + int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) { + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_SESSION_TYPE", + "Неподдерживаемый sessionType" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: bad sessionType"); + return err; + } + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); + String deviceKeyFromDb = user.getDeviceKey(); if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( @@ -315,6 +328,35 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } + SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck; + try { + sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda( + canonicalLogin, + sessionKey, + requestedSessionType + ); + } catch (Exception e) { + log.error("Ошибка проверки sessionType по Solana PDA для login={}", canonicalLogin, e); + Net_Response err = NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "SESSION_TYPE_PDA_CHECK_FAILED", + "Ошибка проверки sessionType в Solana PDA" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType pda check"); + return err; + } + if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) { + Net_Response err = NetExceptionResponseFactory.error( + req, + AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS, + "SESSION_TYPE_MISMATCH", + "sessionType не совпадает с типом сессии в Solana PDA" + ); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: sessionType mismatch"); + return err; + } + // --- генерируем sessionId --- String sessionId = generateRandom32B64Url(); long now = System.currentTimeMillis(); @@ -356,6 +398,8 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { clientIp, clientInfoFromClient, clientInfoFromRequest, + requestedSessionType, + clientPlatform, userLanguage ); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java new file mode 100644 index 0000000..957ed26 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java @@ -0,0 +1,50 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_GetEspPairingStatus_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_GetEspPairingStatus_Request req = (Net_GetEspPairingStatus_Request) baseReq; + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + + String state = row.getStatus(); + if (row.getExpiresAtMs() <= now && EspPairingSupport.STATE_CREATED.equals(state)) { + state = EspPairingSupport.STATE_EXPIRED; + } + + Net_GetEspPairingStatus_Response resp = new Net_GetEspPairingStatus_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(row.getPairingId()); + resp.setState(state); + resp.setShortCode(row.getShortCode()); + resp.setFingerprintB58(row.getFingerprintB58()); + resp.setPayloadType(row.getPayloadType()); + resp.setEncryptedPayload(row.getEncryptedPayload()); + resp.setRejectReason(row.getRejectReason()); + resp.setExpiresAtMs(row.getExpiresAtMs()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetTrustedDeviceLoginSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetTrustedDeviceLoginSettings_Handler.java new file mode 100644 index 0000000..db6e111 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetTrustedDeviceLoginSettings_Handler.java @@ -0,0 +1,43 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.entities.EspPairingSettingsEntry; + +public class Net_GetTrustedDeviceLoginSettings_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_GetTrustedDeviceLoginSettings_Request req = (Net_GetTrustedDeviceLoginSettings_Request) baseReq; + + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + EspPairingSettingsEntry entry = EspPairingSettingsDAO.getInstance().getByLogin(ctx.getLogin()); + boolean enabled = entry == null || entry.isEnabled(); + boolean hasPassword = enabled + && entry != null + && entry.getPasswordHash() != null + && !entry.getPasswordHash().trim().isBlank(); + + Net_GetTrustedDeviceLoginSettings_Response resp = new Net_GetTrustedDeviceLoginSettings_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(200); + resp.setEnabled(enabled); + resp.setHasPassword(hasPassword); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java new file mode 100644 index 0000000..5423b6e --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ListEspPairingRequests_Request req = (Net_ListEspPairingRequests_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + List rows = EspPairingRequestsDAO.getInstance().listActiveByLogin(ctx.getLogin(), now); + List items = new ArrayList<>(); + for (EspPairingRequestEntry row : rows) { + Net_ListEspPairingRequests_Response.PairingRequestItem item = new Net_ListEspPairingRequests_Response.PairingRequestItem(); + item.setPairingId(row.getPairingId()); + item.setState(row.getStatus()); + item.setRequesterSessionKey(row.getRequesterSessionKey()); + item.setRequesterSessionType(row.getRequesterSessionType()); + item.setRequesterClientPlatform(row.getRequesterClientPlatform()); + item.setPayloadType(row.getPayloadType()); + item.setShortCode(row.getShortCode()); + item.setFingerprintB58(row.getFingerprintB58()); + item.setCreatedAtMs(row.getCreatedAtMs()); + item.setExpiresAtMs(row.getExpiresAtMs()); + item.setDeliveredToHomeserver(row.isDeliveredToHomeserver()); + items.add(item); + } + + Net_ListEspPairingRequests_Response resp = new Net_ListEspPairingRequests_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setRequests(items); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index 50e73b9..e53a24d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -2,6 +2,7 @@ package server.logic.ws_protocol.JSON.handlers.auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; @@ -66,6 +67,9 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { info.setSessionId(s.getSessionId()); info.setClientInfoFromClient(s.getClientInfoFromClient()); info.setClientInfoFromRequest(s.getClientInfoFromRequest()); + info.setSessionType(s.getSessionType()); + info.setClientPlatform(s.getClientPlatform()); + info.setOnlineOnThisServer(ActiveConnectionsRegistry.getInstance().getBySessionId(s.getSessionId()) != null); info.setLastAuthenticatedAtMs(s.getLastAuthirificatedAtMs()); String ip = s.getClientIp(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java new file mode 100644 index 0000000..d73d143 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_RejectEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_RejectEspPairing_Request req = (Net_RejectEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String reason = EspPairingSupport.normalizeReason(req.getReason()); + if (reason.isBlank()) reason = "rejected_by_homeserver"; + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя отклонять pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markRejected(pairingId, reason, ctx.getSessionId(), now); + + Net_RejectEspPairing_Response resp = new Net_RejectEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_REJECTED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index 8adac28..7859c3e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -216,6 +216,16 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { clientInfoFromClient = clientInfoFromClient.substring(0, 50); } + int requestedSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requestedSessionType)) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_SESSION_TYPE", + "Неподдерживаемый sessionType" + ); + } + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); String clientIp = null; String clientInfoFromRequest = null; @@ -235,6 +245,31 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { } } + SolanaUserPdaImportService.SessionTypeCheckResult sessionTypeCheck; + try { + sessionTypeCheck = SolanaUserPdaImportService.checkSessionTypeAgainstPda( + session.getLogin(), + sessionKeyFromReq, + requestedSessionType + ); + } catch (Exception e) { + log.error("Ошибка проверки sessionType по Solana PDA для login={} sessionId={}", session.getLogin(), sessionId, e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.SERVER_DATA_ERROR, + "SESSION_TYPE_PDA_CHECK_FAILED", + "Ошибка проверки sessionType в Solana PDA" + ); + } + if (sessionTypeCheck.hasPdaSessionRecord() && !sessionTypeCheck.matchesRequestedType()) { + return NetExceptionResponseFactory.error( + req, + AuthSessionTypeSupport.SESSION_TYPE_MISMATCH_STATUS, + "SESSION_TYPE_MISMATCH", + "sessionType не совпадает с типом сессии в Solana PDA" + ); + } + long now = System.currentTimeMillis(); try { ActiveSessionsDAO.getInstance().updateOnRefresh( @@ -243,6 +278,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { clientIp, clientInfoFromClient, clientInfoFromRequest, + requestedSessionType, + clientPlatform, userLanguage ); } catch (SQLException e) { @@ -253,6 +290,8 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { session.setClientIp(clientIp); session.setClientInfoFromClient(clientInfoFromClient); session.setClientInfoFromRequest(clientInfoFromRequest); + session.setSessionType(requestedSessionType); + session.setClientPlatform(clientPlatform); session.setUserLanguage(userLanguage); // ctx diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java new file mode 100644 index 0000000..38dc3fb --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -0,0 +1,167 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Response; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.JSON.utils.NetIdGenerator; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.EspPairingRequestEntry; +import shine.db.entities.EspPairingSettingsEntry; +import shine.db.entities.SolanaUserEntry; + +import java.util.List; + +public class Net_StartEspPairing_Handler implements JsonMessageHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_StartEspPairing_Request req = (Net_StartEspPairing_Request) baseReq; + + String login = req.getLogin() == null ? "" : req.getLogin().trim(); + if (login.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + int requesterSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getRequesterSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requesterSessionType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_TYPE", "Неподдерживаемый requesterSessionType"); + } + int payloadType = EspPairingSupport.normalizePayloadType(req.getPayloadType()); + if (!EspPairingSupport.isSupportedPayloadType(payloadType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3"); + } + String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash); + if (rawPasswordHash != null && passwordHash == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>"); + } + + SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + if (user == null) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + String canonicalLogin = user.getLogin(); + + EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); + boolean enabled = settings == null || settings.isEnabled(); + if (!enabled) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + if (settings.getBlockedUntilMs() > now) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток"); + } + int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses( + canonicalLogin, + now - EspPairingSupport.REQUEST_RATE_WINDOW_MS, + EspPairingSupport.STATE_CREATED, + EspPairingSupport.STATE_REJECTED + ); + if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время"); + } + String configuredPasswordHash = settings == null || settings.getPasswordHash() == null + ? "" + : settings.getPasswordHash().trim(); + boolean requiresPassword = !configuredPasswordHash.isBlank(); + boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank(); + if ((requiresPassword && !configuredPasswordHash.equals(passwordHash)) + || (!requiresPassword && suppliedPassword)) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); + } + + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); + int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS; + List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); + if (approverConnections.isEmpty()) { + return NetExceptionResponseFactory.error( + req, + 422, + "PAIRING_NO_TRUSTED_SESSION_ONLINE", + "Нет ни одной активной доверенной сессии пользователя в сети" + ); + } + EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( + canonicalLogin, + requesterSessionKey, + requesterSessionType, + clientPlatform, + payloadType, + now + ); + + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(EspPairingSupport.newPairingId()); + entry.setLogin(canonicalLogin); + entry.setRequesterSessionKey(requesterSessionKey); + entry.setRequesterSessionType(requesterSessionType); + entry.setRequesterClientPlatform(clientPlatform); + entry.setPayloadType(payloadType); + entry.setStatus(EspPairingSupport.STATE_CREATED); + entry.setShortCode(fingerprint.shortCode()); + entry.setFingerprintB58(fingerprint.fingerprintB58()); + entry.setCreatedAtMs(now); + entry.setExpiresAtMs(now + ttlSeconds * 1000L); + entry.setUpdatedAtMs(now); + entry.setDeliveredToHomeserver(false); + EspPairingRequestsDAO.getInstance().insert(entry); + + boolean delivered = false; + for (ConnectionContext targetCtx : approverConnections) { + String eventId = NetIdGenerator.eventId("pair"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("pairingId", entry.getPairingId()); + payload.put("login", canonicalLogin); + payload.put("requesterSessionKey", requesterSessionKey); + payload.put("requesterSessionType", requesterSessionType); + payload.put("requesterClientPlatform", clientPlatform); + payload.put("payloadType", payloadType); + payload.put("shortCode", entry.getShortCode()); + payload.put("fingerprintB58", entry.getFingerprintB58()); + payload.put("createdAtMs", entry.getCreatedAtMs()); + payload.put("expiresAtMs", entry.getExpiresAtMs()); + delivered |= WsEventSender.sendEvent(targetCtx, "IncomingTrustedDeviceLoginRequest", eventId, payload); + } + if (delivered) { + EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis()); + } + + Net_StartEspPairing_Response resp = new Net_StartEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(entry.getPairingId()); + resp.setState(entry.getStatus()); + resp.setShortCode(entry.getShortCode()); + resp.setFingerprintB58(entry.getFingerprintB58()); + resp.setExpiresAtMs(entry.getExpiresAtMs()); + resp.setTrustedSessionOnline(!approverConnections.isEmpty()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java new file mode 100644 index 0000000..d838f62 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.entities.EspPairingSettingsEntry; + +public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_UpsertEspPairingSettings_Request req = (Net_UpsertEspPairingSettings_Request) baseReq; + + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + boolean enabled = req.getEnabled() != null && req.getEnabled(); + String rawPasswordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + String passwordHash = EspPairingSupport.normalizePasswordHash(rawPasswordHash); + if (rawPasswordHash != null && passwordHash == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_PASSWORD_HASH_FORMAT", + "passwordHash должен быть пустым или иметь формат sha256$<64 hex>" + ); + } + long now = System.currentTimeMillis(); + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(ctx.getLogin()); + entry.setEnabled(enabled); + entry.setPasswordHash(enabled && passwordHash != null ? passwordHash : ""); + entry.setTtlSeconds(EspPairingSupport.DEFAULT_TTL_SECONDS); + entry.setFailedAttempts(0); + entry.setFirstFailedAtMs(0L); + entry.setBlockedUntilMs(0L); + entry.setUpdatedAtMs(now); + EspPairingSettingsDAO.getInstance().upsert(entry); + + Net_UpsertEspPairingSettings_Response resp = new Net_UpsertEspPairingSettings_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setEnabled(enabled); + resp.setHasPassword(enabled && passwordHash != null && !passwordHash.isBlank()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 3183fbe..6b76347 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -14,7 +14,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; +import java.util.Locale; /** * Lazy-import пользователя из Solana PDA в локальную БД сервера. @@ -57,6 +60,28 @@ public final class SolanaUserPdaImportService { return usersDao.getByLogin(login); } + public static SessionTypeCheckResult checkSessionTypeAgainstPda(String loginRaw, String sessionKeyApi, int requestedSessionType) throws Exception { + String login = normalizeLogin(loginRaw); + if (login == null) return SessionTypeCheckResult.noRecord(); + + ParsedSolanaUser parsed = fetchFromSolana(login); + if (parsed == null) return SessionTypeCheckResult.noRecord(); + + byte[] sessionPubKey32 = AuthSessionTypeSupport.tryParseSessionPublicKey32(sessionKeyApi); + if (sessionPubKey32 == null) return SessionTypeCheckResult.noRecord(); + + for (ParsedSessionRecord session : parsed.sessions()) { + if (constantTimeEquals(session.sessionPubKey32(), sessionPubKey32)) { + if (session.sessionType() == requestedSessionType) { + return SessionTypeCheckResult.match(session.sessionType(), session.sessionName()); + } + return SessionTypeCheckResult.mismatch(session.sessionType(), session.sessionName()); + } + } + + return SessionTypeCheckResult.noRecord(); + } + private static ParsedSolanaUser fetchFromSolana(String login) throws Exception { String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8)); String lenB58 = toBase58(new byte[]{(byte) login.length()}); @@ -135,6 +160,7 @@ public final class SolanaUserPdaImportService { byte[] blockchainKey32 = null; byte[] deviceKey32 = null; long paidLimitBytes = 0L; + List sessions = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { int blockType = u8(raw, c++); @@ -196,11 +222,19 @@ public final class SolanaUserPdaImportService { int sessionsCount = u8(raw, c++); if (sessionsCount > 64) return null; for (int j = 0; j < sessionsCount; j++) { - c += 1; // session_type - c += 1; // session_version + int sessionType = u8(raw, c++); + int sessionVersion = u8(raw, c++); int n = u8(raw, c++); + String sessionName = new String(raw, c, n, StandardCharsets.UTF_8); c += n; - c += 32; // session_pub_key + byte[] sessionPubKey32 = slice(raw, c, 32); + c += 32; + sessions.add(new ParsedSessionRecord( + sessionType, + sessionVersion, + sessionName, + sessionPubKey32 + )); } } else if (blockType == 70) { c += 1; @@ -217,7 +251,8 @@ public final class SolanaUserPdaImportService { blockchainName, Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(deviceKey32), - paidLimitBytes + paidLimitBytes, + sessions ); } @@ -225,7 +260,7 @@ public final class SolanaUserPdaImportService { if (login == null) return null; String s = login.trim(); if (s.isEmpty()) return null; - return s.toLowerCase(); + return s.toLowerCase(Locale.ROOT); } private static int u8(byte[] b, int o) { return b[o] & 0xFF; } @@ -272,11 +307,45 @@ public final class SolanaUserPdaImportService { return remainder; } + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a == null || b == null || a.length != b.length) return false; + int diff = 0; + for (int i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + private record ParsedSolanaUser( String login, String blockchainName, String blockchainKeyB64, String deviceKeyB64, - long paidLimitBytes + long paidLimitBytes, + List sessions ) {} + + private record ParsedSessionRecord( + int sessionType, + int sessionVersion, + String sessionName, + byte[] sessionPubKey32 + ) {} + + public record SessionTypeCheckResult( + boolean hasPdaSessionRecord, + boolean matchesRequestedType, + int pdaSessionType, + String sessionName + ) { + static SessionTypeCheckResult noRecord() { + return new SessionTypeCheckResult(false, true, 0, ""); + } + + static SessionTypeCheckResult match(int pdaSessionType, String sessionName) { + return new SessionTypeCheckResult(true, true, pdaSessionType, sessionName == null ? "" : sessionName); + } + + static SessionTypeCheckResult mismatch(int pdaSessionType, String sessionName) { + return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName); + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java new file mode 100644 index 0000000..61d49d4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ApproveEspPairing_Request extends Net_Request { + private String pairingId; + private String encryptedPayload; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java new file mode 100644 index 0000000..9052198 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_ApproveEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java new file mode 100644 index 0000000..24196ef --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_CancelEspPairing_Request extends Net_Request { + private String pairingId; + private String requesterSessionKey; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java new file mode 100644 index 0000000..575cb2a --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_CancelEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java index cda4f80..91fa95b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java @@ -41,6 +41,12 @@ public class Net_CreateAuthSession_Request extends Net_Request { /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ private String clientInfo; + /** Числовой код типа сессии. */ + private Integer sessionType; + + /** Свободная строка платформы клиента, например Web / Android / ESP32. */ + private String clientPlatform; + public String getLogin() { return login; } @@ -104,4 +110,20 @@ public class Net_CreateAuthSession_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } + + public Integer getSessionType() { + return sessionType; + } + + public void setSessionType(Integer sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java new file mode 100644 index 0000000..3a071da --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java @@ -0,0 +1,15 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetEspPairingStatus_Request extends Net_Request { + private String pairingId; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java new file mode 100644 index 0000000..8683d02 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java @@ -0,0 +1,78 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetEspPairingStatus_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private int payloadType; + private String encryptedPayload; + private String rejectReason; + private long expiresAtMs; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Request.java new file mode 100644 index 0000000..26cf97c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetTrustedDeviceLoginSettings_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Response.java new file mode 100644 index 0000000..b03afa3 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetTrustedDeviceLoginSettings_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetTrustedDeviceLoginSettings_Response extends Net_Response { + private boolean enabled; + private boolean hasPassword; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isHasPassword() { + return hasPassword; + } + + public void setHasPassword(boolean hasPassword) { + this.hasPassword = hasPassword; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java new file mode 100644 index 0000000..12cc85f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ListEspPairingRequests_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java new file mode 100644 index 0000000..31395f8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java @@ -0,0 +1,120 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Response extends Net_Response { + private List requests = new ArrayList<>(); + + public List getRequests() { + return requests; + } + + public void setRequests(List requests) { + this.requests = requests; + } + + public static class PairingRequestItem { + private String pairingId; + private String state; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String shortCode; + private String fingerprintB58; + private long createdAtMs; + private long expiresAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java index 08219d1..00ae36b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListSessions_Response.java @@ -52,6 +52,15 @@ public class Net_ListSessions_Response extends Net_Response { /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ private String clientInfoFromRequest; + /** Числовой код типа сессии. */ + private int sessionType; + + /** Свободная строка платформы, как её прислал клиент. */ + private String clientPlatform; + + /** Подключена ли эта сессия прямо сейчас к данному серверу. */ + private boolean onlineOnThisServer; + /** Строка геолокации вида "Country, City" или "unknown". */ private String geo; @@ -84,6 +93,30 @@ public class Net_ListSessions_Response extends Net_Response { this.clientInfoFromRequest = clientInfoFromRequest; } + public int getSessionType() { + return sessionType; + } + + public void setSessionType(int sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } + + public boolean isOnlineOnThisServer() { + return onlineOnThisServer; + } + + public void setOnlineOnThisServer(boolean onlineOnThisServer) { + this.onlineOnThisServer = onlineOnThisServer; + } + public String getGeo() { return geo; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java new file mode 100644 index 0000000..8d290c5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_RejectEspPairing_Request extends Net_Request { + private String pairingId; + private String reason; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java new file mode 100644 index 0000000..59b06f3 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_RejectEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java index e1f1d4d..51483f9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_SessionLogin_Request.java @@ -21,6 +21,12 @@ public class Net_SessionLogin_Request extends Net_Request { /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ private String clientInfo; + /** Числовой код типа сессии. */ + private Integer sessionType; + + /** Свободная строка платформы клиента, например Web / Android / ESP32. */ + private String clientPlatform; + public String getSessionId() { return sessionId; } @@ -60,4 +66,20 @@ public class Net_SessionLogin_Request extends Net_Request { public void setClientInfo(String clientInfo) { this.clientInfo = clientInfo; } + + public Integer getSessionType() { + return sessionType; + } + + public void setSessionType(Integer sessionType) { + this.sessionType = sessionType; + } + + public String getClientPlatform() { + return clientPlatform; + } + + public void setClientPlatform(String clientPlatform) { + this.clientPlatform = clientPlatform; + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java new file mode 100644 index 0000000..c5568a8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_StartEspPairing_Request extends Net_Request { + private String login; + private String passwordHash; + private String requesterSessionKey; + private Integer requesterSessionType; + private String requesterClientPlatform; + private Integer payloadType; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public Integer getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(Integer requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public Integer getPayloadType() { + return payloadType; + } + + public void setPayloadType(Integer payloadType) { + this.payloadType = payloadType; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java new file mode 100644 index 0000000..cc70d6f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_StartEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private long expiresAtMs; + private boolean trustedSessionOnline; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isTrustedSessionOnline() { + return trustedSessionOnline; + } + + public void setTrustedSessionOnline(boolean trustedSessionOnline) { + this.trustedSessionOnline = trustedSessionOnline; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java new file mode 100644 index 0000000..30ed3b5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java @@ -0,0 +1,33 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UpsertEspPairingSettings_Request extends Net_Request { + private Boolean enabled; + private String passwordHash; + private Integer ttlSeconds; + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Integer getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(Integer ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java new file mode 100644 index 0000000..0320846 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_UpsertEspPairingSettings_Response extends Net_Response { + private boolean enabled; + private boolean hasPassword; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isHasPassword() { + return hasPassword; + } + + public void setHasPassword(boolean hasPassword) { + this.hasPassword = hasPassword; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index f95b7b7..a1f9faf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -8,6 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { @@ -43,7 +44,7 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean inserted = SignedMessagesCore.saveIfAbsent(entry); + boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (inserted) { counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index eb11a3b..db0f9e8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -49,11 +49,18 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + boolean pairInserted; + if (incoming.isContentType()) { + pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair( + incomingEntry, outgoingEntry + ); + } else { + pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null); + inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming); } String excludeSessionId = null; @@ -62,7 +69,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { } SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId); + outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java index 87967a4..4528abf 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -6,7 +6,8 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; final class SignedMessageBlock { - static final byte[] PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); + static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); + static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); static final int TYPE_INCOMING_TEXT = 1; static final int TYPE_OUTGOING_COPY = 2; static final int TYPE_READ_INCOMING = 3; @@ -17,10 +18,15 @@ final class SignedMessageBlock { final long timeMs; final long nonce; final int messageType; + final long revisionTimeMs; + final int formatVersionMajor; + final int formatVersionMinor; final byte[] payloadBytes; + final byte[] encryptedBodyBytes; final byte[] signedBody; final byte[] signature64; final byte[] rawPacket; + final boolean legacyFormat; private SignedMessageBlock( String toLogin, @@ -28,36 +34,52 @@ final class SignedMessageBlock { long timeMs, long nonce, int messageType, + long revisionTimeMs, + int formatVersionMajor, + int formatVersionMinor, byte[] payloadBytes, + byte[] encryptedBodyBytes, byte[] signedBody, byte[] signature64, - byte[] rawPacket + byte[] rawPacket, + boolean legacyFormat ) { this.toLogin = toLogin; this.fromLogin = fromLogin; this.timeMs = timeMs; this.nonce = nonce; this.messageType = messageType; + this.revisionTimeMs = revisionTimeMs; + this.formatVersionMajor = formatVersionMajor; + this.formatVersionMinor = formatVersionMinor; this.payloadBytes = payloadBytes; + this.encryptedBodyBytes = encryptedBodyBytes; this.signedBody = signedBody; this.signature64 = signature64; this.rawPacket = rawPacket; + this.legacyFormat = legacyFormat; } - static SignedMessageBlock parse(byte[] raw, int maxPayloadBytes) { - if (raw == null || raw.length < PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { + static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) { + if (raw == null || raw.length < 64) { throw new IllegalArgumentException("BAD_LEN"); } - if (raw.length > 8192) { - throw new IllegalArgumentException("PAYLOAD_TOO_LARGE"); - } - ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - byte[] prefix = new byte[PREFIX.length]; - bb.get(prefix); - if (!Arrays.equals(prefix, PREFIX)) { - throw new IllegalArgumentException("BAD_PREFIX"); + if (startsWith(raw, LEGACY_PREFIX)) { + return parseLegacy(raw, maxEncryptedBodyBytes); } + if (startsWith(raw, V1_PREFIX)) { + return parseV1(raw, maxEncryptedBodyBytes); + } + throw new IllegalArgumentException("BAD_PREFIX"); + } + + private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) { + if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); + bb.position(LEGACY_PREFIX.length); String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); @@ -67,9 +89,7 @@ final class SignedMessageBlock { long nonce = Integer.toUnsignedLong(bb.getInt()); int messageType = Short.toUnsignedInt(bb.getShort()); - if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { - throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); - } + ensureMessageType(messageType); int payloadLen = Short.toUnsignedInt(bb.getShort()); if (payloadLen < 1 || payloadLen > maxPayloadBytes) { @@ -86,7 +106,82 @@ final class SignedMessageBlock { byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); return new SignedMessageBlock( - toLogin, fromLogin, timeMs, nonce, messageType, payload, signedBody, signature64, raw + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + 0L, + 2, + 0, + payload, + payload, + signedBody, + signature64, + raw, + true + ); + } + + private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) { + if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); + bb.position(V1_PREFIX.length); + + int major = Byte.toUnsignedInt(bb.get()); + int minor = Byte.toUnsignedInt(bb.get()); + if (major != 1 || minor != 0) { + throw new IllegalArgumentException("BAD_FORMAT_VERSION"); + } + + String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); + String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); + long timeMs = bb.getLong(); + if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); + long nonce = Integer.toUnsignedLong(bb.getInt()); + int messageType = Short.toUnsignedInt(bb.getShort()); + ensureMessageType(messageType); + long revisionTimeMs = bb.getLong(); + if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + + int attachmentsCount = Byte.toUnsignedInt(bb.get()); + if (attachmentsCount != 0) { + throw new IllegalArgumentException("ATTACHMENTS_DISABLED"); + } + + if (bb.remaining() < 4 + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt()); + if (encryptedBodyLen > maxEncryptedBodyBytes) { + throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } + if (bb.remaining() != encryptedBodyLen + 64) { + throw new IllegalArgumentException("BAD_LEN"); + } + byte[] encryptedBody = new byte[(int) encryptedBodyLen]; + bb.get(encryptedBody); + byte[] signature64 = new byte[64]; + bb.get(signature64); + byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); + + return new SignedMessageBlock( + toLogin, + fromLogin, + timeMs, + nonce, + messageType, + revisionTimeMs, + major, + minor, + encryptedBody, + encryptedBody, + signedBody, + signature64, + raw, + false ); } @@ -98,10 +193,36 @@ final class SignedMessageBlock { return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY; } + boolean isContentType() { + return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY; + } + + boolean isReadReceiptType() { + return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY; + } + + boolean isDeletedContent() { + return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0; + } + String targetLogin() { return isIncomingType() ? toLogin : fromLogin; } + private static void ensureMessageType(int messageType) { + if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { + throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); + } + } + + private static boolean startsWith(byte[] raw, byte[] prefix) { + if (raw.length < prefix.length) return false; + for (int i = 0; i < prefix.length; i++) { + if (raw[i] != prefix[i]) return false; + } + return true; + } + private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) { if (!bb.hasRemaining()) throw new IllegalArgumentException(code); int len = Byte.toUnsignedInt(bb.get()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index 4909fc0..4e2bf93 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,24 +1,40 @@ package server.logic.ws_protocol.JSON.messages; -import shine.db.dao.SignedMessagesV2DAO; import shine.db.dao.SolanaUsersDAO; import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; import utils.crypto.Ed25519Util; +import java.nio.charset.StandardCharsets; import java.util.Base64; final class SignedMessagesCore { - private static final int MAX_PAYLOAD_BYTES = 4096; + private static final int MAX_ENCRYPTED_BODY_BYTES = 16384; private SignedMessagesCore() {} static SignedMessageBlock parseFromB64(String blobB64) { try { byte[] raw = Base64.getDecoder().decode(blobB64.trim()); - return SignedMessageBlock.parse(raw, MAX_PAYLOAD_BYTES); + return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); + String code = e.getMessage(); + if (code == null || code.isBlank()) { + throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); + } + switch (code) { + case "ATTACHMENTS_DISABLED", + "BAD_PREFIX", + "BAD_LEN", + "BAD_TO_LOGIN", + "BAD_FROM_LOGIN", + "BAD_TIME", + "BAD_MESSAGE_TYPE", + "BAD_MESSAGE_LEN", + "BAD_FORMAT_VERSION", + "BAD_REVISION_TIME" -> throw e; + default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); + } } } @@ -42,7 +58,7 @@ final class SignedMessagesCore { if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS"); - if (incoming.messageType == SignedMessageBlock.TYPE_READ_INCOMING) { + if (incoming.isReadReceiptType()) { ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes); ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes); if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) @@ -52,6 +68,27 @@ final class SignedMessagesCore { || inRef.refType != outRef.refType) { throw new IllegalArgumentException("BAD_RECEIPT_REF"); } + return; + } + + if (incoming.legacyFormat || outgoing.legacyFormat) { + throw new IllegalArgumentException("BAD_CONTENT_FORMAT"); + } + if (incoming.revisionTimeMs != outgoing.revisionTimeMs) { + throw new IllegalArgumentException("BAD_REVISION_TIME"); + } + if (incoming.formatVersionMajor != outgoing.formatVersionMajor + || incoming.formatVersionMinor != outgoing.formatVersionMinor) { + throw new IllegalArgumentException("BAD_FORMAT_VERSION"); + } + + if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) { + throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } + for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) { + if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } } } @@ -68,6 +105,7 @@ final class SignedMessagesCore { entry.setTimeMs(block.timeMs); entry.setNonce(block.nonce); entry.setMessageType(block.messageType); + entry.setRevisionTimeMs(block.revisionTimeMs); entry.setRawBlock(block.rawPacket); entry.setCreatedAtMs(System.currentTimeMillis()); entry.setSourceApi(sourceApi); @@ -83,7 +121,10 @@ final class SignedMessagesCore { return entry; } - static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception { - return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + static String previewTextForPush(SignedMessageBlock block) { + if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) { + return ""; + } + return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 76019e3..ee344a4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -21,8 +21,13 @@ public final class SignedMessagesRealtime { private static final ObjectMapper MAPPER = new ObjectMapper(); private SignedMessagesRealtime() {} + static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + return deliverToTargetSessions(message, block, null); + } + static DeliveryCounters deliverToTargetSessions( SignedMessageV2Entry message, + SignedMessageBlock block, String excludeSessionId ) throws Exception { DeliveryCounters counters = new DeliveryCounters(); @@ -39,8 +44,11 @@ public final class SignedMessagesRealtime { counters.wsDelivered++; continue; } - if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT) { - boolean pushed = pushNewMessageNotification(s, message); + if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT + && block != null + && block.revisionTimeMs == 0 + && !block.isDeletedContent()) { + boolean pushed = pushNewMessageNotification(s, message, block); if (pushed) counters.pushDelivered++; } } @@ -89,13 +97,21 @@ public final class SignedMessagesRealtime { return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { + private static boolean pushNewMessageNotification( + ActiveSessionEntry session, + SignedMessageV2Entry message, + SignedMessageBlock block + ) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String text = "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."; + String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim(); + if (preview.length() > 80) preview = preview.substring(0, 80) + "..."; + String text = preview.isBlank() + ? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения." + : preview; String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 31bf3df..01f4884 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -75,4 +75,4 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); server.join(); } -} \ No newline at end of file +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java new file mode 100644 index 0000000..8c77114 --- /dev/null +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -0,0 +1,236 @@ +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; +import utils.crypto.Ed25519Util; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.SolanaUserEntry; +import utils.crypto.HashSHA256Util; + +import java.time.Duration; +import java.util.Base64; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +public class IT_07_EspPairing { + + private static final String LOGIN = TestConfig.LOGIN(); + + public static void main(String[] args) { + TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД"); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_07_EspPairing"); + Duration t = Duration.ofSeconds(5); + + try { + ensureUserSeeded(); + Session clientSession = createSession(LOGIN, 1, "Web", t); + + try (WsSession requesterWs = WsSession.open(); + WsSession clientWs = WsSession.open()) { + + sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r); + + String passwordHash = derivePairingHash(LOGIN, "test-pairing-password"); + String upsertResp = clientWs.call( + "UpsertEspPairingSettings", + JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180), + t + ); + assertEquals(200, JsonParsers.status(upsertResp), "UpsertEspPairingSettings must be 200"); + + SessionMaterial requesterMaterial = newSessionMaterial(); + String startResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startResp), "StartEspPairing must be 200"); + String pairingId = JsonParsers.payloadText(startResp, "pairingId"); + assertNotNull(pairingId, "pairingId must be present"); + assertEquals("created", JsonParsers.payloadText(startResp, "state")); + assertEquals(Boolean.TRUE, JsonParsers.payloadBoolean(startResp, "trustedSessionOnline")); + + String listResp = clientWs.call("ListEspPairingRequests", JsonBuilders.listEspPairingRequests(), t); + assertEquals(200, JsonParsers.status(listResp), "ListEspPairingRequests must be 200"); + assertTrue(listResp.contains(pairingId), "ListEspPairingRequests must contain created pairingId"); + + String approveResp = clientWs.call( + "ApproveEspPairing", + JsonBuilders.approveEspPairing(pairingId, "AQIDBA=="), + t + ); + assertEquals(200, JsonParsers.status(approveResp), "ApproveEspPairing must be 200"); + assertEquals("approved", JsonParsers.payloadText(approveResp, "state")); + + String statusResp = requesterWs.call( + "GetEspPairingStatus", + JsonBuilders.getEspPairingStatus(pairingId), + t + ); + assertEquals(200, JsonParsers.status(statusResp), "GetEspPairingStatus must be 200"); + assertEquals("approved", JsonParsers.payloadText(statusResp, "state")); + assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload")); + + String upsertNoPasswordResp = clientWs.call( + "UpsertEspPairingSettings", + JsonBuilders.upsertEspPairingSettings(true, "", 180), + t + ); + assertEquals(200, JsonParsers.status(upsertNoPasswordResp), "UpsertEspPairingSettings without password must be 200"); + + SessionMaterial requesterNoPasswordMaterial = newSessionMaterial(); + String startNoPasswordResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200"); + + String startWrongPasswordResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startWrongPasswordResp, "StartEspPairing", "PAIRING_PASSWORD_INVALID"); + + SessionMaterial cancelMaterial = newSessionMaterial(); + String startCancelableResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", cancelMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startCancelableResp), "StartEspPairing for cancel must be 200"); + String cancelPairingId = JsonParsers.payloadText(startCancelableResp, "pairingId"); + String cancelResp = requesterWs.call( + "CancelEspPairing", + JsonBuilders.cancelEspPairing(cancelPairingId, cancelMaterial.sessionKey()), + t + ); + assertEquals(200, JsonParsers.status(cancelResp), "CancelEspPairing must be 200"); + assertEquals("canceled", JsonParsers.payloadText(cancelResp, "state")); + + String closeResp = clientWs.call( + "CloseActiveSession", + JsonBuilders.closeActiveSession(clientSession.sessionId(), 0, ""), + t + ); + assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession must be 200"); + + SessionMaterial requesterOfflineMaterial = newSessionMaterial(); + String startOfflineResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", requesterOfflineMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startOfflineResp, "StartEspPairing", "PAIRING_NO_TRUSTED_SESSION_ONLINE"); + + String forbiddenResp = requesterWs.call( + "ListEspPairingRequests#anonymous", + JsonBuilders.listEspPairingRequests(), + t + ); + assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION"); + + r.ok("ESP pairing: доверенная сессия принимает заявки как с доп. паролем, так и без него"); + } + } catch (Throwable e) { + r.fail("IT_07_EspPairing упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static Session createSession(String login, int sessionType, String clientPlatform, Duration t) { + try (WsSession ws = WsSession.open()) { + String nonceResp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t); + assertEquals(200, JsonParsers.status(nonceResp)); + String authNonce = JsonParsers.authNonce(nonceResp); + assertNotNull(authNonce); + + SessionMaterial material = newSessionMaterial(); + String storagePwd = TestConfig.fakeStoragePwd(); + String createResp = ws.call( + "CreateAuthSession", + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, material.sessionKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession must be 200"); + String sessionId = JsonParsers.sessionId(createResp); + assertNotNull(sessionId); + return new Session(sessionId, material.sessionKey(), material.sessionPrivKey(), storagePwd); + } + } + + private static void sessionLogin2Steps(WsSession ws, + Session s, + int sessionType, + String clientPlatform, + Duration t, + TestResult r) { + String chResp = ws.call("SessionChallenge", JsonBuilders.sessionChallenge(s.sessionId()), t); + assertEquals(200, JsonParsers.status(chResp)); + String nonce = JsonParsers.sessionNonce(chResp); + assertNotNull(nonce); + + String loginResp = ws.call( + "SessionLogin", + JsonBuilders.sessionLogin(s.sessionId(), s.sessionKey(), nonce, s.sessionPrivKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(loginResp)); + assertEquals(s.storagePwd(), JsonParsers.storagePwd(loginResp)); + r.ok("SessionLogin OK for sessionType=" + sessionType); + } + + private static void assertErrorFormat(String resp, String op, String code) { + int status = JsonParsers.status(resp); + assertFalse(status >= 200 && status < 300, "Expected non-2xx status: " + resp); + assertEquals(Boolean.FALSE, JsonParsers.ok(resp), "Expected ok=false: " + resp); + assertEquals(op, JsonParsers.op(resp), "Unexpected op: " + resp); + assertEquals(code, JsonParsers.errorCode(resp), "Unexpected error code: " + resp); + } + + private static SessionMaterial newSessionMaterial() { + byte[] sessionPrivKey = Ed25519Util.generatePrivateKey(); + byte[] sessionPubKey = Ed25519Util.derivePublicKey(sessionPrivKey); + String sessionKey = "ed25519/" + Base64.getEncoder().encodeToString(sessionPubKey); + return new SessionMaterial(sessionKey, sessionPrivKey); + } + + private static void ensureUserSeeded() throws Exception { + if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) { + return; + } + SolanaUserEntry entry = new SolanaUserEntry(); + entry.setLogin(LOGIN); + entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); + entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); + entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); + entry.setDeviceKey(TestConfig.devicePublicKeyB64(LOGIN)); + SolanaUsersDAO.getInstance().insert(entry); + } + + private static String derivePairingHash(String login, String password) { + String preimage = "shine-pairing|" + login.trim().toLowerCase() + "|" + password; + byte[] digest = HashSHA256Util.sha256(preimage.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte b : digest) { + sb.append(Character.forDigit((b >>> 4) & 0x0F, 16)); + sb.append(Character.forDigit(b & 0x0F, 16)); + } + return "sha256$" + sb; + } + + private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {} + private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {} +} diff --git a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java index 6524bed..9c1c9ce 100644 --- a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java @@ -7,6 +7,7 @@ import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_05_UserConnections; import test.it.cases.IT_06_ChannelsApi; +import test.it.cases.IT_07_EspPairing; import test.it.cases.Seed_TestDataPopulation; import test.it.utils.log.TestLog; @@ -61,9 +62,12 @@ public class IT_RunAllMain { String s6 = IT_06_ChannelsApi.run(); summaries.add(s6); if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s7 = Seed_TestDataPopulation.run(); summaries.add(s7); + String s7 = IT_07_EspPairing.run(); summaries.add(s7); if (s7.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + String s8 = Seed_TestDataPopulation.run(); summaries.add(s8); + if (s8.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + return finish(summaries, failed); } diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index 6e88898..3952366 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -137,6 +137,10 @@ public final class JsonBuilders { // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) { + return createAuthSessionV2(login, authNonce, storagePwd, sessionKey, 1, "IT"); + } + + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); byte[] devicePriv = TestConfig.getDevicePrivatKey(login); @@ -156,6 +160,8 @@ public final class JsonBuilders { "authNonce": "%s", "deviceKey": "%s", "signatureB64": "%s", + "sessionType": %d, + "clientPlatform": "%s", "clientInfo": "%s" } } @@ -168,6 +174,8 @@ public final class JsonBuilders { authNonce, deviceKey, sigB64, + sessionType, + clientPlatform == null ? "" : clientPlatform, TestConfig.TEST_CLIENT_INFO ); } @@ -192,6 +200,10 @@ public final class JsonBuilders { // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) { + return sessionLogin(sessionId, sessionKey, nonce, sessionPrivKey, 1, "IT"); + } + + public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); @@ -203,12 +215,14 @@ public final class JsonBuilders { "payload": { "sessionId": "%s", "sessionKey": "%s", + "sessionType": %d, + "clientPlatform": "%s", "timeMs": %d, "signatureB64": "%s", "clientInfo": "%s" } } - """.formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + """.formatted(requestId, sessionId, sessionKey, sessionType, clientPlatform == null ? "" : clientPlatform, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); } // ---------------- ListSessions ---------------- @@ -242,6 +256,110 @@ public final class JsonBuilders { """.formatted(requestId, sessionId, timeMs, signatureB64); } + public static String upsertEspPairingSettings(boolean enabled, String passwordHash, int ttlSeconds) { + String requestId = TestIds.next("esp-set"); + return """ + { + "op": "UpsertEspPairingSettings", + "requestId": "%s", + "payload": { + "enabled": %s, + "passwordHash": "%s", + "ttlSeconds": %d + } + } + """.formatted(requestId, enabled, passwordHash == null ? "" : passwordHash, ttlSeconds); + } + + public static String startEspPairing(String login, + String passwordHash, + String requesterSessionKey, + int requesterSessionType, + String requesterClientPlatform, + int payloadType) { + String requestId = TestIds.next("esp-start"); + return """ + { + "op": "StartEspPairing", + "requestId": "%s", + "payload": { + "login": "%s", + "passwordHash": "%s", + "requesterSessionKey": "%s", + "requesterSessionType": %d, + "requesterClientPlatform": "%s", + "payloadType": %d + } + } + """.formatted(requestId, login, passwordHash, requesterSessionKey, requesterSessionType, requesterClientPlatform == null ? "" : requesterClientPlatform, payloadType); + } + + public static String listEspPairingRequests() { + String requestId = TestIds.next("esp-list"); + return """ + { + "op": "ListEspPairingRequests", + "requestId": "%s", + "payload": {} + } + """.formatted(requestId); + } + + public static String approveEspPairing(String pairingId, String encryptedPayload) { + String requestId = TestIds.next("esp-approve"); + return """ + { + "op": "ApproveEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "encryptedPayload": "%s" + } + } + """.formatted(requestId, pairingId, encryptedPayload); + } + + public static String rejectEspPairing(String pairingId, String reason) { + String requestId = TestIds.next("esp-reject"); + return """ + { + "op": "RejectEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "reason": "%s" + } + } + """.formatted(requestId, pairingId, reason == null ? "" : reason); + } + + public static String cancelEspPairing(String pairingId, String requesterSessionKey) { + String requestId = TestIds.next("esp-cancel"); + return """ + { + "op": "CancelEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "requesterSessionKey": "%s" + } + } + """.formatted(requestId, pairingId, requesterSessionKey); + } + + public static String getEspPairingStatus(String pairingId) { + String requestId = TestIds.next("esp-status"); + return """ + { + "op": "GetEspPairingStatus", + "requestId": "%s", + "payload": { + "pairingId": "%s" + } + } + """.formatted(requestId, pairingId); + } + // ---------------- ListSubscribedChannels ---------------- public static String listSubscribedChannels(String login) { diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java index 7309208..16d7d46 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java @@ -147,6 +147,19 @@ public final class JsonParsers { return getPayloadText(json, field); } + public static Boolean payloadBoolean(String json, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has(field) && !payload.get(field).isNull()) { + return payload.get(field).asBoolean(); + } + return null; + } catch (Exception e) { + return null; + } + } + public static List sessionIds(String json) { List res = new ArrayList<>(); try { @@ -315,4 +328,19 @@ public final class JsonParsers { return -1; } } + + public static String firstArrayItemText(String json, String arrayField, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return null; + JsonNode arr = payload.get(arrayField); + if (arr == null || !arr.isArray() || arr.isEmpty()) return null; + JsonNode item = arr.get(0); + if (item == null || !item.has(field) || item.get(field).isNull()) return null; + return item.get(field).asText(); + } catch (Exception e) { + return null; + } + } } diff --git a/VERSION.properties b/VERSION.properties index 003bbd1..a8b6c74 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.159 -server.version=1.2.148 +client.version=1.2.216 +server.version=1.2.204 diff --git a/build.gradle b/build.gradle index 2385265..4866cff 100644 --- a/build.gradle +++ b/build.gradle @@ -292,17 +292,6 @@ tasks.register('startLocalWithBuild') { dependsOn tasks.named('startLocal') } -tasks.register('deployPromoSolanaDevnet', Exec) { - group = "!!deployment" - description = "Деплой отдельного временного сервиса SHiNE-promo-solana-devnet на сервер через домен shineup.me" - - // Этот сервис не входит в основной multi-module build данного репозитория. - // Поэтому деплой выполняется через отдельный Gradle-проект в подпапке - // SHiNE-promo-solana-devnet, где собраны его собственные задачи и зависимости. - workingDir = file('SHiNE-promo-solana-devnet') - commandLine 'bash', '-lc', './gradlew deployToServer' -} - tasks.named('startLocal').configure { mustRunAfter tasks.named('build') } diff --git a/codex-agent-VPS/.env.example b/codex-agent-VPS/.env.example new file mode 100644 index 0000000..9b15d6d --- /dev/null +++ b/codex-agent-VPS/.env.example @@ -0,0 +1,31 @@ +TELEGRAM_BOT_TOKEN=replace_me +OPENAI_API_KEY= +ALLOWED_TELEGRAM_USERNAME=owner_username +ALLOWED_TELEGRAM_PLAYERS=user_one:User One,user_two:User Two +ALLOWED_TELEGRAM_CHANNEL_USERNAME= +BOT_USERNAME=your_bot_username +TELEGRAM_API_BASE_URL=https://api.telegram.org +OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe +TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 +OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 +OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES=25165824 +OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS=900 +OPENAI_TRANSCRIBE_OVERLAP_SECONDS=2 +OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS=24 +OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS=1800 +FFMPEG_BIN=ffmpeg +FFPROBE_BIN=ffprobe +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +OPENAI_TTS_RESPONSE_FORMAT=opus +OPENAI_TTS_TIMEOUT_SECONDS=180 +OPENAI_TTS_CHUNK_CHARS=3500 +OPENAI_VOICE_REWRITE_MODEL=gpt-4.1-nano +OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS=90 +OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS=12000 +OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS=900 +CODEX_BIN=/home/your_user/.local/bin/codex +CODEX_WORKDIR=/home/your_user +CODEX_TIMEOUT_SECONDS=900 +MAX_RETRIES=3 +DATA_DIR=./data diff --git a/codex-agent-VPS/.gitignore b/codex-agent-VPS/.gitignore new file mode 100644 index 0000000..2af457e --- /dev/null +++ b/codex-agent-VPS/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +logs/ +run/ +__pycache__/ diff --git a/codex-agent-VPS/AGENTS.md b/codex-agent-VPS/AGENTS.md new file mode 100644 index 0000000..770e488 --- /dev/null +++ b/codex-agent-VPS/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS + +## Назначение +- `codex-agent-VPS` — переносимая версия Telegram-бота для запуска `codex` CLI на VPS. +- Папку можно ставить в любое место на Linux-сервере, если там есть `python3`, `systemd`, `codex` и доступ в интернет. +- Конфигурация делается через `.env`. + +## Состав папки +- `README.md` — краткое описание структуры. +- `Agent-server-package/` — готовый набор файлов для копирования на VPS. +- `.env.example` — пример конфигурации. +- `AGENTS.md` — инструкция по установке и настройке. + +## Требования к VPS +- Linux-сервер с `systemd`. +- Установленные `python3`, `curl`, `ffmpeg`. +- Установленный `codex` CLI. +- Выполненный `codex login` под тем пользователем, от которого будет работать сервис. +- Telegram bot token. +- Telegram usernames разрешённых пользователей. + +## Установка через Codex +1. Скопировать содержимое `Agent-server-package/` на сервер в нужное место, например: + - `/home/your_user/codex-agent` +2. Установить `codex` CLI под рабочим пользователем. +3. Выполнить под этим же пользователем: + - `codex login` +4. Установить системные зависимости: + - `python3` + - `ffmpeg` +5. Взять `.env.example` из корня `codex-agent-VPS` и создать на сервере `.env`. +6. В `.env` заполнить: + - `TELEGRAM_BOT_TOKEN` + - `ALLOWED_TELEGRAM_USERNAME` + - `ALLOWED_TELEGRAM_PLAYERS` + - `BOT_USERNAME` + - `CODEX_BIN` + - `CODEX_WORKDIR` +7. Если нужны voice/audio и голосовые ответы, дополнительно задать: + - `OPENAI_API_KEY` +8. В `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service` заменить: + - `your_user` + - `/home/your_user/codex-agent` + на реальные значения. +9. Скопировать unit в: + - `/etc/systemd/system/shine-agent-bot-coder.service` +10. Выполнить: + - `sudo systemctl daemon-reload` + - `sudo systemctl enable --now shine-agent-bot-coder` +11. Проверить: + - `sudo systemctl status shine-agent-bot-coder --no-pager` + - `sudo journalctl -u shine-agent-bot-coder -f` + +## Настройка доступа +- `ALLOWED_TELEGRAM_USERNAME` — основной разрешённый пользователь. +- `ALLOWED_TELEGRAM_PLAYERS` — дополнительные разрешённые пользователи: + - `username1:Имя 1,username2:Имя 2` +- Все пользователи из whitelist в этой версии считаются полноправными. +- Все входящие задачи попадают в одну общую очередь и выполняются строго последовательно. + +## Поведение агента +- Бот принимает текст, voice и audio. +- Для каждого пользователя ведётся отдельная история. +- Все задачи запускаются через `codex exec`. +- Рабочая директория задаётся через `CODEX_WORKDIR`. +- Вызов идёт без sandbox/approval ограничений: `--dangerously-bypass-approvals-and-sandbox`. + +## Что обычно меняют при переносе +- `.env` +- `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service` +- при необходимости `Agent-server-package/AGENT.md` + +## Полезные команды +- Проверка установки Codex: + - `codex --version` + - `codex doctor` +- Self-test без Telegram: + - `python3 py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"` +- Проверка сервиса: + - `sudo systemctl status shine-agent-bot-coder --no-pager` + - `sudo journalctl -u shine-agent-bot-coder -f` + +## Примечания +- Если `codex doctor` пишет, что credentials не найдены, нужно выполнить `codex login`. +- Если `OPENAI_API_KEY` пустой, текстовые задачи через `codex` будут работать, а voice/audio и TTS-функции — нет. +- Если у пользователя в Telegram нет username, whitelist по username его не пропустит. diff --git a/codex-agent-VPS/Agent-server-package/.env.example b/codex-agent-VPS/Agent-server-package/.env.example new file mode 100644 index 0000000..9b15d6d --- /dev/null +++ b/codex-agent-VPS/Agent-server-package/.env.example @@ -0,0 +1,31 @@ +TELEGRAM_BOT_TOKEN=replace_me +OPENAI_API_KEY= +ALLOWED_TELEGRAM_USERNAME=owner_username +ALLOWED_TELEGRAM_PLAYERS=user_one:User One,user_two:User Two +ALLOWED_TELEGRAM_CHANNEL_USERNAME= +BOT_USERNAME=your_bot_username +TELEGRAM_API_BASE_URL=https://api.telegram.org +OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe +TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 +OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 +OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES=25165824 +OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS=900 +OPENAI_TRANSCRIBE_OVERLAP_SECONDS=2 +OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS=24 +OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS=1800 +FFMPEG_BIN=ffmpeg +FFPROBE_BIN=ffprobe +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +OPENAI_TTS_RESPONSE_FORMAT=opus +OPENAI_TTS_TIMEOUT_SECONDS=180 +OPENAI_TTS_CHUNK_CHARS=3500 +OPENAI_VOICE_REWRITE_MODEL=gpt-4.1-nano +OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS=90 +OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS=12000 +OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS=900 +CODEX_BIN=/home/your_user/.local/bin/codex +CODEX_WORKDIR=/home/your_user +CODEX_TIMEOUT_SECONDS=900 +MAX_RETRIES=3 +DATA_DIR=./data diff --git a/codex-agent-VPS/Agent-server-package/AGENT.md b/codex-agent-VPS/Agent-server-package/AGENT.md new file mode 100644 index 0000000..8831ff1 --- /dev/null +++ b/codex-agent-VPS/Agent-server-package/AGENT.md @@ -0,0 +1,51 @@ +# AGENT.md для codex-agent-VPS + +Ты запущен как обработчик входящего Telegram-сообщения от пользователя. + +## Контекст +- `codex-agent-VPS` — Telegram-бот, который принимает сообщения, ведёт историю, ставит задачи в очередь и последовательно запускает `codex` CLI на VPS. +- Текстовые сообщения обрабатываются напрямую. +- Voice и audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача. +- История диалога хранится в JSONL-файле, путь передаётся в промпте. +- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение. +- Основная реализация сервиса — Python-скрипт `py_bot_service.py`. + +## Пользователи и доступ +- Разрешённые пользователи задаются через `ALLOWED_TELEGRAM_USERNAME` и `ALLOWED_TELEGRAM_PLAYERS`. +- Все разрешённые пользователи считаются полноправными. +- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом. +- Все входящие задачи попадают в одну общую очередь и выполняются строго по одной. + +## Очередь и состояние +- Сервис ведёт состояние активной задачи и текущего файла истории. +- После рестарта сервис продолжает незавершённую обработку с учётом сохранённого состояния. +- Истории диалогов хранятся отдельно по username: `data/history//`. +- Архив истории после `/new`: `data/history//archive/`. +- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume. +- Дедупликация Telegram update обязательна, чтобы одно сообщение не обрабатывалось повторно. +- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус и повторяет его каждые 2 минуты. + +## Голосовые ответы +- Озвучивание финальных ответов настраивается персонально командами `/voice_on` и `/voice_off`. +- Для новых пользователей озвучивание включено по умолчанию. +- Адаптация текста перед озвучкой настраивается командами `/voice_rewrite_on` и `/voice_rewrite_off`. +- Если озвучивание включено, после текстового финального ответа сервис дополнительно отправляет voice-файл через OpenAI TTS. +- Промежуточные статусы озвучивать не нужно. + +## Команды +- `/status` — состояние очереди и персональных настроек. +- `/settings` — текущие пользовательские настройки. +- `/queue` — список задач в очереди. +- `/tasks` — список задач и предложений пользователя. +- `/new` — архивировать историю и начать новую Codex-сессию. +- `/stop` — остановить текущую задачу. +- `/cancel ` — удалить задачу по id или очистить очередь. +- `/restart` и `/restart_service` — отложенный рестарт после текущей задачи. +- `/restart_hard`, `/restart_now`, `/restart_force` — жёсткий рестарт прямо сейчас. + +## Правила ответа +- Пиши содержательно и коротко. +- Не упоминай внутренние служебные детали, файловую систему и технические логи, если это не нужно пользователю. +- Если запрос требует действий с кодом или файлами, выполняй их в рабочей директории `CODEX_WORKDIR`. +- Если данных недостаточно, задай ровно один уточняющий вопрос. +- Если в промпте есть пометка retry, учитывай текущее состояние и продолжай аккуратно, а не начинай заново без причины. diff --git a/codex-agent-VPS/Agent-server-package/py_bot_service.py b/codex-agent-VPS/Agent-server-package/py_bot_service.py new file mode 100644 index 0000000..e96dee3 --- /dev/null +++ b/codex-agent-VPS/Agent-server-package/py_bot_service.py @@ -0,0 +1,2960 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import json +import mimetypes +import os +import random +import re +import shutil +import string +import subprocess +import tempfile +import threading +import time +import traceback +import uuid +from pathlib import Path +from typing import Any, Callable +from urllib import error, request + +DEFAULT_ALLOWED_PLAYERS = ",".join([ + "user_one:User One", + "user_two:User Two", +]) + +TASK_STATUS_LABELS = { + "new": "новая", + "approved": "одобрена", + "rejected": "отклонена", + "needs_work": "на доработку", + "done": "сделана", +} + + +def now_iso() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def normalize_username(value: str | None) -> str: + if not value: + return "" + value = value.strip() + if value.startswith("@"): + value = value[1:] + return value.lower() + + +def parse_allowed_players(raw: str) -> dict[str, str]: + players: dict[str, str] = {} + for item in (raw or "").split(","): + part = item.strip() + if not part: + continue + username_part, sep, name_part = part.partition(":") + username = normalize_username(username_part) + if not username: + continue + player_name = (name_part if sep else username_part).strip() or username + players[username] = player_name + return players + + +def compact_spaces(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip()) + + +def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: + text = (text or "").strip() + if not text: + return ["(пустой ответ)"] + 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: + return [] + chunks: list[str] = [] + current = "" + paragraphs = re.split(r"\n\s*\n", text) + for paragraph in paragraphs: + paragraph = paragraph.strip() + if not paragraph: + continue + if len(paragraph) > chunk_size: + if current: + chunks.append(current) + current = "" + for i in range(0, len(paragraph), chunk_size): + part = paragraph[i:i + chunk_size].strip() + if part: + chunks.append(part) + continue + candidate = paragraph if not current else f"{current}\n\n{paragraph}" + if len(candidate) <= chunk_size: + current = candidate + else: + if current: + chunks.append(current) + current = paragraph + if current: + chunks.append(current) + return chunks + + +def read_env_file(path: Path) -> dict[str, str]: + result: dict[str, str] = {} + if not path.exists(): + return result + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + result[key] = value + return result + + +class VoiceTranscriptionError(RuntimeError): + def __init__( + self, + user_message: str, + *, + stage: str, + retryable: bool = True, + detail: str = "", + ): + super().__init__(user_message) + self.user_message = user_message + self.stage = stage + self.retryable = retryable + self.detail = detail + + def log_text(self) -> str: + if self.detail and self.detail != self.user_message: + return f"{self.user_message} stage={self.stage} retryable={self.retryable} detail={self.detail}" + return f"{self.user_message} stage={self.stage} retryable={self.retryable}" + + +class VoiceReplyError(RuntimeError): + pass + + +class JsonLineStore: + @staticmethod + def load(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + items: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + items.append(json.loads(line)) + return items + + @staticmethod + def save(path: Path, items: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + for item in items: + f.write(json.dumps(item, ensure_ascii=False) + "\n") + tmp.replace(path) + + @staticmethod + def append(path: Path, item: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(item, ensure_ascii=False) + "\n") + + +class TelegramApi: + def __init__(self, token: str, base_url: str = "https://api.telegram.org"): + self.token = token + self.api_root = (base_url or "https://api.telegram.org").rstrip("/") + self.base = f"{self.api_root}/bot{token}/" + self.file_base = f"{self.api_root}/file/bot{token}/" + + def call(self, method: str, payload: dict[str, Any] | None = None, timeout: int = 60) -> dict[str, Any]: + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = request.Request(self.base + method, data=data, headers=headers, method="POST") + try: + with request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Telegram HTTP {e.code}: {body}") from e + except Exception as e: + raise RuntimeError(f"Telegram request failed: {e}") from e + + result = json.loads(raw) + if not result.get("ok"): + raise RuntimeError(f"Telegram API error: {result}") + return result + + def call_multipart( + self, + method: str, + fields: dict[str, Any], + files: dict[str, tuple[str, bytes, str]], + timeout: int = 120, + ) -> dict[str, Any]: + boundary = "----shine-tg-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) + body = bytearray() + for name, value in fields.items(): + if value is None: + continue + body.extend( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + ).encode("utf-8") + ) + for name, (filename, data, mime) in files.items(): + body.extend( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' + f"Content-Type: {mime}\r\n\r\n" + ).encode("utf-8") + ) + body.extend(data) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode("utf-8")) + + req = request.Request(self.base + method, data=bytes(body), method="POST") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + try: + with request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except error.HTTPError as e: + body_text = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Telegram HTTP {e.code}: {body_text}") from e + except Exception as e: + raise RuntimeError(f"Telegram multipart request failed: {e}") from e + + result = json.loads(raw) + if not result.get("ok"): + raise RuntimeError(f"Telegram API error: {result}") + return result + + def get_updates(self, offset: int | None, timeout_sec: int) -> list[dict[str, Any]]: + payload: dict[str, Any] = {"timeout": timeout_sec, "allowed_updates": ["message", "channel_post"]} + if offset is not None: + payload["offset"] = offset + result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15) + return result.get("result", []) + + def send_message(self, chat_id: int | str, text: str, reply_to_message_id: int | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"chat_id": chat_id, "text": text} + if reply_to_message_id is not None: + 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, + voice: str, + caption: str = "", + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"chat_id": chat_id, "voice": voice} + if caption: + payload["caption"] = caption + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id + return self.call("sendVoice", payload=payload, timeout=60) + + def send_audio( + self, + chat_id: int | str, + audio: str, + caption: str = "", + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = {"chat_id": chat_id, "audio": audio} + if caption: + payload["caption"] = caption + if reply_to_message_id is not None: + payload["reply_to_message_id"] = reply_to_message_id + return self.call("sendAudio", payload=payload, timeout=60) + + def send_voice_upload( + self, + chat_id: int | str, + voice_bytes: bytes, + filename: str, + caption: str = "", + reply_to_message_id: int | None = None, + ) -> dict[str, Any]: + fields: dict[str, Any] = {"chat_id": chat_id} + if caption: + fields["caption"] = caption + if reply_to_message_id is not None: + fields["reply_to_message_id"] = reply_to_message_id + return self.call_multipart( + "sendVoice", + fields=fields, + files={"voice": (filename, voice_bytes, "audio/ogg")}, + timeout=180, + ) + + def delete_webhook(self) -> None: + self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30) + + +class BotConfig: + def __init__(self, root_dir: Path): + env = dict(os.environ) + env.update(read_env_file(root_dir / ".env")) + + self.root_dir = root_dir + self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN") + self.allowed_username = normalize_username(env.get("ALLOWED_TELEGRAM_USERNAME", "AidarKC")) + self.allowed_players = parse_allowed_players(env.get("ALLOWED_TELEGRAM_PLAYERS", DEFAULT_ALLOWED_PLAYERS)) + self.allowed_channel_username = normalize_username(env.get("ALLOWED_TELEGRAM_CHANNEL_USERNAME", "shine_writing")) + self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot") + self.telegram_api_base_url = env.get("TELEGRAM_API_BASE_URL", "https://api.telegram.org").strip() or "https://api.telegram.org" + self.openai_api_key = env.get("OPENAI_API_KEY", "").strip() + self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe") + self.telegram_file_download_timeout_seconds = int(env.get("TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS", "300")) + self.openai_transcribe_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900")) + self.openai_transcribe_max_upload_bytes = max(1_000_000, int(env.get("OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES", str(24 * 1024 * 1024)))) + self.openai_transcribe_max_chunk_seconds = max(60, int(env.get("OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS", "900"))) + self.openai_transcribe_overlap_seconds = max(0, int(env.get("OPENAI_TRANSCRIBE_OVERLAP_SECONDS", "2"))) + self.openai_transcribe_reencode_bitrate_kbps = max(12, int(env.get("OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS", "24"))) + self.openai_transcribe_ffmpeg_timeout_seconds = max(30, int(env.get("OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS", "1800"))) + self.ffmpeg_bin = env.get("FFMPEG_BIN", "ffmpeg").strip() or "ffmpeg" + self.ffprobe_bin = env.get("FFPROBE_BIN", "ffprobe").strip() or "ffprobe" + self.openai_tts_model = env.get("OPENAI_TTS_MODEL", "gpt-4o-mini-tts") + self.openai_tts_voice = env.get("OPENAI_TTS_VOICE", "alloy") + self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") + self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) + self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) + self.openai_voice_rewrite_model = env.get("OPENAI_VOICE_REWRITE_MODEL", "gpt-4.1-nano") + self.openai_voice_rewrite_timeout_seconds = int(env.get("OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS", "90")) + self.openai_voice_rewrite_max_input_chars = max(1000, int(env.get("OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS", "12000"))) + self.openai_voice_rewrite_max_output_tokens = max(200, int(env.get("OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS", "900"))) + self.codex_bin = Path(env.get( + "CODEX_BIN", + str(Path.home() / ".local/bin/codex") + )) + self.codex_workdir = Path(env.get("CODEX_WORKDIR", str(Path.home()))) + self.codex_timeout_seconds = int(env.get("CODEX_TIMEOUT_SECONDS", "900")) + self.max_retries = max(1, int(env.get("MAX_RETRIES", "3"))) + self.data_dir = (root_dir / env.get("DATA_DIR", "./data")).resolve() + self.agent_instructions_file = (root_dir / "AGENT.md").resolve() + + @staticmethod + def _required(env: dict[str, str], key: str) -> str: + value = env.get(key, "").strip() + if not value: + raise RuntimeError(f"Не задан обязательный параметр: {key}") + return value + + +class ShinePyBotService: + def __init__(self, config: BotConfig): + self.cfg = config + self.telegram = TelegramApi(config.telegram_bot_token, config.telegram_api_base_url) + + self.queue_file = config.data_dir / "py_queue.jsonl" + self.state_file = config.data_dir / "py_state.json" + self.processed_updates_file = config.data_dir / "py_processed_updates.log" + self.lock_file = config.data_dir / "py_app.lock" + self.history_dir = config.data_dir / "history" + self.history_archive_dir = self.history_dir / "archive" + self.task_center_dir = config.data_dir / "task_center" + self.task_center_file = self.task_center_dir / "items.json" + self.max_processed_updates = 5000 + + self.queue_lock = threading.RLock() + self.task_center_lock = threading.RLock() + self.stop_event = threading.Event() + self.worker = threading.Thread(target=self._worker_loop, name="shine-py-bot-worker", daemon=True) + + self.queue: list[dict[str, Any]] = [] + self.state: dict[str, Any] = {} + self.processed_updates: list[str] = [] + self.active_job_id: str | None = None + self.active_job_started_at: float | None = None + self.active_process: subprocess.Popen[str] | None = None + self.active_process_lock = threading.Lock() + self.stop_current_job = False + self.lock_fd = None + self.last_heartbeat_at: float = 0.0 + self.restart_requested = False + + def _is_owner(self, username: str) -> bool: + return self._is_allowed_user(username) + + def _is_allowed_player(self, username: str) -> bool: + return False + + def _is_allowed_user(self, username: str) -> bool: + uname = normalize_username(username) + return uname == self.cfg.allowed_username or uname in self.cfg.allowed_players + + def _player_name(self, username: str) -> str: + uname = normalize_username(username) + return self.cfg.allowed_players.get(uname, uname) + + def _display_name(self, username: str) -> str: + uname = normalize_username(username) + if uname == self.cfg.allowed_username: + return "Айдар" + return self._player_name(uname) + + def _known_usernames(self) -> dict[str, str]: + users = {self.cfg.allowed_username: "Айдар"} + users.update(self.cfg.allowed_players) + return users + + def _find_user_by_text(self, text: str) -> str: + source = normalize_username(text) + if source in self._known_usernames(): + return source + source_lower = (text or "").strip().lower() + aliases = { + "айдар": self.cfg.allowed_username, + "айдару": self.cfg.allowed_username, + "айдара": self.cfg.allowed_username, + "милана": "malvviiina", + "милане": "malvviiina", + "милану": "malvviiina", + "миланы": "malvviiina", + "сергей": "zodiaktechnika32", + "сергею": "zodiaktechnika32", + "сергея": "zodiaktechnika32", + "иван": "oidasyda", + "ивану": "oidasyda", + "ивана": "oidasyda", + "ворон": "blackbyrd1", + "ворону": "blackbyrd1", + "ворона": "blackbyrd1", + "дима": "dimasol1", + "диме": "dimasol1", + "диму": "dimasol1", + "димы": "dimasol1", + } + for alias, username in aliases.items(): + if re.search(rf"(^|\W){re.escape(alias)}($|\W)", source_lower, flags=re.IGNORECASE): + return username + for username, name in self._known_usernames().items(): + if username and username in source_lower: + return username + if name and name.lower() in source_lower: + return username + return "" + + def run(self) -> None: + self._ensure_dirs() + self._acquire_single_instance_lock() + self._load_state() + self._load_queue() + self._load_processed_updates() + self._recover_active_jobs_after_restart() + self.telegram.delete_webhook() + self._init_offset_if_missing() + self.worker.start() + self._append_history_event("service_started", {"allowedUsername": self.cfg.allowed_username}) + print(f"[py-bot] Запущен. allowed user: @{self.cfg.allowed_username}", flush=True) + + try: + while not self.stop_event.is_set(): + try: + offset = self.state.get("offset") + updates = self.telegram.get_updates(offset=offset, timeout_sec=25) + except Exception as e: + print(f"[py-bot] Ошибка getUpdates: {e}", flush=True) + time.sleep(2) + continue + + for update in updates: + update_id = update.get("update_id") + if isinstance(update_id, int): + self.state["offset"] = update_id + 1 + self._persist_state() + self._handle_update(update) + finally: + self.shutdown() + + def shutdown(self) -> None: + if self.stop_event.is_set(): + pass + self.stop_event.set() + self._stop_active_codex_process() + if self.worker.is_alive(): + self.worker.join(timeout=10) + if self.lock_fd is not None: + try: + fcntl.flock(self.lock_fd, fcntl.LOCK_UN) + finally: + self.lock_fd.close() + self.lock_fd = None + self._append_history_event("service_stopped", {}) + + def _ensure_dirs(self) -> None: + self.cfg.data_dir.mkdir(parents=True, exist_ok=True) + self.history_dir.mkdir(parents=True, exist_ok=True) + self.history_archive_dir.mkdir(parents=True, exist_ok=True) + self.task_center_dir.mkdir(parents=True, exist_ok=True) + + def _acquire_single_instance_lock(self) -> None: + self.lock_file.parent.mkdir(parents=True, exist_ok=True) + self.lock_fd = self.lock_file.open("a+") + try: + fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise RuntimeError(f"Уже запущен другой инстанс (lock: {self.lock_file})") + + def _load_state(self) -> None: + if self.state_file.exists(): + self.state = json.loads(self.state_file.read_text(encoding="utf-8")) + else: + self.state = {} + sessions = self.state.get("user_sessions") + if not isinstance(sessions, dict): + sessions = {} + self.state["user_sessions"] = sessions + user_settings = self.state.get("user_settings") + if not isinstance(user_settings, dict): + user_settings = {} + self.state["user_settings"] = user_settings + if not self.state.get("current_history_file"): + history_file = self._create_new_history_file("initial", self.cfg.allowed_username) + self.state["current_history_file"] = str(history_file) + sessions[self.cfg.allowed_username] = {"current_history_file": str(history_file)} + elif self.cfg.allowed_username not in sessions: + sessions[self.cfg.allowed_username] = {"current_history_file": str(self.state["current_history_file"])} + if not isinstance(self.state.get("next_job_number"), int): + self.state["next_job_number"] = 1 + self.state["updated_at"] = now_iso() + self._persist_state() + + def _persist_state(self) -> None: + self.state["updated_at"] = now_iso() + tmp = self.state_file.with_suffix(".tmp") + tmp.write_text(json.dumps(self.state, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(self.state_file) + + def _load_queue(self) -> None: + self.queue = JsonLineStore.load(self.queue_file) + + def _persist_queue(self) -> None: + JsonLineStore.save(self.queue_file, self.queue) + + def _load_processed_updates(self) -> None: + if not self.processed_updates_file.exists(): + self.processed_updates = [] + return + lines = [x.strip() for x in self.processed_updates_file.read_text(encoding="utf-8").splitlines() if x.strip()] + if len(lines) > self.max_processed_updates: + lines = lines[-self.max_processed_updates:] + self.processed_updates_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + self.processed_updates = lines + + def _mark_processed_update(self, update_key: str) -> bool: + if update_key in self.processed_updates: + return True + self.processed_updates.append(update_key) + if len(self.processed_updates) > self.max_processed_updates: + self.processed_updates = self.processed_updates[-self.max_processed_updates:] + self.processed_updates_file.write_text("\n".join(self.processed_updates) + "\n", encoding="utf-8") + else: + with self.processed_updates_file.open("a", encoding="utf-8") as f: + f.write(update_key + "\n") + return False + + def _recover_active_jobs_after_restart(self) -> None: + recovered_ids: list[str] = [] + for job in self.queue: + if job.get("status") == "active": + job["status"] = "pending" + job["retry_reason"] = "service_restart_recovery" + job["updated_at"] = now_iso() + recovered_ids.append(job.get("id", "")) + if recovered_ids: + self._persist_queue() + self._append_history_event("active_jobs_recovered", {"jobIds": recovered_ids}) + + def _init_offset_if_missing(self) -> None: + if self.state.get("offset") is not None: + return + try: + updates = self.telegram.get_updates(offset=None, timeout_sec=0) + if updates: + self.state["offset"] = int(updates[-1]["update_id"]) + 1 + else: + self.state["offset"] = 0 + self._persist_state() + except Exception as e: + print(f"[py-bot] Не удалось инициализировать offset: {e}", flush=True) + self.state["offset"] = 0 + self._persist_state() + + def _current_history_file(self) -> Path: + return self._current_history_file_for_user(self.cfg.allowed_username) + + def _history_dirs_for_user(self, username: str) -> tuple[Path, Path]: + uname = normalize_username(username) or "unknown" + history_dir = self.history_dir / uname + archive_dir = history_dir / "archive" + history_dir.mkdir(parents=True, exist_ok=True) + archive_dir.mkdir(parents=True, exist_ok=True) + return history_dir, archive_dir + + def _ensure_user_session(self, username: str) -> None: + uname = normalize_username(username) or self.cfg.allowed_username + sessions = self.state.setdefault("user_sessions", {}) + if not isinstance(sessions, dict): + sessions = {} + self.state["user_sessions"] = sessions + self._user_settings(uname) + session = sessions.get(uname) + if isinstance(session, dict) and session.get("current_history_file"): + return + history_file = self._create_new_history_file("initial", uname) + sessions[uname] = {"current_history_file": str(history_file)} + if uname == self.cfg.allowed_username: + self.state["current_history_file"] = str(history_file) + self._persist_state() + + def _user_session_state(self, username: str) -> dict[str, Any]: + uname = normalize_username(username) or self.cfg.allowed_username + self._ensure_user_session(uname) + sessions = self.state.get("user_sessions") or {} + session = sessions.get(uname) + if not isinstance(session, dict): + session = {} + sessions[uname] = session + return session + + def _current_history_file_for_user(self, username: str) -> Path: + session = self._user_session_state(username) + return Path(session["current_history_file"]) + + def _codex_thread_id_for_user(self, username: str) -> str: + thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip() + return thread_id + + def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None: + session = self._user_session_state(username) + normalized = (thread_id or "").strip() + if normalized: + session["codex_thread_id"] = normalized + else: + session.pop("codex_thread_id", None) + self._persist_state() + + def _create_new_history_file(self, reason: str, username: str) -> Path: + ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S") + rnd = "".join(random.choices(string.hexdigits.lower(), k=8)) + history_dir, _ = self._history_dirs_for_user(username) + path = history_dir / f"{ts}_{rnd}.jsonl" + JsonLineStore.append(path, { + "ts": now_iso(), + "type": "history_created", + "reason": reason, + "username": normalize_username(username), + }) + return path + + def _rotate_history(self, reason: str, username: str) -> Path: + uname = normalize_username(username) or self.cfg.allowed_username + current = self._current_history_file_for_user(uname) + _, archive_dir = self._history_dirs_for_user(uname) + if current.exists(): + archived = archive_dir / current.name + current.replace(archived) + else: + archived = archive_dir / "(empty)" + new_file = self._create_new_history_file(reason, uname) + sessions = self.state.setdefault("user_sessions", {}) + if not isinstance(sessions, dict): + sessions = {} + self.state["user_sessions"] = sessions + previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {} + sessions[uname] = {"current_history_file": str(new_file)} + if reason != "command_new" and isinstance(previous, dict): + thread_id = (previous.get("codex_thread_id") or "").strip() + if thread_id: + sessions[uname]["codex_thread_id"] = thread_id + if uname == self.cfg.allowed_username: + self.state["current_history_file"] = str(new_file) + self._persist_state() + self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname) + return archived + + def _user_settings(self, username: str) -> dict[str, Any]: + uname = normalize_username(username) or self.cfg.allowed_username + settings = self.state.get("user_settings") + if not isinstance(settings, dict): + settings = {} + self.state["user_settings"] = settings + user_settings = settings.get(uname) + if not isinstance(user_settings, dict): + user_settings = {} + settings[uname] = user_settings + if not isinstance(user_settings.get("voice_replies_enabled"), bool): + 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: + return bool(self._user_settings(username).get("voice_replies_enabled")) + + def _set_voice_replies_enabled(self, username: str, enabled: bool) -> None: + self._user_settings(username)["voice_replies_enabled"] = enabled + self._persist_state() + + def _voice_rewrite_enabled(self, username: str) -> bool: + return bool(self._user_settings(username).get("voice_rewrite_enabled")) + + def _set_voice_rewrite_enabled(self, username: str, enabled: bool) -> None: + 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: + return + private_chats = self.state.get("private_chat_ids") + if not isinstance(private_chats, dict): + private_chats = {} + self.state["private_chat_ids"] = private_chats + if private_chats.get(uname) == chat_id: + return + private_chats[uname] = chat_id + self._persist_state() + + def _private_chat_id_for_user(self, username: str) -> int | None: + private_chats = self.state.get("private_chat_ids") + if not isinstance(private_chats, dict): + return None + chat_id = private_chats.get(normalize_username(username)) + return self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else None + + def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: + row = {"ts": now_iso(), "type": event_type} + row.update(payload) + JsonLineStore.append(history_path, row) + + def _append_history_event(self, event_type: str, payload: dict[str, Any], username: str | None = None) -> None: + history_path = self._current_history_file_for_user(username or self.cfg.allowed_username) + self._append_history(history_path, "system_event", {"event": event_type, **payload}) + + def _load_task_items(self) -> list[dict[str, Any]]: + with self.task_center_lock: + if not self.task_center_file.exists(): + return [] + try: + data = json.loads(self.task_center_file.read_text(encoding="utf-8")) + except Exception: + return [] + return data if isinstance(data, list) else [] + + def _save_task_items(self, items: list[dict[str, Any]]) -> None: + with self.task_center_lock: + self.task_center_file.parent.mkdir(parents=True, exist_ok=True) + tmp = self.task_center_file.with_suffix(".tmp") + tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(self.task_center_file) + + def _next_task_item_id(self, items: list[dict[str, Any]]) -> str: + max_num = 0 + for item in items: + raw = str(item.get("id") or "") + match = re.match(r"TC-(\d+)$", raw) + if match: + max_num = max(max_num, int(match.group(1))) + return f"TC-{max_num + 1:04d}" + + def _create_task_item( + self, + *, + kind: str, + title: str, + text: str, + source_username: str, + target_username: str, + source_message_id: int | None = None, + source_chat_id: int | None = None, + opinion: str = "", + ) -> dict[str, Any]: + items = self._load_task_items() + item = { + "id": self._next_task_item_id(items), + "kind": kind, + "status": "new", + "title": compact_spaces(title)[:160] or ("Предложение" if kind == "proposal" else "Задача"), + "text": (text or "").strip(), + "opinion": (opinion or "").strip(), + "source_username": normalize_username(source_username), + "source_name": self._display_name(source_username), + "target_username": normalize_username(target_username), + "target_name": self._display_name(target_username), + "source_chat_id": source_chat_id, + "source_message_id": source_message_id, + "created_at": now_iso(), + "updated_at": now_iso(), + } + items.append(item) + self._save_task_items(items) + self._append_history_event("task_center_item_created", { + "itemId": item["id"], + "kind": kind, + "sourceUsername": item["source_username"], + "targetUsername": item["target_username"], + "title": item["title"], + }, username=source_username) + return item + + def _task_items_for_user(self, username: str, *, include_done: bool = False) -> list[dict[str, Any]]: + uname = normalize_username(username) + items = self._load_task_items() + result = [ + item for item in items + if normalize_username(item.get("target_username")) == uname + and (include_done or item.get("status") != "done") + ] + return sorted(result, key=lambda x: str(x.get("created_at") or "")) + + def _task_center_counts_text(self, username: str) -> str: + counts: dict[str, int] = {} + for item in self._task_items_for_user(username): + status = str(item.get("status") or "new") + counts[status] = counts.get(status, 0) + 1 + active_total = sum(counts.values()) + if active_total <= 0: + return "" + parts = [] + for status in ("new", "approved", "needs_work", "rejected"): + count = counts.get(status, 0) + if count: + parts.append(f"{TASK_STATUS_LABELS.get(status, status)}: {count}") + return f"Напоминание по задачам: всего активных {active_total}; " + ", ".join(parts) + "." + + def _format_task_items(self, username: str, *, include_done: bool = False) -> str: + items = self._task_items_for_user(username, include_done=include_done) + if not items: + return f"Для {self._display_name(username)} активных задач и предложений нет." + lines = [f"Задачи и предложения для {self._display_name(username)}:"] + for item in items[:15]: + kind = "предложение" if item.get("kind") == "proposal" else "задача" + status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) + source = item.get("source_name") or item.get("source_username") or "неизвестно" + title = item.get("title") or "(без названия)" + lines.append(f"{item.get('id')} [{status}] {kind} от {source}: {title}") + if len(items) > 15: + lines.append(f"...и ещё {len(items) - 15}") + return "\n".join(lines) + + def _update_task_item_status(self, item_id: str, status: str) -> dict[str, Any] | None: + item_id = (item_id or "").strip().upper() + items = self._load_task_items() + updated = None + for item in items: + if str(item.get("id") or "").upper() == item_id: + item["status"] = status + item["updated_at"] = now_iso() + updated = item + break + if updated is not None: + self._save_task_items(items) + return updated + + def _find_first_task_item(self, *, source_username: str = "", target_username: str = "", kind: str = "") -> dict[str, Any] | None: + source = normalize_username(source_username) + target = normalize_username(target_username) + for item in self._load_task_items(): + if item.get("status") == "done": + continue + if source and normalize_username(item.get("source_username")) != source: + continue + if target and normalize_username(item.get("target_username")) != target: + continue + if kind and item.get("kind") != kind: + continue + return item + return None + + def _notify_user_about_task_item(self, username: str, item: dict[str, Any]) -> None: + chat_id = self._private_chat_id_for_user(username) + if chat_id is None: + return + kind = "предложение" if item.get("kind") == "proposal" else "задача" + source = item.get("source_name") or item.get("source_username") or "кто-то" + title = item.get("title") or "(без названия)" + status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) + if item.get("status") == "new": + text = f"У тебя новое {kind} от {source}: {title}\nID: {item.get('id')}" + else: + text = f"Обновление по {kind} {item.get('id')}: статус «{status}».\n{title}" + self._safe_send(chat_id, text) + + def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None: + uname = normalize_username(username) + sent = self.state.get("player_welcome_sent") + if not isinstance(sent, dict): + sent = {} + self.state["player_welcome_sent"] = sent + if sent.get(uname): + return + player_name = self._player_name(uname) + text = ( + f"Привет, {player_name}.\n" + "Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n" + "Команда /new начинает новую Codex-сессию и архивирует текущую историю." + ) + reminder = self._task_center_counts_text(uname) + if reminder: + text = f"{text}\n\n{reminder}\nКоманда /tasks покажет список." + self._safe_send(chat_id, text, reply_to=message_id) + sent[uname] = now_iso() + self._persist_state() + + def _resolve_chat_id(self, chat_id: int) -> int: + migrations = self.state.get("chat_id_migrations") + if not isinstance(migrations, dict): + return chat_id + current = chat_id + visited: set[int] = set() + while current not in visited: + visited.add(current) + next_chat_id = migrations.get(str(current)) + if not isinstance(next_chat_id, int): + break + current = next_chat_id + return current + + def _remember_chat_migration(self, old_chat_id: int, new_chat_id: int, source: str) -> None: + if old_chat_id == new_chat_id: + return + migrations = self.state.get("chat_id_migrations") + if not isinstance(migrations, dict): + migrations = {} + self.state["chat_id_migrations"] = migrations + if migrations.get(str(old_chat_id)) == new_chat_id: + return + migrations[str(old_chat_id)] = new_chat_id + self._persist_state() + with self.queue_lock: + changed = False + for job in self.queue: + if job.get("chat_id") == old_chat_id: + job["chat_id"] = new_chat_id + job["updated_at"] = now_iso() + changed = True + if changed: + self._persist_queue() + self._append_history_event("chat_migrated_to_supergroup", { + "oldChatId": old_chat_id, + "newChatId": new_chat_id, + "source": source, + }) + + @staticmethod + def _extract_migrate_to_chat_id(error_text: str) -> int | None: + match = re.search(r'"migrate_to_chat_id"\s*:\s*(-?\d+)', error_text) + if match: + return int(match.group(1)) + match = re.search(r"'migrate_to_chat_id'\s*:\s*(-?\d+)", error_text) + if match: + return int(match.group(1)) + return None + + def _handle_update(self, update: dict[str, Any]) -> None: + message = update.get("message") + update_type = "message" + if not isinstance(message, dict): + message = update.get("channel_post") + update_type = "channel_post" + if not isinstance(message, dict): + return + chat = message.get("chat") or {} + chat_id = chat.get("id") + message_id = message.get("message_id") + chat_type = str(chat.get("type") or "") + chat_username = normalize_username(chat.get("username")) + chat_title = str(chat.get("title") or "") + sender = message.get("from") or {} + username = normalize_username(sender.get("username")) + author_signature = str(message.get("author_signature") or "").strip() + author_username = username or normalize_username(author_signature) + if not isinstance(chat_id, int) or not isinstance(message_id, int): + return + + migrate_to_chat_id = message.get("migrate_to_chat_id") + if isinstance(migrate_to_chat_id, int): + self._remember_chat_migration(chat_id, migrate_to_chat_id, "telegram_message") + return + + update_key = f"{chat_id}:{message_id}" + if self._mark_processed_update(update_key): + return + + is_channel_post = update_type == "channel_post" or chat_type == "channel" + is_group_message = update_type == "message" and chat_type in ("group", "supergroup") + is_allowed_channel = ( + not is_channel_post + or not self.cfg.allowed_channel_username + or chat_username == self.cfg.allowed_channel_username + ) + if is_channel_post and not is_allowed_channel: + return + if chat_username and chat_username == self.cfg.allowed_channel_username: + self._remember_public_report_chat(chat_id) + + # Игнорируем системные сообщения о входе/выходе и смене заголовка/фото. + if message.get("new_chat_members") or message.get("left_chat_member"): + return + if message.get("group_chat_created") or message.get("supergroup_chat_created") or message.get("channel_chat_created"): + return + + text = (message.get("text") or message.get("caption") or "").strip() + actor_username = normalize_username(author_username) + is_allowed = self._is_allowed_user(actor_username) + is_private = chat_type == "private" + if not is_allowed: + if is_private: + self._safe_send(chat_id, "Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.", reply_to=message_id) + return + if self._is_allowed_player(actor_username) and not is_private: + return + + self._ensure_user_session(actor_username) + if is_private: + self._remember_private_chat(actor_username, chat_id) + history_path = self._current_history_file_for_user(actor_username) + if self._is_allowed_player(actor_username): + self._send_player_welcome_once(chat_id, message_id, actor_username) + + if not text: + if message.get("voice"): + self._enqueue_voice_job( + chat_id, + message_id, + actor_username, + message["voice"].get("file_id"), + duration_seconds=message["voice"].get("duration"), + telegram_file_size=message["voice"].get("file_size"), + media_type="voice", + update_type=update_type, + chat_username=chat_username, + chat_title=chat_title, + author_signature=author_signature, + chat_type=chat_type, + ) + return + if message.get("audio"): + self._enqueue_voice_job( + chat_id, + message_id, + actor_username, + message["audio"].get("file_id"), + duration_seconds=message["audio"].get("duration"), + telegram_file_size=message["audio"].get("file_size"), + media_type="audio", + update_type=update_type, + chat_username=chat_username, + chat_title=chat_title, + author_signature=author_signature, + chat_type=chat_type, + ) + return + self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id) + return + + if text.startswith("/"): + self._handle_command(chat_id, message_id, actor_username, text) + return + + if self._handle_task_center_text(chat_id, message_id, actor_username, text): + return + + self._append_history(history_path, "incoming_text", { + "chatId": chat_id, + "messageId": message_id, + "updateType": update_type, + "chatType": chat_type, + "chatUsername": chat_username, + "chatTitle": chat_title, + "username": actor_username, + "authorSignature": author_signature, + "text": text, + }) + job = self._build_job_base(chat_id, message_id, actor_username, str(history_path)) + job["type"] = "text" + job["text"] = text + job["update_type"] = update_type + job["chat_type"] = chat_type + job["chat_username"] = chat_username + job["chat_title"] = chat_title + job["author_signature"] = author_signature + job["role"] = "owner" if self._is_owner(actor_username) else "player" + job["player_name"] = self._player_name(actor_username) if job["role"] == "player" else "" + with self.queue_lock: + self.queue.append(job) + 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) + + def _enqueue_voice_job( + self, + chat_id: int, + message_id: int, + username: str, + file_id: str | None, + *, + duration_seconds: int | None = None, + telegram_file_size: int | None = None, + media_type: str = "voice", + update_type: str = "message", + chat_username: str = "", + chat_title: str = "", + author_signature: str = "", + chat_type: str = "", + ) -> None: + if not file_id: + self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id) + return + history_path = self._current_history_file_for_user(username) + self._append_history(history_path, "incoming_voice", { + "chatId": chat_id, + "messageId": message_id, + "updateType": update_type, + "chatType": chat_type, + "chatUsername": chat_username, + "chatTitle": chat_title, + "username": username, + "authorSignature": author_signature, + "fileId": file_id, + "mediaType": media_type, + "durationSeconds": duration_seconds, + "fileSize": telegram_file_size, + }) + job = self._build_job_base(chat_id, message_id, username, str(history_path)) + job["type"] = "voice" + job["telegram_file_id"] = file_id + job["telegram_media_type"] = media_type + job["telegram_duration_seconds"] = duration_seconds or 0 + job["telegram_file_size"] = telegram_file_size or 0 + job["update_type"] = update_type + job["chat_type"] = chat_type + job["chat_username"] = chat_username + job["chat_title"] = chat_title + job["author_signature"] = author_signature + job["role"] = "owner" if self._is_owner(username) else "player" + job["player_name"] = self._player_name(username) if job["role"] == "player" else "" + with self.queue_lock: + self.queue.append(job) + 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) + + def _build_job_base(self, chat_id: int, message_id: int, username: str, history_file: str) -> dict[str, Any]: + with self.queue_lock: + num = int(self.state.get("next_job_number", 1)) + self.state["next_job_number"] = num + 1 + self._persist_state() + return { + "id": str(uuid.uuid4()), + "num": num, + "status": "pending", + "type": "text", + "chat_id": chat_id, + "message_id": message_id, + "username": username, + "update_type": "message", + "chat_type": "", + "chat_username": "", + "chat_title": "", + "author_signature": "", + "role": "owner", + "player_name": "", + "text": "", + "telegram_file_id": "", + "telegram_media_type": "", + "history_file": history_file, + "attempts": 0, + "retry_reason": "", + "last_error": "", + "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: + source_text = (text or "").strip() + lower = source_text.lower() + is_owner = self._is_owner(username) + + if re.search(r"\b(покажи|список|какие)\b.*\b(задач|задачи|предложени)", lower): + target = username + explicit_target = self._find_user_by_text(source_text) + if is_owner and explicit_target: + target = explicit_target + self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) + return True + + if is_owner: + assign_match = re.search( + r"(?:поставь|добавь|создай|запиши)\s+(?:задачу|задание)\s+(.+?)(?::|\s+-\s+|\s+—\s+)(.+)", + source_text, + flags=re.IGNORECASE | re.DOTALL, + ) + if assign_match: + target = self._find_user_by_text(assign_match.group(1)) + body = assign_match.group(2).strip() + if target and body: + item = self._create_task_item( + kind="task", + title=body, + text=body, + source_username=username, + target_username=target, + source_message_id=message_id, + source_chat_id=chat_id, + ) + self._notify_user_about_task_item(target, item) + self._safe_send( + chat_id, + f"Задача добавлена для {self._display_name(target)}: {item['id']} — {item['title']}", + reply_to=message_id, + ) + return True + + status_match = re.search(r"\b(одобрить|отклонить|доработать|закрыть|сделано|закрыта)\b(?:\s+(.+))?", lower, flags=re.IGNORECASE) + if status_match and ("задач" in lower or "предложени" in lower or re.search(r"tc-\d+", lower, flags=re.IGNORECASE)): + action = status_match.group(1) + tail = status_match.group(2) or "" + status = { + "одобрить": "approved", + "отклонить": "rejected", + "доработать": "needs_work", + "закрыть": "done", + "сделано": "done", + "закрыта": "done", + }.get(action, "new") + id_match = re.search(r"tc-\d+", source_text, flags=re.IGNORECASE) + item = None + if id_match: + item = self._update_task_item_status(id_match.group(0), status) + else: + source_user = self._find_user_by_text(tail) + item = self._find_first_task_item( + source_username=source_user, + target_username=username, + kind="proposal" if "предложени" in lower else "", + ) + if item: + item = self._update_task_item_status(str(item.get("id")), status) + if item: + label = TASK_STATUS_LABELS.get(status, status) + self._safe_send(chat_id, f"{item.get('id')} обновлена: {label}.", reply_to=message_id) + source_user = normalize_username(item.get("source_username")) + if source_user and source_user != username: + self._notify_user_about_task_item(source_user, item) + return True + + if not is_owner: + proposal_match = re.match(r"\s*(?:предложение|идея|заявка)\s*[::-]\s*(.+)", source_text, flags=re.IGNORECASE | re.DOTALL) + if proposal_match: + body = proposal_match.group(1).strip() + if body: + item = self._create_task_item( + kind="proposal", + title=body, + text=body, + opinion="Нужно решение Айдара: одобрить, отклонить или отправить на доработку.", + source_username=username, + target_username=self.cfg.allowed_username, + source_message_id=message_id, + source_chat_id=chat_id, + ) + self._notify_user_about_task_item(self.cfg.allowed_username, item) + self._safe_send( + chat_id, + f"Предложение отправлено Айдару как {item['id']}. Статус: новая.", + reply_to=message_id, + ) + return True + + return False + + def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None: + lower = text.lower() + command = lower.split(maxsplit=1)[0].split("@", 1)[0] + is_owner = self._is_owner(username) + 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 + if command == "/queue": + self._safe_send(chat_id, self._queue_text(), reply_to=message_id) + return + if command in ("/tasks", "/my_tasks"): + parts = text.split(maxsplit=1) + target = username + if is_owner and len(parts) > 1: + parsed = self._find_user_by_text(parts[1]) + if parsed: + target = parsed + self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) + return + if command == "/voice_on": + self._set_voice_replies_enabled(username, True) + self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Озвучивание финальных ответов включено для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_off": + self._set_voice_replies_enabled(username, False) + self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id) + return + if command in ("/voice_status", "/voice_rewrite_status"): + self._safe_send(chat_id, self._status_text(username), reply_to=message_id) + return + if command == "/voice_rewrite_on": + self._set_voice_rewrite_enabled(username, True) + self._append_history_event("voice_rewrite_enabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Адаптация текста перед озвучкой включена для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_rewrite_off": + self._set_voice_rewrite_enabled(username, False) + 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) + return + if command in ("/restart_service", "/restart"): + if not is_owner: + self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) + return + self._append_history_event("restart_service_deferred_requested", { + "chatId": chat_id, + "messageId": message_id, + "username": username, + }, username=username) + self._safe_send( + chat_id, + "Отложенный рестарт принят. Если задача сейчас выполняется, сервис перезапустится после её завершения и до следующей задачи.", + reply_to=message_id, + ) + self._request_deferred_restart() + return + if command in ("/restart_hard", "/restart_now", "/restart_force"): + if not is_owner: + self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) + return + self._append_history_event("restart_service_hard_requested", { + "chatId": chat_id, + "messageId": message_id, + "username": username, + }, username=username) + self._safe_send( + chat_id, + "Выполняю жёсткий рестарт сервиса прямо сейчас. Активная задача, если есть, будет прервана и после старта вернётся в очередь.", + reply_to=message_id, + ) + self._schedule_self_restart("hard_restart_requested", force=True) + return + if command == "/stop": + stopped = self._cancel_active_job("stopped_by_user") + if stopped: + self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id) + else: + self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id) + return + if command == "/cancel": + parts = text.split(maxsplit=1) + if len(parts) < 2: + self._safe_send(chat_id, "Использование: /cancel ", reply_to=message_id) + return + arg = parts[1].strip() + if arg.lower() == "all": + with self.queue_lock: + self.stop_current_job = True + self._stop_active_codex_process() + count = len(self.queue) + self.queue = [] + self._persist_queue() + self._safe_send(chat_id, f"Удалено задач из очереди: {count}", reply_to=message_id) + return + cancelled = self._cancel_by_id_prefix(arg) + self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id) + return + + def _help_text(self, *, is_owner: bool) -> str: + lines = [ + "Доступные команды:", + "/status — активная задача и размер очереди", + "/settings — текущие настройки и команды для их изменения", + "/queue — список задач в очереди", + "/tasks — список ваших задач и предложений", + "/stop — остановить текущую задачу", + "/cancel — удалить задачу по id (префикс) или все", + "/new — архивировать историю и начать новую Codex-сессию", + "/help — эта справка", + ] + if is_owner: + lines.insert(-1, "/tasks <пользователь> — список задач пользователя") + lines.insert(-1, "/restart — отложенный рестарт после текущей задачи") + 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}\n" + f"Режим одного сообщения в личке: {single_message_status}" + ) + restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else "" + if not active: + return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}{restart_text}" + elapsed = int(time.time() - (self.active_job_started_at or time.time())) + return ( + f"Статус: активная задача #{active.get('num', '?')}\n" + f"Тип: {active.get('type', 'text')}\n" + f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" + f"Выполняется: {elapsed}с\n" + f"Pending: {pending}\n" + f"{settings_text}{restart_text}" + ) + + def _queue_text(self) -> str: + with self.queue_lock: + items = list(self.queue) + if not items: + return "Очередь пуста." + lines = [f"Очередь: {len(items)}"] + for i, job in enumerate(items[:10], start=1): + lines.append( + f"{i}) #{job.get('num', '?')} [{job.get('status')}] {job.get('type')} attempts={job.get('attempts', 0)}" + ) + if len(items) > 10: + lines.append(f"...и ещё {len(items) - 10} задач") + return "\n".join(lines) + + def _cancel_active_job(self, reason: str) -> bool: + with self.queue_lock: + active = next((j for j in self.queue if j.get("status") == "active"), None) + if not active: + return False + self.stop_current_job = True + self._stop_active_codex_process() + self.queue = [j for j in self.queue if j.get("id") != active.get("id")] + self._persist_queue() + self._append_history_event("job_stopped_by_user", {"jobId": active.get("id"), "reason": reason}) + return True + + def _cancel_by_id_prefix(self, prefix: str) -> bool: + prefix = prefix.strip().lower() + normalized_num = prefix.lstrip("#") + with self.queue_lock: + target = next( + ( + j for j in self.queue + if str(j.get("id", "")).lower().startswith(prefix) + or str(j.get("num", "")).lower() == normalized_num + ), + None + ) + if not target: + return False + if target.get("status") == "active": + self.stop_current_job = True + self._stop_active_codex_process() + self.queue = [j for j in self.queue if j.get("id") != target.get("id")] + self._persist_queue() + return True + + def _worker_loop(self) -> None: + while not self.stop_event.is_set(): + if self.restart_requested: + self._exit_for_restart("deferred_restart_before_next_job") + return + job = None + with self.queue_lock: + for item in self.queue: + if item.get("status") == "pending": + item["status"] = "active" + item["active_since"] = now_iso() + item["updated_at"] = now_iso() + self.active_job_id = item.get("id") + self.active_job_started_at = time.time() + job = dict(item) + self._persist_queue() + break + if not job: + time.sleep(0.5) + continue + + self.stop_current_job = False + self._process_job(job) + self.active_job_id = None + self.active_job_started_at = None + if self.restart_requested: + self._exit_for_restart("deferred_restart_after_job") + return + + def _process_job(self, job: dict[str, Any]) -> None: + job_id = job["id"] + job_num = job.get("num", "?") + chat_id = int(job["chat_id"]) + message_id = int(job["message_id"]) + history_path = Path(job["history_file"]) + 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._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: распознаю voice.") + recognized = self._transcribe_voice_job( + job, + 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) > 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}) + 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._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._set_job_status_text(job, f"Задача #{job_num} остановлена.") + self._mark_job_removed(job_id) + self.stop_current_job = False + return + if isinstance(e, VoiceTranscriptionError): + self._append_history(history_path, "voice_transcription_failed", { + "jobId": job_id, + "jobNum": job_num, + "stage": e.stage, + "retryable": e.retryable, + "error": e.user_message, + "detail": e.detail, + }) + self._handle_job_failure(job, e) + + def _build_prompt(self, job: dict[str, Any]) -> str: + retry_block = "" + retry_reason = (job.get("retry_reason") or "").strip() + if retry_reason: + retry_block = f"\n\nПометка retry: {retry_reason}" + return ( + "Пришло сообщение в Telegram.\n" + f"Тип: {job.get('type')}\n" + f"Источник Telegram: {job.get('update_type', 'message')}\n" + f"Тип чата: {job.get('chat_type') or ''}\n" + f"Канал/чат: @{job.get('chat_username') or ''} {job.get('chat_title') or ''}\n" + f"Username отправителя: @{job.get('username')}\n" + f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n" + "Текст для обработки:\n" + f"{job.get('text')}\n\n" + f"История диалога (JSONL): {job.get('history_file')}\n" + f"Инструкции агента: {self.cfg.agent_instructions_file}\n" + f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{retry_block}" + ) + + def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: + username = job.get("username") or self.cfg.allowed_username + thread_id = self._codex_thread_id_for_user(username) + try: + return self._run_codex_once(prompt, job, thread_id=thread_id) + except RuntimeError as e: + if not thread_id or not self._is_missing_codex_session_error(str(e)): + raise + self._set_codex_thread_id_for_user(username, "") + self._append_history( + Path(job["history_file"]), + "system_event", + { + "event": "codex_thread_reset", + "reason": "missing_session", + "username": normalize_username(username), + "oldThreadId": thread_id, + }, + ) + return self._run_codex_once(prompt, job, thread_id="") + + def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str: + output_lines: list[str] = [] + job_id = str(job["id"]) + job_num = job.get("num", "?") + username = job.get("username") or self.cfg.allowed_username + with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: + output_file = Path(tmp.name) + + cmd = [ + str(self.cfg.codex_bin), + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "-C", str(self.cfg.codex_workdir), + "-o", str(output_file), + ] + if thread_id: + cmd.extend(["resume", thread_id]) + cmd.append(prompt) + mode = f"resume {thread_id}" if thread_id else "new" + print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True) + process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + with self.active_process_lock: + self.active_process = process + + self.last_heartbeat_at = 0.0 + last_user_note = "" + last_user_note_at = 0.0 + codex_started_at = time.time() + last_job_message_at = codex_started_at + seen_thread_id = "" + + def on_line(line: str) -> None: + nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id + output_lines.append(line) + current_thread_id = self._extract_codex_thread_id(line) + if current_thread_id: + seen_thread_id = current_thread_id + 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._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: {note}") + last_user_note = note + last_user_note_at = now + last_job_message_at = now + + reader_done = threading.Event() + + def reader() -> None: + if not process.stdout: + reader_done.set() + return + for line in process.stdout: + on_line(line.rstrip("\n")) + reader_done.set() + + t = threading.Thread(target=reader, name=f"codex-reader-{job_id[:8]}", daemon=True) + t.start() + + try: + deadline = time.time() + self.cfg.codex_timeout_seconds + return_code = None + while return_code is None: + return_code = process.poll() + now = time.time() + if return_code is not None: + break + if now >= deadline: + process.kill() + t.join(timeout=2) + 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._set_job_status_text( + job, + f"Задача #{job_num} в работе.\nСтатус: выполняется уже {elapsed}, от Codex давно нет новых сообщений.", + ) + last_job_message_at = now + self.last_heartbeat_at = now + time.sleep(1) + finally: + with self.active_process_lock: + self.active_process = None + + reader_done.wait(timeout=2) + + if return_code != 0: + tail = "\n".join(output_lines[-40:]) + raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}") + + if seen_thread_id and seen_thread_id != thread_id: + self._set_codex_thread_id_for_user(username, seen_thread_id) + + if output_file.exists(): + answer = output_file.read_text(encoding="utf-8").strip() + try: + output_file.unlink(missing_ok=True) + except Exception: + pass + if answer: + return answer + + fallback = self._extract_fallback_message(output_lines) + if not fallback: + raise RuntimeError("Codex returned empty response") + return fallback + + def _stop_active_codex_process(self) -> bool: + with self.active_process_lock: + process = self.active_process + if process is None: + return False + if process.poll() is not None: + return False + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + return True + + def _handle_job_failure(self, job: dict[str, Any], err: Exception) -> None: + job_id = job["id"] + job_num = job.get("num", "?") + 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 + log_error_text = err.log_text() if isinstance(err, VoiceTranscriptionError) else error_text + print(f"[py-bot] Ошибка job={job_id[:8]}: {log_error_text}", flush=True) + print(traceback.format_exc(), flush=True) + + with self.queue_lock: + target = next((j for j in self.queue if j.get("id") == job_id), None) + if not target: + return + attempts = int(target.get("attempts", 0)) + 1 + target["attempts"] = attempts + target["last_error"] = error_text[:1000] + target["updated_at"] = now_iso() + if retryable and attempts < self.cfg.max_retries: + target["status"] = "pending" + target["retry_reason"] = error_text[:200] + self._persist_queue() + will_retry = True + else: + self.queue = [j for j in self.queue if j.get("id") != job_id] + self._persist_queue() + will_retry = False + + if will_retry: + self._set_job_status_text( + job, + f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.", + ) + else: + 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): + return f"Не удалось распознать голосовое: {err.user_message}" + error_text = str(err).strip() or err.__class__.__name__ + return f"Ошибка выполнения задачи: {error_text}" + + def _mark_job_done(self, job_id: str) -> None: + with self.queue_lock: + self.queue = [j for j in self.queue if j.get("id") != job_id] + self._persist_queue() + + def _mark_job_removed(self, job_id: str) -> None: + with self.queue_lock: + self.queue = [j for j in self.queue if j.get("id") != job_id] + self._persist_queue() + + def _send_task_center_reminder(self, job: dict[str, Any]) -> None: + if job.get("chat_type") != "private": + return + username = job.get("username") or "" + reminder = self._task_center_counts_text(username) + if not reminder: + return + self._safe_send(int(job["chat_id"]), reminder, reply_to=int(job["message_id"])) + + def _remember_public_report_chat(self, chat_id: int) -> None: + if self.state.get("public_report_chat_id") == chat_id: + return + self.state["public_report_chat_id"] = chat_id + self.state["updated_at"] = now_iso() + self._persist_state() + + def _public_report_chat_id(self) -> int | str | None: + chat_id = self.state.get("public_report_chat_id") + if isinstance(chat_id, int): + return self._resolve_chat_id(chat_id) + if self.cfg.allowed_channel_username: + return f"@{self.cfg.allowed_channel_username}" + return None + + def _send_private_job_public_report(self, job: dict[str, Any], answer: str) -> None: + if job.get("chat_type") != "private": + return + report_chat_id = self._public_report_chat_id() + if report_chat_id is None: + return + + job_num = job.get("num", "?") + source_text = (job.get("text") or "").strip() + if not source_text: + source_text = "(пустой текст запроса)" + role = (job.get("role") or "owner").strip().lower() + author_label = "Айдар" + if role == "player": + player_name = (job.get("player_name") or "").strip() or job.get("username") or "Игрок" + author_label = f"{player_name} (@{job.get('username')})" + if job.get("type") == "voice": + voice_file_id = (job.get("telegram_file_id") or "").strip() + media_type = (job.get("telegram_media_type") or "voice").strip() + request_caption = self._trim_telegram_caption( + f"{author_label} сделал {media_type}-запрос, задача #{job_num}.\n\n" + f"Распознанный текст:\n{source_text}" + ) + request_message_id = None + if voice_file_id: + request_message_id = self._safe_send_telegram_file( + report_chat_id, + voice_file_id, + media_type=media_type, + caption=request_caption, + ) + if request_message_id is None: + request_message_id = self._safe_send(report_chat_id, request_caption) + else: + request_report = ( + f"{author_label} сделал запрос, задача #{job_num}.\n\n" + f"{source_text}" + ) + request_message_id = self._safe_send(report_chat_id, request_report) + if request_message_id is None: + return + + answer_text = (answer or "").strip() or "(пустой ответ)" + answer_chunks = split_long_text(f"Ответ на задачу #{job_num}:\n\n{answer_text}") + for chunk in answer_chunks: + self._safe_send(report_chat_id, chunk, reply_to=request_message_id) + + @staticmethod + def _trim_telegram_caption(text: str, limit: int = 1000) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return text[:limit].rstrip() + "\n...[обрезано]" + + def _safe_send_telegram_file( + self, + chat_id: int | str, + file_id: str, + *, + media_type: str = "voice", + caption: str = "", + reply_to: int | None = None, + ) -> int | None: + file_id = (file_id or "").strip() + if not file_id: + return None + caption = self._trim_telegram_caption(caption) + resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id + resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None + + def send(target_chat_id: int | str, target_reply_to: int | None) -> dict[str, Any]: + if media_type == "audio": + return self.telegram.send_audio(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to) + return self.telegram.send_voice(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to) + + try: + sent = send(resolved_chat_id, resolved_reply_to) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as e: + migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e)) + 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, "send_file_error") + try: + sent = send(migrate_to_chat_id, None) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as retry_error: + print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True) + return None + print(f"[py-bot] sendFile error: {e}", flush=True) + return None + + def _safe_send_voice_upload( + self, + chat_id: int | str, + voice_bytes: bytes, + filename: str, + *, + caption: str = "", + reply_to: int | None = None, + ) -> int | None: + if not voice_bytes: + return None + caption = self._trim_telegram_caption(caption) + resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id + resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None + + def send(target_chat_id: int | str, target_reply_to: int | None) -> dict[str, Any]: + return self.telegram.send_voice_upload( + target_chat_id, + voice_bytes, + filename, + caption=caption, + reply_to_message_id=target_reply_to, + ) + + try: + sent = send(resolved_chat_id, resolved_reply_to) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as e: + migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e)) + 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, "send_voice_upload_error") + try: + sent = send(migrate_to_chat_id, None) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as retry_error: + print(f"[py-bot] sendVoiceUpload retry after migration error: {retry_error}", flush=True) + return None + print(f"[py-bot] sendVoiceUpload error: {e}", flush=True) + return None + + def _safe_send(self, chat_id: int | str, text: str, reply_to: int | None = None) -> int | None: + text = (text or "").strip() + if not text: + return None + 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 + resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None + try: + sent = self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as e: + migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e)) + 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, "send_message_error") + try: + sent = self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None) + result = sent.get("result") or {} + message_id = result.get("message_id") + return message_id if isinstance(message_id, int) else None + except Exception as retry_error: + print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True) + return None + 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 + self.restart_requested = True + self._append_history_event("restart_service_deferred_scheduled", {}) + with self.queue_lock: + has_active = any(j.get("status") == "active" for j in self.queue) + if not has_active: + threading.Thread( + target=lambda: self._exit_for_restart("deferred_restart_no_active_job"), + name="shine-py-bot-deferred-restart", + daemon=True, + ).start() + + def _exit_for_restart(self, reason: str) -> None: + print(f"[py-bot] restart now: {reason}", flush=True) + self._append_history_event("restart_service_executing", {"reason": reason}) + time.sleep(0.5) + os._exit(0) + + def _schedule_self_restart(self, reason: str = "restart_requested", *, force: bool = False) -> None: + if self.restart_requested and not force: + return + self.restart_requested = True + + def restart() -> None: + time.sleep(1.5) + print(f"[py-bot] restart requested by Telegram command: {reason}", flush=True) + if force: + self._stop_active_codex_process() + os._exit(0) + + threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() + + def _voice_reply_targets(self, job: dict[str, Any]) -> list[tuple[int | str, int | None, str]]: + chat_id = int(job["chat_id"]) + message_id = int(job["message_id"]) + targets: list[tuple[int | str, int | None, str]] = [(chat_id, message_id, "source")] + username = job.get("username") or "" + + private_chat_id = self._private_chat_id_for_user(username) + if private_chat_id is not None and private_chat_id != self._resolve_chat_id(chat_id): + targets.append((private_chat_id, None, "private")) + + report_chat_id = self._public_report_chat_id() + if report_chat_id is not None: + resolved_report_chat_id = self._resolve_chat_id(report_chat_id) if isinstance(report_chat_id, int) else report_chat_id + resolved_current_chat_id: int | str = self._resolve_chat_id(chat_id) + if resolved_report_chat_id != resolved_current_chat_id: + targets.append((report_chat_id, None, "public")) + + deduped: list[tuple[int | str, int | None, str]] = [] + seen: set[str] = set() + for target_chat_id, target_reply_to, label in targets: + resolved: int | str = self._resolve_chat_id(target_chat_id) if isinstance(target_chat_id, int) else target_chat_id + key = str(resolved) + if key in seen: + continue + seen.add(key) + deduped.append((target_chat_id, target_reply_to, label)) + return deduped + + def _send_voice_reply_for_answer( + self, + job: dict[str, Any], + answer: str, + history_path: Path, + job_id: str, + ) -> None: + chat_id = int(job["chat_id"]) + message_id = int(job["message_id"]) + job_num = job.get("num", "?") + username = job.get("username") or "" + if not self.cfg.openai_api_key: + note = "не настроен ключ OpenAI для озвучивания." + self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) + self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) + return + + voice_text = answer + rewrite_enabled = self._voice_rewrite_enabled(username) + if rewrite_enabled: + try: + voice_text = self._openai_rewrite_text_for_voice(answer) + self._append_history(history_path, "voice_rewrite_done", { + "jobId": job_id, + "jobNum": job_num, + "model": self.cfg.openai_voice_rewrite_model, + "sourceChars": len(answer or ""), + "resultChars": len(voice_text or ""), + }) + except VoiceReplyError as e: + self._append_history(history_path, "voice_rewrite_failed", { + "jobId": job_id, + "jobNum": job_num, + "model": self.cfg.openai_voice_rewrite_model, + "error": str(e), + }) + self._safe_send( + chat_id, + f"Не удалось адаптировать ответ #{job_num} для озвучки, озвучиваю обычный текст: {e}", + reply_to=message_id, + ) + voice_text = answer + + chunks = split_text_for_tts(voice_text, self.cfg.openai_tts_chunk_chars) + if not chunks: + return + + sent_count = 0 + total = len(chunks) + targets = self._voice_reply_targets(job) + print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total} targets={len(targets)} rewrite={rewrite_enabled}", flush=True) + for index, chunk in enumerate(chunks, start=1): + try: + audio = self._openai_tts(chunk) + except VoiceReplyError as e: + self._append_history(history_path, "voice_reply_failed", { + "jobId": job_id, + "jobNum": job_num, + "part": index, + "parts": total, + "error": str(e), + }) + self._safe_send(chat_id, f"Не удалось озвучить ответ #{job_num}: {e}", reply_to=message_id) + return + caption = f"Озвучка ответа #{job_num}" + if total > 1: + caption += f", часть {index}/{total}" + for target_chat_id, target_reply_to, target_label in targets: + message_sent = self._safe_send_voice_upload( + target_chat_id, + audio, + f"shine-answer-{job_num}-{index}.ogg", + caption=caption, + reply_to=target_reply_to, + ) + if message_sent is None: + self._append_history(history_path, "voice_reply_failed", { + "jobId": job_id, + "jobNum": job_num, + "part": index, + "parts": total, + "target": target_label, + "error": "Telegram не принял voice-файл озвучки.", + }) + if target_label == "source": + self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) + continue + sent_count += 1 + self._append_history(history_path, "voice_reply_sent", { + "jobId": job_id, + "jobNum": job_num, + "parts": total, + "messages": sent_count, + "targets": len(targets), + "rewriteEnabled": rewrite_enabled, + }) + print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) + + def _openai_rewrite_text_for_voice(self, text: str) -> str: + source = (text or "").strip() + if not source: + return "" + if len(source) > self.cfg.openai_voice_rewrite_max_input_chars: + source = source[:self.cfg.openai_voice_rewrite_max_input_chars].rstrip() + "\n\n...[текстовый ответ был длиннее и обрезан для голосовой версии]" + payload = { + "model": self.cfg.openai_voice_rewrite_model, + "messages": [ + { + "role": "system", + "content": ( + "Ты готовишь русскую версию финального ответа технического агента для озвучивания. " + "Не пересказывай заново и не меняй смысл: сохрани порядок мыслей, итог, предупреждения, статусы и важные действия. " + "Мягко убери только то, что плохо воспринимается на слух: длинные пути, хэши, ID, команды, JSON, " + "длинные списки файлов, точные размеры и счётчики символов. Если деталь важна, замени её коротким описанием. " + "Не добавляй новых фактов. Пиши естественно, без markdown, близко к исходному тексту." + ), + }, + { + "role": "user", + "content": f"Переделай этот финальный текст в вариант для озвучки:\n\n{source}", + }, + ], + "temperature": 0.2, + "max_tokens": self.cfg.openai_voice_rewrite_max_output_tokens, + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request("https://api.openai.com/v1/chat/completions", method="POST", data=data) + req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") + req.add_header("Content-Type", "application/json") + try: + with request.urlopen(req, timeout=self.cfg.openai_voice_rewrite_timeout_seconds) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except TimeoutError as e: + raise VoiceReplyError( + f"OpenAI не успел адаптировать текст за {self.cfg.openai_voice_rewrite_timeout_seconds} секунд." + ) from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + if e.code == 401: + message = "OpenAI отклонил ключ API для адаптации текста." + elif e.code == 429: + message = "OpenAI временно ограничил адаптацию текста из-за лимита запросов." + elif e.code >= 500: + message = "OpenAI временно не смог адаптировать текст." + else: + message = f"OpenAI вернул ошибку HTTP {e.code} при адаптации текста." + if detail: + message = f"{message} Детали: {detail[:500]}" + raise VoiceReplyError(message) from e + except error.URLError as e: + raise VoiceReplyError(f"не удалось отправить текст в OpenAI для адаптации из-за сетевой ошибки: {e.reason}") from e + + try: + body = json.loads(raw) + content = (((body.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() + except Exception as e: + raise VoiceReplyError("OpenAI вернул неразборчивый ответ при адаптации текста.") from e + if not content: + raise VoiceReplyError("OpenAI вернул пустой текст адаптации.") + return content + + def _openai_tts(self, text: str) -> bytes: + payload = { + "model": self.cfg.openai_tts_model, + "voice": self.cfg.openai_tts_voice, + "input": text, + "response_format": self.cfg.openai_tts_response_format, + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request("https://api.openai.com/v1/audio/speech", method="POST", data=data) + req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") + req.add_header("Content-Type", "application/json") + try: + with request.urlopen(req, timeout=self.cfg.openai_tts_timeout_seconds) as resp: + audio = resp.read() + except TimeoutError as e: + raise VoiceReplyError(f"OpenAI не успел сгенерировать речь за {self.cfg.openai_tts_timeout_seconds} секунд.") from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + if e.code == 401: + message = "OpenAI отклонил ключ API для озвучивания." + elif e.code == 429: + message = "OpenAI временно ограничил озвучивание из-за лимита запросов." + elif e.code >= 500: + message = "OpenAI временно не смог сгенерировать речь." + else: + message = f"OpenAI вернул ошибку HTTP {e.code} при озвучивании." + if detail: + message = f"{message} Детали: {detail[:500]}" + raise VoiceReplyError(message) from e + except error.URLError as e: + raise VoiceReplyError(f"не удалось отправить текст в OpenAI TTS из-за сетевой ошибки: {e.reason}") from e + if not audio: + raise VoiceReplyError("OpenAI вернул пустой аудиофайл.") + return audio + + def _transcribe_voice_job( + self, + job: dict[str, Any], + *, + status_cb: Callable[[str], Any] | None = None, + ) -> str: + if not self.cfg.openai_api_key: + raise VoiceTranscriptionError( + "не настроен ключ OpenAI для распознавания.", + stage="config", + retryable=False, + ) + file_id = (job.get("telegram_file_id") or "").strip() + if not file_id: + raise VoiceTranscriptionError( + "Telegram не передал идентификатор файла.", + stage="telegram_file_id", + retryable=False, + ) + job_id = str(job.get("id") or "")[:8] + job_num = job.get("num", "?") + media_type = (job.get("telegram_media_type") or "voice").strip() + duration_seconds = int(job.get("telegram_duration_seconds") or 0) + telegram_file_size = int(job.get("telegram_file_size") or 0) + file_looks_big_for_cloud = self._telegram_cloud_download_is_likely_too_big(telegram_file_size) + if file_looks_big_for_cloud and status_cb is not None: + status_cb( + "файл большой, всё равно пробую скачать его из Telegram. " + f"Предварительный размер около {self._bytes_to_mb(telegram_file_size)} MB." + ) + started_at = time.time() + print(f"[py-bot] transcribe start job={job_id} num={job_num} media={media_type}", flush=True) + file_bytes, filename = self._download_telegram_file(file_id) + print( + f"[py-bot] transcribe downloaded job={job_id} filename={filename} size={len(file_bytes)} bytes", + flush=True, + ) + if file_looks_big_for_cloud and status_cb is not None: + status_cb( + "скачивание из Telegram прошло успешно. " + f"Фактический размер около {self._bytes_to_mb(len(file_bytes))} MB, дальше готовлю аудио и отправляю в OpenAI." + ) + prepared_parts = self._prepare_audio_parts_for_transcription( + file_bytes, + filename, + duration_seconds=duration_seconds, + job_id=job_id, + job_num=job_num, + ) + print( + f"[py-bot] transcribe prepared job={job_id} parts={len(prepared_parts)} duration={duration_seconds}s", + flush=True, + ) + parts_text: list[str] = [] + prompt_tail = "" + for index, (part_bytes, part_name) in enumerate(prepared_parts, start=1): + print( + f"[py-bot] transcribe part job={job_id} index={index}/{len(prepared_parts)} filename={part_name} size={len(part_bytes)} bytes", + flush=True, + ) + part_text = self._openai_transcribe(part_bytes, part_name, prompt=prompt_tail).strip() + if part_text: + parts_text.append(part_text) + prompt_tail = self._transcription_prompt_tail("\n".join(parts_text)) + text = "\n".join(parts_text).strip() + if not text: + raise VoiceTranscriptionError( + "сервис распознавания вернул пустой текст. Возможно, в записи нет слышимой речи или качество звука слишком низкое.", + stage="empty_text", + retryable=False, + ) + elapsed = self._format_duration(int(time.time() - started_at)) + print(f"[py-bot] transcribe done job={job_id} chars={len(text)} elapsed={elapsed}", flush=True) + return text + + def _download_telegram_file(self, file_id: str) -> tuple[bytes, str]: + try: + result = self.telegram.call("getFile", {"file_id": file_id}, timeout=120) + except TimeoutError as e: + raise VoiceTranscriptionError( + "Telegram долго не отдавал информацию о файле.", + stage="telegram_get_file_timeout", + detail=str(e), + ) from e + except Exception as e: + detail = str(e) + if "file is too big" in detail.lower(): + raise VoiceTranscriptionError( + "Файл большой: я попробовал скачать его через текущий Telegram Bot API, " + "но Telegram не дал это сделать. Для такого аудио нужен локальный `telegram-bot-api` " + "сервер или другой способ передать файл боту.", + stage="telegram_get_file_too_big", + retryable=False, + detail=detail, + ) from e + raise VoiceTranscriptionError( + "не удалось получить информацию о файле из Telegram.", + stage="telegram_get_file", + detail=detail, + ) from e + info = result.get("result") or {} + file_path = info.get("file_path") + if not file_path: + raise VoiceTranscriptionError( + "Telegram не вернул путь к файлу.", + stage="telegram_file_path", + retryable=True, + detail=json.dumps(info, ensure_ascii=False)[:1000], + ) + file_url = self.telegram.file_base + file_path.lstrip("/") + req = request.Request(file_url, method="GET") + try: + with request.urlopen(req, timeout=self.cfg.telegram_file_download_timeout_seconds) as resp: + data = resp.read() + except TimeoutError as e: + raise VoiceTranscriptionError( + f"Telegram не успел отдать аудиофайл за {self.cfg.telegram_file_download_timeout_seconds} секунд.", + stage="telegram_download_timeout", + detail=str(e), + ) from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise VoiceTranscriptionError( + f"Telegram вернул ошибку HTTP {e.code} при скачивании аудио.", + stage="telegram_download_http", + retryable=e.code >= 500 or e.code == 429, + detail=detail[:1000], + ) from e + except error.URLError as e: + raise VoiceTranscriptionError( + "не удалось скачать аудиофайл из Telegram из-за сетевой ошибки.", + stage="telegram_download_network", + detail=str(e.reason), + ) from e + if not data: + raise VoiceTranscriptionError( + "Telegram отдал пустой аудиофайл.", + stage="telegram_download_empty", + retryable=True, + ) + original_name = Path(file_path).name or "audio.ogg" + lower = original_name.lower() + # OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg. + if lower.endswith(".oga"): + base = original_name[:-4] if len(original_name) > 4 else "audio" + normalized = f"{base}.ogg" + else: + normalized = original_name + return data, normalized + + def _prepare_audio_parts_for_transcription( + self, + file_bytes: bytes, + filename: str, + *, + duration_seconds: int, + job_id: str, + job_num: Any, + ) -> list[tuple[bytes, str]]: + needs_duration_chunking = duration_seconds > self.cfg.openai_transcribe_max_chunk_seconds + if len(file_bytes) <= self.cfg.openai_transcribe_max_upload_bytes and not needs_duration_chunking: + return [(file_bytes, filename)] + ffmpeg_path = shutil.which(self.cfg.ffmpeg_bin) + ffprobe_path = shutil.which(self.cfg.ffprobe_bin) + if not ffmpeg_path or not ffprobe_path: + raise VoiceTranscriptionError( + "для длинного аудио нужен локальный `ffmpeg`/`ffprobe`, но они не найдены в системе.", + stage="audio_prepare_tools_missing", + retryable=False, + ) + with tempfile.TemporaryDirectory(prefix="shine-audio-") as tmpdir: + tmp = Path(tmpdir) + input_suffix = Path(filename).suffix or ".ogg" + input_path = tmp / f"source{input_suffix}" + input_path.write_bytes(file_bytes) + prepared_path = tmp / "prepared.ogg" + self._ffmpeg_reencode_audio(input_path, prepared_path) + prepared_bytes = prepared_path.read_bytes() + prepared_duration = self._ffprobe_duration_seconds(prepared_path) + if ( + len(prepared_bytes) <= self.cfg.openai_transcribe_max_upload_bytes + and prepared_duration <= self.cfg.openai_transcribe_max_chunk_seconds + ): + return [(prepared_bytes, prepared_path.name)] + chunk_length = self._choose_transcription_chunk_seconds(prepared_duration, len(prepared_bytes)) + print( + f"[py-bot] audio chunking job={job_id} num={job_num} duration={prepared_duration:.1f}s total_bytes={len(prepared_bytes)} chunk_seconds={chunk_length}", + flush=True, + ) + chunks: list[tuple[bytes, str]] = [] + offset = 0 + index = 1 + total_duration = max(1, int(prepared_duration + 0.999)) + while offset < total_duration: + chunk_path = tmp / f"chunk_{index:03d}.ogg" + self._ffmpeg_extract_audio_chunk(prepared_path, chunk_path, offset, chunk_length) + chunk_bytes = chunk_path.read_bytes() + if not chunk_bytes: + break + if len(chunk_bytes) > self.cfg.openai_transcribe_max_upload_bytes: + raise VoiceTranscriptionError( + "локальная нарезка аудио дала слишком большой кусок для OpenAI; нужно уменьшить размер чанка.", + stage="audio_chunk_too_large", + retryable=False, + ) + chunks.append((chunk_bytes, chunk_path.name)) + step = max(1, chunk_length - self.cfg.openai_transcribe_overlap_seconds) + offset += step + index += 1 + if not chunks: + raise VoiceTranscriptionError( + "не удалось подготовить куски аудио для распознавания.", + stage="audio_chunk_empty", + retryable=False, + ) + return chunks + + def _ffmpeg_reencode_audio(self, input_path: Path, output_path: Path) -> None: + cmd = [ + self.cfg.ffmpeg_bin, + "-y", + "-i", + str(input_path), + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "libopus", + "-b:a", + f"{self.cfg.openai_transcribe_reencode_bitrate_kbps}k", + str(output_path), + ] + self._run_subprocess_checked(cmd, "audio_reencode_ffmpeg") + + def _ffmpeg_extract_audio_chunk(self, input_path: Path, output_path: Path, offset_seconds: int, chunk_seconds: int) -> None: + cmd = [ + self.cfg.ffmpeg_bin, + "-y", + "-ss", + str(offset_seconds), + "-t", + str(chunk_seconds), + "-i", + str(input_path), + "-vn", + "-acodec", + "copy", + str(output_path), + ] + self._run_subprocess_checked(cmd, "audio_chunk_ffmpeg") + + def _ffprobe_duration_seconds(self, audio_path: Path) -> float: + cmd = [ + self.cfg.ffprobe_bin, + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(audio_path), + ] + try: + result = subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, + ) + except subprocess.TimeoutExpired as e: + raise VoiceTranscriptionError( + f"`ffprobe` не успел определить длительность аудио за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", + stage="audio_probe_timeout", + retryable=False, + ) from e + except subprocess.CalledProcessError as e: + detail = (e.stderr or e.stdout or "").strip() + raise VoiceTranscriptionError( + "не удалось определить длительность аудио через `ffprobe`.", + stage="audio_probe_failed", + retryable=False, + detail=detail[:1500], + ) from e + raw = (result.stdout or "").strip() + try: + return max(0.0, float(raw)) + except ValueError as e: + raise VoiceTranscriptionError( + "`ffprobe` вернул некорректную длительность аудио.", + stage="audio_probe_invalid", + retryable=False, + detail=raw[:300], + ) from e + + def _run_subprocess_checked(self, cmd: list[str], stage: str) -> None: + try: + subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, + ) + except subprocess.TimeoutExpired as e: + raise VoiceTranscriptionError( + f"локальная обработка аудио не успела завершиться за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", + stage=f"{stage}_timeout", + retryable=False, + ) from e + except subprocess.CalledProcessError as e: + detail = (e.stderr or e.stdout or "").strip() + raise VoiceTranscriptionError( + "локальная обработка аудио через `ffmpeg` завершилась с ошибкой.", + stage=f"{stage}_failed", + retryable=False, + detail=detail[:1500], + ) from e + + def _choose_transcription_chunk_seconds(self, duration_seconds: float, total_bytes: int) -> int: + max_chunk = self.cfg.openai_transcribe_max_chunk_seconds + safe_seconds = max(60, max_chunk - self.cfg.openai_transcribe_overlap_seconds) + if duration_seconds <= 0 or total_bytes <= 0: + return safe_seconds + bytes_per_second = total_bytes / max(duration_seconds, 1.0) + if bytes_per_second <= 0: + return safe_seconds + size_limited = int((self.cfg.openai_transcribe_max_upload_bytes * 0.9) / bytes_per_second) + return max(60, min(safe_seconds, size_limited if size_limited > 0 else safe_seconds)) + + @staticmethod + def _transcription_prompt_tail(text: str, limit: int = 1000) -> str: + source = compact_spaces(text) + if len(source) <= limit: + return source + return source[-limit:] + + def _telegram_cloud_download_is_likely_too_big(self, file_size: int) -> bool: + if file_size <= 0: + return False + using_cloud_api = self.cfg.telegram_api_base_url.rstrip("/") == "https://api.telegram.org" + return using_cloud_api and file_size > 20 * 1024 * 1024 + + @staticmethod + def _bytes_to_mb(value: int) -> str: + return f"{value / (1024 * 1024):.1f}" + + def _openai_transcribe(self, file_bytes: bytes, filename: str, prompt: str = "") -> str: + boundary = "----shine-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) + mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + def text_part(name: str, value: str) -> bytes: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + ).encode("utf-8") + + body = bytearray() + body.extend(text_part("model", self.cfg.openai_transcribe_model)) + body.extend(text_part("response_format", "text")) + prompt = compact_spaces(prompt) + if prompt: + body.extend(text_part("prompt", prompt[:1000])) + body.extend( + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {mime}\r\n\r\n" + ).encode("utf-8") + ) + body.extend(file_bytes) + body.extend(b"\r\n") + body.extend(f"--{boundary}--\r\n".encode("utf-8")) + + req = request.Request("https://api.openai.com/v1/audio/transcriptions", method="POST", data=bytes(body)) + req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + try: + with request.urlopen(req, timeout=self.cfg.openai_transcribe_timeout_seconds) as resp: + return resp.read().decode("utf-8", errors="replace") + except TimeoutError as e: + raise VoiceTranscriptionError( + f"OpenAI не успел распознать аудио за {self.cfg.openai_transcribe_timeout_seconds} секунд.", + stage="openai_transcribe_timeout", + detail=str(e), + ) from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + if e.code == 400: + user_message = "OpenAI не принял аудиофайл для распознавания." + elif e.code == 401: + user_message = "OpenAI отклонил ключ API для распознавания." + elif e.code == 413: + user_message = "аудиофайл слишком большой для распознавания OpenAI." + elif e.code == 429: + user_message = "OpenAI временно ограничил распознавание из-за лимита запросов." + elif e.code >= 500: + user_message = "OpenAI временно не смог обработать распознавание." + else: + user_message = f"OpenAI вернул ошибку HTTP {e.code} при распознавании." + raise VoiceTranscriptionError( + user_message, + stage="openai_transcribe_http", + retryable=e.code == 429 or e.code >= 500, + detail=detail[:1500], + ) from e + except error.URLError as e: + raise VoiceTranscriptionError( + "не удалось отправить аудио в OpenAI из-за сетевой ошибки.", + stage="openai_transcribe_network", + detail=str(e.reason), + ) from e + + @staticmethod + def _extract_codex_user_note(line: str) -> str | None: + s = (line or "").strip() + if not s.startswith("{"): + return None + try: + obj = json.loads(s) + except Exception: + return None + if obj.get("type") != "item.completed": + return None + item = obj.get("item") or {} + if item.get("type") != "agent_message": + return None + text = (item.get("text") or "").strip() + if not text: + return None + if len(text) > 220: + return text[:220] + "..." + return text + + @staticmethod + def _extract_fallback_message(lines: list[str]) -> str: + for line in reversed(lines): + line = line.strip() + if not line: + continue + if line.startswith("{") and '"type":' in line: + continue + if line.startswith("mcp:") or line.startswith("OpenAI Codex"): + continue + return line + return "" + + @staticmethod + def _extract_codex_thread_id(line: str) -> str: + s = (line or "").strip() + if not s.startswith("{"): + return "" + try: + obj = json.loads(s) + except Exception: + return "" + if obj.get("type") != "thread.started": + return "" + thread_id = (obj.get("thread_id") or "").strip() + return thread_id + + @staticmethod + def _is_missing_codex_session_error(text: str) -> bool: + lowered = (text or "").lower() + markers = [ + "session not found", + "conversation not found", + "thread not found", + "no session found", + "invalid session", + "unknown session", + "no conversation found", + "unknown thread", + ] + return any(marker in lowered for marker in markers) + + @staticmethod + def _format_duration(seconds: int) -> str: + seconds = max(0, seconds) + minutes, sec = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}ч {minutes}м {sec}с" + if minutes: + return f"{minutes}м {sec}с" + return f"{sec}с" + + +def run_selftest(config: BotConfig, prompt: str) -> int: + cmd = [ + str(config.codex_bin), + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "-C", str(config.codex_workdir), + prompt, + ] + proc = subprocess.run( + cmd, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + print(proc.stdout) + if proc.stderr: + print(proc.stderr) + return proc.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="SHiNE Python Telegram bot wrapper for Codex CLI") + parser.add_argument("--selftest-codex", default="", help="Выполнить только codex exec с этим prompt и выйти") + args = parser.parse_args() + + root = Path(__file__).resolve().parent + cfg = BotConfig(root) + if args.selftest_codex: + return run_selftest(cfg, args.selftest_codex) + + service = ShinePyBotService(cfg) + try: + service.run() + except KeyboardInterrupt: + service.shutdown() + return 0 + except Exception as e: + print(f"[py-bot] FATAL: {e}", flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service b/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service new file mode 100644 index 0000000..f166ef8 --- /dev/null +++ b/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service @@ -0,0 +1,23 @@ +[Unit] +Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=your_user +Group=your_user +WorkingDirectory=/home/your_user/codex-agent +Environment=HOME=/home/your_user +Environment=PATH=/home/your_user/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +EnvironmentFile=/home/your_user/codex-agent/.env +ExecStart=/usr/bin/python3 /home/your_user/codex-agent/py_bot_service.py +Restart=always +RestartSec=5 +TimeoutStopSec=20 +SuccessExitStatus=143 0 +StandardOutput=append:/home/your_user/codex-agent/logs/service.log +StandardError=append:/home/your_user/codex-agent/logs/service.log + +[Install] +WantedBy=multi-user.target diff --git a/codex-agent-VPS/README.md b/codex-agent-VPS/README.md new file mode 100644 index 0000000..9ce3b9a --- /dev/null +++ b/codex-agent-VPS/README.md @@ -0,0 +1,32 @@ +# codex-agent-VPS + +Переносимый комплект Telegram-бота для запуска `codex` CLI на VPS. + +## Структура +- `README.md` — краткое описание структуры. +- `AGENTS.md` — инструкции по установке и настройке через Codex. +- `.env.example` — верхнеуровневый пример конфига. +- `Agent-server-package/` — готовый комплект файлов для копирования на другой сервер. + +## Что копировать на сервер +На VPS обычно копируется содержимое папки: + +- `Agent-server-package/` + +Внутри неё лежат: +- `py_bot_service.py` +- `AGENT.md` +- `scripts/systemd/shine-agent-bot-coder.service` + +## Что настраивать +- взять `.env.example` из корня `codex-agent-VPS/` +- создать на сервере `.env` +- вписать Telegram bot token +- вписать разрешённые usernames +- указать путь к `codex` +- указать рабочую директорию `CODEX_WORKDIR` + +## Где инструкция +Полная инструкция по установке и настройке лежит в: + +- `AGENTS.md` diff --git a/shine-UI/index.html b/shine-UI/index.html index 6be3986..7fd4f01 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -7,7 +7,7 @@ Shine UI Demo