Добавить кошелек блокчейна и озвучивание агента
This commit is contained in:
parent
775b655aac
commit
3a5856c7f0
@ -99,11 +99,24 @@
|
|||||||
"blockchainName": "anya-001",
|
"blockchainName": "anya-001",
|
||||||
"solanaKey": "BASE64_32_PUBLIC_KEY",
|
"solanaKey": "BASE64_32_PUBLIC_KEY",
|
||||||
"blockchainKey": "BASE64_32_PUBLIC_KEY",
|
"blockchainKey": "BASE64_32_PUBLIC_KEY",
|
||||||
"deviceKey": "BASE64_32_PUBLIC_KEY"
|
"deviceKey": "BASE64_32_PUBLIC_KEY",
|
||||||
|
"serverLastGlobalNumber": 128,
|
||||||
|
"serverLastGlobalHash": "4f...ab",
|
||||||
|
"serverBlockchainSizeBytes": 45212,
|
||||||
|
"serverBlockchainSizeLimitBytes": 100000,
|
||||||
|
"serverBlocksCount": 129
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Дополнительные серверные поля в `GetUser`:
|
||||||
|
|
||||||
|
- `serverLastGlobalNumber` — номер последнего блока в пользовательском блокчейне на сервере;
|
||||||
|
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
|
||||||
|
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
|
||||||
|
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
|
||||||
|
- `serverBlocksCount` — количество блоков в пользовательском блокчейне на сервере;
|
||||||
|
|
||||||
### Успешный ответ: пользователя нет
|
### Успешный ответ: пользователя нет
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
| Операция | Раздел документации | Кратко |
|
| Операция | Раздел документации | Кратко |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
|
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
|
||||||
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя |
|
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
|
||||||
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
|
||||||
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
|
||||||
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
# Кошелёк: лимит/закрепление блокчейна Сияния
|
||||||
|
|
||||||
|
- статус: `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`, `Увеличить лимит`) выполняются без ошибок при валидных данных.
|
||||||
|
- Восстановление ключей через пароль работает, а без нужных ключей операция не выполняется молча.
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
# Озвучивание ответов агента
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
В локальный 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
|
||||||
@ -7,6 +7,11 @@ BOT_USERNAME=aidar_su_bot
|
|||||||
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||||
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
|
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
|
||||||
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
|
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
|
||||||
|
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
|
||||||
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
|
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
|
||||||
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
||||||
CODEX_TIMEOUT_SECONDS=900
|
CODEX_TIMEOUT_SECONDS=900
|
||||||
|
|||||||
@ -32,6 +32,8 @@
|
|||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||||
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
||||||
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
||||||
|
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`.
|
||||||
|
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Промежуточные статусы и публичный отчёт в `@shine_writing` не озвучивать.
|
||||||
|
|
||||||
## Планы и отложенные фичи
|
## Планы и отложенные фичи
|
||||||
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
|
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
- обрабатывает задачи строго последовательно;
|
- обрабатывает задачи строго последовательно;
|
||||||
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||||
|
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
- при рестарте восстанавливает незавершённые задачи;
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||||
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
|
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
|
||||||
@ -33,6 +34,11 @@
|
|||||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||||
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
||||||
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
||||||
|
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
|
||||||
|
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
|
||||||
|
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
|
||||||
|
- `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд.
|
||||||
|
- `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов.
|
||||||
3. Запуск:
|
3. Запуск:
|
||||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
||||||
|
|
||||||
@ -62,4 +68,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
- `/stop` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
- `/new` — архивировать текущую историю и начать новый диалог.
|
- `/new` — архивировать текущую историю и начать новый диалог.
|
||||||
|
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||||
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
|
- `/voice_status` — показать состояние озвучивания для текущего пользователя.
|
||||||
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
|
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
|
||||||
|
|||||||
@ -64,6 +64,38 @@ def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
|
|||||||
return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]
|
return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]
|
||||||
|
|
||||||
|
|
||||||
|
def split_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]:
|
def read_env_file(path: Path) -> dict[str, str]:
|
||||||
result: dict[str, str] = {}
|
result: dict[str, str] = {}
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@ -100,6 +132,10 @@ class VoiceTranscriptionError(RuntimeError):
|
|||||||
return f"{self.user_message} stage={self.stage} retryable={self.retryable}"
|
return f"{self.user_message} stage={self.stage} retryable={self.retryable}"
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceReplyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class JsonLineStore:
|
class JsonLineStore:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(path: Path) -> list[dict[str, Any]]:
|
def load(path: Path) -> list[dict[str, Any]]:
|
||||||
@ -154,6 +190,53 @@ class TelegramApi:
|
|||||||
raise RuntimeError(f"Telegram API error: {result}")
|
raise RuntimeError(f"Telegram API error: {result}")
|
||||||
return 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]]:
|
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"]}
|
payload: dict[str, Any] = {"timeout": timeout_sec, "allowed_updates": ["message", "channel_post"]}
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
@ -195,6 +278,26 @@ class TelegramApi:
|
|||||||
payload["reply_to_message_id"] = reply_to_message_id
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
return self.call("sendAudio", payload=payload, timeout=60)
|
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:
|
def delete_webhook(self) -> None:
|
||||||
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
|
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
|
||||||
|
|
||||||
@ -214,6 +317,11 @@ class BotConfig:
|
|||||||
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
|
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.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_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900"))
|
||||||
|
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.codex_bin = Path(env.get(
|
self.codex_bin = Path(env.get(
|
||||||
"CODEX_BIN",
|
"CODEX_BIN",
|
||||||
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
||||||
@ -344,6 +452,10 @@ class ShinePyBotService:
|
|||||||
if not isinstance(sessions, dict):
|
if not isinstance(sessions, dict):
|
||||||
sessions = {}
|
sessions = {}
|
||||||
self.state["user_sessions"] = 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"):
|
if not self.state.get("current_history_file"):
|
||||||
history_file = self._create_new_history_file("initial", self.cfg.allowed_username)
|
history_file = self._create_new_history_file("initial", self.cfg.allowed_username)
|
||||||
self.state["current_history_file"] = str(history_file)
|
self.state["current_history_file"] = str(history_file)
|
||||||
@ -433,6 +545,7 @@ class ShinePyBotService:
|
|||||||
if not isinstance(sessions, dict):
|
if not isinstance(sessions, dict):
|
||||||
sessions = {}
|
sessions = {}
|
||||||
self.state["user_sessions"] = sessions
|
self.state["user_sessions"] = sessions
|
||||||
|
self._user_settings(uname)
|
||||||
session = sessions.get(uname)
|
session = sessions.get(uname)
|
||||||
if isinstance(session, dict) and session.get("current_history_file"):
|
if isinstance(session, dict) and session.get("current_history_file"):
|
||||||
return
|
return
|
||||||
@ -483,6 +596,27 @@ class ShinePyBotService:
|
|||||||
self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname)
|
self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname)
|
||||||
return archived
|
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"] = False
|
||||||
|
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 _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
|
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
row = {"ts": now_iso(), "type": event_type}
|
row = {"ts": now_iso(), "type": event_type}
|
||||||
row.update(payload)
|
row.update(payload)
|
||||||
@ -766,21 +900,36 @@ class ShinePyBotService:
|
|||||||
|
|
||||||
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
|
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
|
command = lower.split(maxsplit=1)[0].split("@", 1)[0]
|
||||||
is_owner = self._is_owner(username)
|
is_owner = self._is_owner(username)
|
||||||
if lower in ("/start", "/help"):
|
if command in ("/start", "/help"):
|
||||||
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
|
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower == "/status":
|
if command == "/status":
|
||||||
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
|
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower == "/queue":
|
if command == "/queue":
|
||||||
self._safe_send(chat_id, self._queue_text(), reply_to=message_id)
|
self._safe_send(chat_id, self._queue_text(), reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower == "/new":
|
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 == "/voice_status":
|
||||||
|
status = "включено" if self._voice_replies_enabled(username) else "выключено"
|
||||||
|
self._safe_send(chat_id, f"Озвучивание финальных ответов: {status}.", reply_to=message_id)
|
||||||
|
return
|
||||||
|
if command == "/new":
|
||||||
archived = self._rotate_history("command_new", username)
|
archived = self._rotate_history("command_new", username)
|
||||||
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower in ("/restart_service", "/restart"):
|
if command in ("/restart_service", "/restart"):
|
||||||
if not is_owner:
|
if not is_owner:
|
||||||
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
|
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
|
||||||
return
|
return
|
||||||
@ -796,14 +945,14 @@ class ShinePyBotService:
|
|||||||
)
|
)
|
||||||
self._schedule_self_restart()
|
self._schedule_self_restart()
|
||||||
return
|
return
|
||||||
if lower == "/stop":
|
if command == "/stop":
|
||||||
stopped = self._cancel_active_job("stopped_by_user")
|
stopped = self._cancel_active_job("stopped_by_user")
|
||||||
if stopped:
|
if stopped:
|
||||||
self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id)
|
self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id)
|
||||||
else:
|
else:
|
||||||
self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id)
|
self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower.startswith("/cancel"):
|
if command == "/cancel":
|
||||||
parts = text.split(maxsplit=1)
|
parts = text.split(maxsplit=1)
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
self._safe_send(chat_id, "Использование: /cancel <id|all>", reply_to=message_id)
|
self._safe_send(chat_id, "Использование: /cancel <id|all>", reply_to=message_id)
|
||||||
@ -830,6 +979,9 @@ class ShinePyBotService:
|
|||||||
"/stop — остановить текущую задачу",
|
"/stop — остановить текущую задачу",
|
||||||
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
||||||
"/new — архивировать историю и начать новую",
|
"/new — архивировать историю и начать новую",
|
||||||
|
"/voice_on — включить озвучивание финальных ответов",
|
||||||
|
"/voice_off — выключить озвучивание финальных ответов",
|
||||||
|
"/voice_status — показать состояние озвучивания",
|
||||||
"/help — эта справка",
|
"/help — эта справка",
|
||||||
]
|
]
|
||||||
if is_owner:
|
if is_owner:
|
||||||
@ -945,6 +1097,8 @@ class ShinePyBotService:
|
|||||||
answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num)
|
answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num)
|
||||||
for chunk in split_long_text(answer):
|
for chunk in split_long_text(answer):
|
||||||
self._safe_send(chat_id, chunk, reply_to=message_id)
|
self._safe_send(chat_id, chunk, reply_to=message_id)
|
||||||
|
if self._voice_replies_enabled(job.get("username") or ""):
|
||||||
|
self._send_voice_reply_for_answer(chat_id, message_id, job_num, answer, history_path, job_id)
|
||||||
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
|
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
|
||||||
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
||||||
self._mark_job_done(job_id)
|
self._mark_job_done(job_id)
|
||||||
@ -1284,6 +1438,51 @@ class ShinePyBotService:
|
|||||||
print(f"[py-bot] sendFile error: {e}", flush=True)
|
print(f"[py-bot] sendFile error: {e}", flush=True)
|
||||||
return None
|
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:
|
def _safe_send(self, chat_id: int | str, text: str, reply_to: int | None = None) -> int | None:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@ -1325,6 +1524,100 @@ class ShinePyBotService:
|
|||||||
|
|
||||||
threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start()
|
threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start()
|
||||||
|
|
||||||
|
def _send_voice_reply_for_answer(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
message_id: int,
|
||||||
|
job_num: Any,
|
||||||
|
answer: str,
|
||||||
|
history_path: Path,
|
||||||
|
job_id: str,
|
||||||
|
) -> None:
|
||||||
|
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
|
||||||
|
|
||||||
|
chunks = split_text_for_tts(answer, self.cfg.openai_tts_chunk_chars)
|
||||||
|
if not chunks:
|
||||||
|
return
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
total = len(chunks)
|
||||||
|
print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total}", 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}"
|
||||||
|
message_sent = self._safe_send_voice_upload(
|
||||||
|
chat_id,
|
||||||
|
audio,
|
||||||
|
f"shine-answer-{job_num}-{index}.ogg",
|
||||||
|
caption=caption,
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
if message_sent is None:
|
||||||
|
self._append_history(history_path, "voice_reply_failed", {
|
||||||
|
"jobId": job_id,
|
||||||
|
"jobNum": job_num,
|
||||||
|
"part": index,
|
||||||
|
"parts": total,
|
||||||
|
"error": "Telegram не принял voice-файл озвучки.",
|
||||||
|
})
|
||||||
|
self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id)
|
||||||
|
return
|
||||||
|
sent_count += 1
|
||||||
|
self._append_history(history_path, "voice_reply_sent", {"jobId": job_id, "jobNum": job_num, "parts": sent_count})
|
||||||
|
print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True)
|
||||||
|
|
||||||
|
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]) -> str:
|
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
|
||||||
if not self.cfg.openai_api_key:
|
if not self.cfg.openai_api_key:
|
||||||
raise VoiceTranscriptionError(
|
raise VoiceTranscriptionError(
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.95
|
client.version=1.2.96
|
||||||
server.version=1.2.89
|
server.version=1.2.90
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<title>Shine UI Demo</title>
|
<title>Shine UI Demo</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260426113000';
|
window.__SHINE_BUILD_HASH__ = '20260528010500';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.8';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.8';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
@ -32,6 +32,42 @@
|
|||||||
// Public VAPID key for Web Push (Base64URL)
|
// Public VAPID key for Web Push (Base64URL)
|
||||||
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = 'BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI';
|
window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = 'BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI';
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
(function attachBootErrorOverlay() {
|
||||||
|
const show = (title, text) => {
|
||||||
|
try {
|
||||||
|
let el = document.getElementById('boot-error-overlay');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('pre');
|
||||||
|
el.id = 'boot-error-overlay';
|
||||||
|
el.style.position = 'fixed';
|
||||||
|
el.style.left = '8px';
|
||||||
|
el.style.right = '8px';
|
||||||
|
el.style.bottom = '8px';
|
||||||
|
el.style.maxHeight = '40vh';
|
||||||
|
el.style.overflow = 'auto';
|
||||||
|
el.style.padding = '10px';
|
||||||
|
el.style.margin = '0';
|
||||||
|
el.style.background = 'rgba(120, 0, 0, 0.92)';
|
||||||
|
el.style.color = '#fff';
|
||||||
|
el.style.fontSize = '12px';
|
||||||
|
el.style.lineHeight = '1.4';
|
||||||
|
el.style.zIndex = '999999';
|
||||||
|
el.style.whiteSpace = 'pre-wrap';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = `[BOOT ERROR] ${title}\n${String(text || '')}`;
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`);
|
||||||
|
});
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const reason = e?.reason;
|
||||||
|
show('unhandledrejection', reason?.stack || reason?.message || String(reason || 'unknown'));
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachAppWithBuildHash() {
|
(function attachAppWithBuildHash() {
|
||||||
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
|
||||||
|
|||||||
@ -47,7 +47,7 @@ import * as keyStorageView from './pages/key-storage-view.js';
|
|||||||
|
|
||||||
import * as profileView from './pages/profile-view.js';
|
import * as profileView from './pages/profile-view.js';
|
||||||
import * as profileEditView from './pages/profile-edit-view.js';
|
import * as profileEditView from './pages/profile-edit-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js';
|
import * as walletView from './pages/wallet-view.js?v=2026052806';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||||
import * as serverSettingsView from './pages/server-settings-view.js';
|
import * as serverSettingsView from './pages/server-settings-view.js';
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService, clearAuthMessages, state } from '../state.js';
|
import { authService, clearAuthMessages, state } from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import { formatSolanaErrorDetails, precheckLoginClassOnSolana } from '../services/solana-register-service.js';
|
import {
|
||||||
|
checkLoginExistsOnSolana,
|
||||||
|
formatSolanaErrorDetails,
|
||||||
|
precheckLoginClassOnSolana,
|
||||||
|
} from '../services/solana-register-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||||
|
|
||||||
@ -87,8 +91,11 @@ export function render({ navigate }) {
|
|||||||
checkButton.disabled = true;
|
checkButton.disabled = true;
|
||||||
checkButton.textContent = 'Проверка...';
|
checkButton.textContent = 'Проверка...';
|
||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
const check = await checkLoginExistsOnSolana({
|
||||||
const isFree = await authService.ensureLoginFree(login);
|
login,
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
});
|
||||||
|
const isFree = !check.exists;
|
||||||
let className = '';
|
let className = '';
|
||||||
let precheckWarning = '';
|
let precheckWarning = '';
|
||||||
if (isFree) {
|
if (isFree) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
createRandomSolanaWallet,
|
createRandomSolanaWallet,
|
||||||
createSolanaWalletFromPrivateBase58,
|
createSolanaWalletFromPrivateBase58,
|
||||||
@ -17,6 +17,14 @@ import {
|
|||||||
getArweaveWalletFromStoredDeviceKey,
|
getArweaveWalletFromStoredDeviceKey,
|
||||||
transferAr,
|
transferAr,
|
||||||
} from '../services/arweave-wallet-service.js';
|
} from '../services/arweave-wallet-service.js';
|
||||||
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
import {
|
||||||
|
calcLimitTopupPriceLamports,
|
||||||
|
getLimitStepBytes,
|
||||||
|
getShineBlockchainUsage,
|
||||||
|
getShineUsersEconomyConfig,
|
||||||
|
updateShineUserPdaOnSolana,
|
||||||
|
} from '../services/shine-blockchain-wallet-service.js?v=2026052803';
|
||||||
|
|
||||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||||
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
|
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
|
||||||
@ -25,6 +33,20 @@ function nowRu() {
|
|||||||
return new Date().toLocaleString('ru-RU');
|
return new Date().toLocaleString('ru-RU');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatKbFromBytes(rawBytes) {
|
||||||
|
const bytes = typeof rawBytes === 'bigint'
|
||||||
|
? Number(rawBytes)
|
||||||
|
: Number(rawBytes || 0);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lamportsToSolText(lamportsBigInt) {
|
||||||
|
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
|
||||||
|
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
|
||||||
|
}
|
||||||
|
|
||||||
function createModeBackButton(renderWalletChoice) {
|
function createModeBackButton(renderWalletChoice) {
|
||||||
const backBtn = document.createElement('button');
|
const backBtn = document.createElement('button');
|
||||||
backBtn.className = 'text-btn';
|
backBtn.className = 'text-btn';
|
||||||
@ -106,11 +128,299 @@ export function render({ navigate }) {
|
|||||||
void renderArweaveWallet();
|
void renderArweaveWallet();
|
||||||
});
|
});
|
||||||
|
|
||||||
card.append(solanaBtn, arweaveBtn);
|
const shineBchBtn = document.createElement('button');
|
||||||
|
shineBchBtn.className = 'primary-btn';
|
||||||
|
shineBchBtn.style.width = '100%';
|
||||||
|
shineBchBtn.textContent = 'Блокчейн Сияния';
|
||||||
|
shineBchBtn.addEventListener('click', () => {
|
||||||
|
void renderShineBlockchainWallet();
|
||||||
|
});
|
||||||
|
|
||||||
|
card.append(solanaBtn, arweaveBtn, shineBchBtn);
|
||||||
content.append(card);
|
content.append(card);
|
||||||
setStatus('Выберите тип кошелька.');
|
setStatus('Выберите тип кошелька.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderShineBlockchainWallet() {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(renderWalletChoice);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card stack';
|
||||||
|
|
||||||
|
const limitLabel = document.createElement('p');
|
||||||
|
limitLabel.className = 'meta-muted';
|
||||||
|
limitLabel.textContent = 'Лимит блокчейна';
|
||||||
|
const limitValue = document.createElement('h2');
|
||||||
|
limitValue.style.fontSize = '26px';
|
||||||
|
limitValue.textContent = '— KB';
|
||||||
|
|
||||||
|
const usedLabel = document.createElement('p');
|
||||||
|
usedLabel.className = 'meta-muted';
|
||||||
|
usedLabel.textContent = 'Израсходовано (закреплено в Solana)';
|
||||||
|
const usedValue = document.createElement('h2');
|
||||||
|
usedValue.style.fontSize = '26px';
|
||||||
|
usedValue.textContent = '— KB';
|
||||||
|
|
||||||
|
const leftLabel = document.createElement('p');
|
||||||
|
leftLabel.className = 'meta-muted';
|
||||||
|
leftLabel.textContent = 'Осталось (по Solana)';
|
||||||
|
const leftValue = document.createElement('h2');
|
||||||
|
leftValue.style.fontSize = '30px';
|
||||||
|
leftValue.textContent = '— KB';
|
||||||
|
|
||||||
|
const pdaLabel = document.createElement('p');
|
||||||
|
pdaLabel.className = 'meta-muted';
|
||||||
|
pdaLabel.style.wordBreak = 'break-all';
|
||||||
|
pdaLabel.textContent = 'PDA: —';
|
||||||
|
|
||||||
|
const endpointLabel = document.createElement('p');
|
||||||
|
endpointLabel.className = 'meta-muted';
|
||||||
|
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
|
||||||
|
|
||||||
|
const updatedLabel = document.createElement('p');
|
||||||
|
updatedLabel.className = 'meta-muted';
|
||||||
|
updatedLabel.textContent = 'Обновлено: —';
|
||||||
|
|
||||||
|
const serverTitle = document.createElement('h3');
|
||||||
|
serverTitle.style.margin = '8px 0 0';
|
||||||
|
serverTitle.textContent = 'Фактическое состояние на сервере';
|
||||||
|
const serverBlocksLabel = document.createElement('p');
|
||||||
|
serverBlocksLabel.className = 'meta-muted';
|
||||||
|
serverBlocksLabel.textContent = 'Блоков: —';
|
||||||
|
const serverSizeLabel = document.createElement('p');
|
||||||
|
serverSizeLabel.className = 'meta-muted';
|
||||||
|
serverSizeLabel.textContent = 'Размер цепочки: —';
|
||||||
|
const serverLastLabel = document.createElement('p');
|
||||||
|
serverLastLabel.className = 'meta-muted';
|
||||||
|
serverLastLabel.textContent = 'Крайний блок: —';
|
||||||
|
const serverLastHashLabel = document.createElement('p');
|
||||||
|
serverLastHashLabel.className = 'meta-muted';
|
||||||
|
serverLastHashLabel.style.wordBreak = 'break-all';
|
||||||
|
serverLastHashLabel.textContent = 'Hash: —';
|
||||||
|
|
||||||
|
const solanaTitle = document.createElement('h3');
|
||||||
|
solanaTitle.style.margin = '8px 0 0';
|
||||||
|
solanaTitle.textContent = 'Закреплено в Solana';
|
||||||
|
const solanaBlocksLabel = document.createElement('p');
|
||||||
|
solanaBlocksLabel.className = 'meta-muted';
|
||||||
|
solanaBlocksLabel.textContent = 'Блоков: —';
|
||||||
|
const solanaLastLabel = document.createElement('p');
|
||||||
|
solanaLastLabel.className = 'meta-muted';
|
||||||
|
solanaLastLabel.textContent = 'Крайний блок: —';
|
||||||
|
const solanaLastHashLabel = document.createElement('p');
|
||||||
|
solanaLastHashLabel.className = 'meta-muted';
|
||||||
|
solanaLastHashLabel.style.wordBreak = 'break-all';
|
||||||
|
solanaLastHashLabel.textContent = 'Hash: —';
|
||||||
|
|
||||||
|
card.append(
|
||||||
|
limitLabel,
|
||||||
|
limitValue,
|
||||||
|
usedLabel,
|
||||||
|
usedValue,
|
||||||
|
leftLabel,
|
||||||
|
leftValue,
|
||||||
|
pdaLabel,
|
||||||
|
endpointLabel,
|
||||||
|
updatedLabel,
|
||||||
|
serverTitle,
|
||||||
|
serverBlocksLabel,
|
||||||
|
serverSizeLabel,
|
||||||
|
serverLastLabel,
|
||||||
|
serverLastHashLabel,
|
||||||
|
solanaTitle,
|
||||||
|
solanaBlocksLabel,
|
||||||
|
solanaLastLabel,
|
||||||
|
solanaLastHashLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
||||||
|
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
|
||||||
|
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
||||||
|
`;
|
||||||
|
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
||||||
|
const syncBtn = actions.querySelector('#sync-shine-solana');
|
||||||
|
const topupBtn = actions.querySelector('#topup-shine-limit');
|
||||||
|
|
||||||
|
const fetchServerState = async () => {
|
||||||
|
const user = await authService.getUser(String(state.session.login || '').trim());
|
||||||
|
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||||||
|
return {
|
||||||
|
blocksCount: Number(user.serverBlocksCount || 0),
|
||||||
|
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||||||
|
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||||||
|
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
|
||||||
|
lastHash: String(user.serverLastGlobalHash || ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveWalletSigningMaterial = async () => {
|
||||||
|
const { login, storagePwd } = sessionArgsOrThrow();
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||||||
|
} catch {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
let rootKey = String(saved?.rootKey || '').trim();
|
||||||
|
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||||||
|
const deviceKey = String(saved?.deviceKey || '').trim();
|
||||||
|
if (!deviceKey) throw new Error('На устройстве нет device.key. Выполните вход заново.');
|
||||||
|
if (rootKey && blockchainKey) {
|
||||||
|
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = window.prompt(
|
||||||
|
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (password == null) throw new Error('Операция отменена пользователем');
|
||||||
|
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||||
|
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||||||
|
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||||||
|
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||||||
|
|
||||||
|
const shouldSave = window.confirm(
|
||||||
|
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||||||
|
);
|
||||||
|
if (shouldSave) {
|
||||||
|
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||||||
|
}
|
||||||
|
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setButtonsDisabled = (disabled) => {
|
||||||
|
refreshBtn.disabled = disabled;
|
||||||
|
syncBtn.disabled = disabled;
|
||||||
|
topupBtn.disabled = disabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshUsage = async () => {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const [usage, serverState] = await Promise.all([
|
||||||
|
getShineBlockchainUsage({
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
}),
|
||||||
|
fetchServerState(),
|
||||||
|
]);
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
const solanaBlocks = usage.lastBlockNumber >= 0 ? usage.lastBlockNumber + 1 : 0;
|
||||||
|
const serverBlocks = serverState.blocksCount;
|
||||||
|
|
||||||
|
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
||||||
|
usedValue.textContent = formatKbFromBytes(usage.usedBytes);
|
||||||
|
leftValue.textContent = formatKbFromBytes(usage.leftBytes);
|
||||||
|
pdaLabel.textContent = `PDA: ${usage.userPda}`;
|
||||||
|
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
|
||||||
|
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
|
||||||
|
|
||||||
|
serverBlocksLabel.textContent = `Блоков: ${serverBlocks.toLocaleString('ru-RU')}`;
|
||||||
|
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)} из ${formatKbFromBytes(serverState.sizeLimitBytes)}`;
|
||||||
|
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||||
|
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||||
|
|
||||||
|
solanaBlocksLabel.textContent = `Блоков: ${solanaBlocks.toLocaleString('ru-RU')}`;
|
||||||
|
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||||||
|
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||||||
|
|
||||||
|
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось обновить состояние блокчейна: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncBtn.addEventListener('click', async () => {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const [serverState, signing] = await Promise.all([
|
||||||
|
fetchServerState(),
|
||||||
|
resolveWalletSigningMaterial(),
|
||||||
|
]);
|
||||||
|
const result = await updateShineUserPdaOnSolana({
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||||
|
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||||
|
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
|
||||||
|
additionalLimitBytes: 0n,
|
||||||
|
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||||||
|
nextLastBlockNumber: serverState.lastNumber,
|
||||||
|
nextLastBlockHashHex: serverState.lastHash,
|
||||||
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
|
||||||
|
await refreshUsage();
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
topupBtn.addEventListener('click', async () => {
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const economy = await getShineUsersEconomyConfig({ solanaEndpoint: state.entrySettings.solanaServer });
|
||||||
|
const step = getLimitStepBytes();
|
||||||
|
const input = window.prompt(
|
||||||
|
`Введите, на сколько увеличить лимит (в байтах, шаг ${step.toString()}).\nЦена за шаг: ${lamportsToSolText(economy.lamportsPerLimitStep)} SOL`,
|
||||||
|
step.toString(),
|
||||||
|
);
|
||||||
|
if (!input) {
|
||||||
|
setStatus('Увеличение лимита отменено.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addBytes = BigInt(String(input).trim());
|
||||||
|
const priceLamports = calcLimitTopupPriceLamports(addBytes, economy.lamportsPerLimitStep);
|
||||||
|
const confirm = window.confirm(
|
||||||
|
`Будет увеличено на ${formatKbFromBytes(addBytes)}.\n` +
|
||||||
|
`С вашего Solana-счёта будет списано ~${lamportsToSolText(priceLamports)} SOL.\n` +
|
||||||
|
`Продолжить?`,
|
||||||
|
);
|
||||||
|
if (!confirm) {
|
||||||
|
setStatus('Увеличение лимита отменено.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signing = await resolveWalletSigningMaterial();
|
||||||
|
const result = await updateShineUserPdaOnSolana({
|
||||||
|
login: String(state.session.login || '').trim(),
|
||||||
|
solanaEndpoint: state.entrySettings.solanaServer,
|
||||||
|
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||||
|
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||||
|
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
|
||||||
|
additionalLimitBytes: addBytes,
|
||||||
|
});
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Лимит увеличен. Tx: ${result.signature}`);
|
||||||
|
await refreshUsage();
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось увеличить лимит: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
void refreshUsage();
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(backBtn, card, actions);
|
||||||
|
setStatus('Загрузка данных блокчейна Сияния...');
|
||||||
|
await refreshUsage();
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSolanaWallet() {
|
async function renderSolanaWallet() {
|
||||||
const modeToken = ++activeModeToken;
|
const modeToken = ++activeModeToken;
|
||||||
clearArweaveSecretsInMemory();
|
clearArweaveSecretsInMemory();
|
||||||
|
|||||||
@ -852,16 +852,26 @@ export class AuthService {
|
|||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Введите логин');
|
if (!cleanLogin) throw new Error('Введите логин');
|
||||||
|
|
||||||
const user = await this.getUser(cleanLogin);
|
|
||||||
if (!user.exists) throw new Error('Пользователь не найден');
|
|
||||||
|
|
||||||
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
|
||||||
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
const session = await this.createAuthSession(cleanLogin, keyBundle);
|
||||||
return { ...session, keyBundle };
|
return { ...session, keyBundle };
|
||||||
}
|
}
|
||||||
|
|
||||||
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
|
||||||
const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
|
let currentSecrets = {};
|
||||||
|
try {
|
||||||
|
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
|
||||||
|
if (loaded && typeof loaded === 'object') {
|
||||||
|
currentSecrets = loaded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = {
|
||||||
|
...currentSecrets,
|
||||||
|
deviceKey: keyBundle.devicePair.privatePkcs8B64,
|
||||||
|
};
|
||||||
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
|
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
|
||||||
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
|
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
|
||||||
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
await saveEncryptedUserSecrets(login, storagePwd, secrets);
|
||||||
|
|||||||
489
shine-UI/js/services/shine-blockchain-wallet-service.js
Normal file
489
shine-UI/js/services/shine-blockchain-wallet-service.js
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
|
||||||
|
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
|
||||||
|
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js';
|
||||||
|
|
||||||
|
const MAGIC = 'SHiNE';
|
||||||
|
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
|
||||||
|
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
|
||||||
|
const LIMIT_STEP = 10_000n;
|
||||||
|
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
|
||||||
|
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
||||||
|
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
||||||
|
|
||||||
|
const BLOCK_TYPE_ROOT_KEY = 1;
|
||||||
|
const BLOCK_TYPE_DEVICE_KEY = 2;
|
||||||
|
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
|
||||||
|
const BLOCK_TYPE_SERVER_PROFILE = 30;
|
||||||
|
const BLOCK_TYPE_ACCESS_SERVERS = 40;
|
||||||
|
const BLOCK_TYPE_TRUSTED_STATE = 50;
|
||||||
|
|
||||||
|
let solanaLibPromise = null;
|
||||||
|
function loadSolanaLib() {
|
||||||
|
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
||||||
|
return solanaLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushU32LE(buf, v) {
|
||||||
|
const n = Number(v) >>> 0;
|
||||||
|
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
|
||||||
|
}
|
||||||
|
function pushU64LE(buf, v) {
|
||||||
|
const b = BigInt(v);
|
||||||
|
const lo = Number(b & 0xffffffffn) >>> 0;
|
||||||
|
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
|
||||||
|
pushU32LE(buf, lo);
|
||||||
|
pushU32LE(buf, hi);
|
||||||
|
}
|
||||||
|
function pushStrU8(buf, value) {
|
||||||
|
const bytes = new TextEncoder().encode(String(value || ''));
|
||||||
|
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
|
||||||
|
buf.push(bytes.length);
|
||||||
|
for (const x of bytes) buf.push(x);
|
||||||
|
}
|
||||||
|
function pushStrU32(buf, value) {
|
||||||
|
const bytes = new TextEncoder().encode(String(value || ''));
|
||||||
|
pushU32LE(buf, bytes.length);
|
||||||
|
for (const x of bytes) buf.push(x);
|
||||||
|
}
|
||||||
|
function pushVecU8(buf, bytes) {
|
||||||
|
const data = bytes || new Uint8Array();
|
||||||
|
pushU32LE(buf, data.length);
|
||||||
|
for (const x of data) buf.push(x);
|
||||||
|
}
|
||||||
|
function pushVecStrU32(buf, values) {
|
||||||
|
const arr = Array.isArray(values) ? values : [];
|
||||||
|
pushU32LE(buf, arr.length);
|
||||||
|
for (const s of arr) pushStrU32(buf, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReader(bytes) {
|
||||||
|
let o = 0;
|
||||||
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); };
|
||||||
|
const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; };
|
||||||
|
const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; };
|
||||||
|
const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; };
|
||||||
|
const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; };
|
||||||
|
const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; };
|
||||||
|
const readStrU8 = () => {
|
||||||
|
const len = readU8();
|
||||||
|
return new TextDecoder().decode(readBytes(len));
|
||||||
|
};
|
||||||
|
return { readU8, readU16, readU32, readU64, readBytes, readStrU8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShineUserPda(dataBytes) {
|
||||||
|
const r = makeReader(dataBytes);
|
||||||
|
const magic = new TextDecoder().decode(r.readBytes(5));
|
||||||
|
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
|
||||||
|
r.readU8();
|
||||||
|
r.readU8();
|
||||||
|
r.readU16();
|
||||||
|
const createdAtMs = r.readU64();
|
||||||
|
const updatedAtMs = r.readU64();
|
||||||
|
const recordNumber = r.readU32();
|
||||||
|
const prevRecordHash = r.readBytes(32);
|
||||||
|
const login = r.readStrU8();
|
||||||
|
const blocksCount = r.readU8();
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
createdAtMs,
|
||||||
|
updatedAtMs,
|
||||||
|
recordNumber,
|
||||||
|
prevRecordHash,
|
||||||
|
login,
|
||||||
|
rootKey: null,
|
||||||
|
deviceKey: null,
|
||||||
|
blockchain: null,
|
||||||
|
isServer: false,
|
||||||
|
serverKey: new Uint8Array(32),
|
||||||
|
serverAddress: '',
|
||||||
|
syncServers: [],
|
||||||
|
accessServers: [],
|
||||||
|
trustedCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < blocksCount; i += 1) {
|
||||||
|
const type = r.readU8();
|
||||||
|
r.readU8();
|
||||||
|
if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; }
|
||||||
|
if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; }
|
||||||
|
if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
|
||||||
|
const count = r.readU8();
|
||||||
|
for (let j = 0; j < count; j += 1) {
|
||||||
|
const blockchainType = r.readU8();
|
||||||
|
const blockchainName = r.readStrU8();
|
||||||
|
const blockchainPublicKey = r.readBytes(32);
|
||||||
|
const paidLimitBytes = r.readU64();
|
||||||
|
const usedBytes = r.readU64();
|
||||||
|
const lastBlockNumber = r.readU32();
|
||||||
|
const lastBlockHash = r.readBytes(32);
|
||||||
|
const lastBlockSignature = r.readBytes(64);
|
||||||
|
const arPresent = r.readU8();
|
||||||
|
const arweaveTxId = arPresent ? r.readStrU8() : '';
|
||||||
|
if (!out.blockchain) {
|
||||||
|
out.blockchain = {
|
||||||
|
blockchainType,
|
||||||
|
blockchainName,
|
||||||
|
blockchainPublicKey,
|
||||||
|
paidLimitBytes,
|
||||||
|
usedBytes,
|
||||||
|
lastBlockNumber,
|
||||||
|
lastBlockHash,
|
||||||
|
lastBlockSignature,
|
||||||
|
arweaveTxId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (type === BLOCK_TYPE_SERVER_PROFILE) {
|
||||||
|
out.isServer = r.readU8() === 1;
|
||||||
|
out.serverKey = r.readBytes(32);
|
||||||
|
out.serverAddress = r.readStrU8();
|
||||||
|
const syncCount = r.readU8();
|
||||||
|
out.syncServers = [];
|
||||||
|
for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (type === BLOCK_TYPE_ACCESS_SERVERS) {
|
||||||
|
const accessCount = r.readU8();
|
||||||
|
out.accessServers = [];
|
||||||
|
for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (type === BLOCK_TYPE_TRUSTED_STATE) {
|
||||||
|
out.trustedCount = r.readU8();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Неизвестный блок PDA: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.rootKey || !out.deviceKey || !out.blockchain) {
|
||||||
|
throw new Error('В PDA отсутствуют обязательные блоки');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUnsignedRecordFromState(stateLike) {
|
||||||
|
const buf = [];
|
||||||
|
const login = String(stateLike.login || '');
|
||||||
|
const bch = stateLike.blockchain;
|
||||||
|
buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0);
|
||||||
|
pushU64LE(buf, stateLike.createdAtMs);
|
||||||
|
pushU64LE(buf, stateLike.updatedAtMs);
|
||||||
|
pushU32LE(buf, stateLike.recordNumber);
|
||||||
|
for (const x of stateLike.prevRecordHash) buf.push(x);
|
||||||
|
pushStrU8(buf, login);
|
||||||
|
const blocksCount = stateLike.isServer ? 6 : 5;
|
||||||
|
buf.push(blocksCount);
|
||||||
|
|
||||||
|
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
|
||||||
|
for (const x of stateLike.rootKey) buf.push(x);
|
||||||
|
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
|
||||||
|
for (const x of stateLike.deviceKey) buf.push(x);
|
||||||
|
|
||||||
|
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1);
|
||||||
|
buf.push(bch.blockchainType);
|
||||||
|
pushStrU8(buf, bch.blockchainName);
|
||||||
|
for (const x of bch.blockchainPublicKey) buf.push(x);
|
||||||
|
pushU64LE(buf, bch.paidLimitBytes);
|
||||||
|
pushU64LE(buf, bch.usedBytes);
|
||||||
|
pushU32LE(buf, bch.lastBlockNumber);
|
||||||
|
for (const x of bch.lastBlockHash) buf.push(x);
|
||||||
|
for (const x of bch.lastBlockSignature) buf.push(x);
|
||||||
|
if (String(bch.arweaveTxId || '').trim()) {
|
||||||
|
buf.push(1);
|
||||||
|
pushStrU8(buf, bch.arweaveTxId);
|
||||||
|
} else {
|
||||||
|
buf.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateLike.isServer) {
|
||||||
|
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
|
||||||
|
for (const x of stateLike.serverKey) buf.push(x);
|
||||||
|
pushStrU8(buf, stateLike.serverAddress);
|
||||||
|
const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : [];
|
||||||
|
buf.push(sync.length & 0xff);
|
||||||
|
for (const s of sync) pushStrU8(buf, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0);
|
||||||
|
const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : [];
|
||||||
|
buf.push(access.length & 0xff);
|
||||||
|
for (const s of access) pushStrU8(buf, s);
|
||||||
|
|
||||||
|
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff);
|
||||||
|
const recLen = buf.length + 64;
|
||||||
|
buf[7] = recLen & 0xff;
|
||||||
|
buf[8] = (recLen >>> 8) & 0xff;
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
|
||||||
|
const buf = [];
|
||||||
|
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
|
||||||
|
pushStrU8(buf, login);
|
||||||
|
pushStrU8(buf, blockchainName);
|
||||||
|
pushU32LE(buf, lastBlockNumber);
|
||||||
|
for (const x of lastBlockHash32) buf.push(x);
|
||||||
|
pushU64LE(buf, usedBytes);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
|
||||||
|
const sigOff = 16;
|
||||||
|
const pkOff = sigOff + 64;
|
||||||
|
const msgOff = pkOff + 32;
|
||||||
|
const data = new Uint8Array(msgOff + 32);
|
||||||
|
const v = new DataView(data.buffer);
|
||||||
|
data[0] = 1;
|
||||||
|
data[1] = 0;
|
||||||
|
v.setUint16(2, sigOff, true);
|
||||||
|
v.setUint16(4, 0xffff, true);
|
||||||
|
v.setUint16(6, pkOff, true);
|
||||||
|
v.setUint16(8, 0xffff, true);
|
||||||
|
v.setUint16(10, msgOff, true);
|
||||||
|
v.setUint16(12, 32, true);
|
||||||
|
v.setUint16(14, 0xffff, true);
|
||||||
|
data.set(sig64, sigOff);
|
||||||
|
data.set(pubkey32, pkOff);
|
||||||
|
data.set(msgHash32, msgOff);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUpdateUserPdaArgs(args) {
|
||||||
|
const b = [];
|
||||||
|
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x);
|
||||||
|
pushStrU32(b, args.login);
|
||||||
|
for (const x of args.rootKey32) b.push(x);
|
||||||
|
pushU64LE(b, args.createdAtMs);
|
||||||
|
pushU64LE(b, args.updatedAtMs);
|
||||||
|
pushU32LE(b, args.version);
|
||||||
|
pushVecU8(b, args.prevHash32);
|
||||||
|
pushU64LE(b, args.additionalLimitBytes);
|
||||||
|
for (const x of args.deviceKey32) b.push(x);
|
||||||
|
for (const x of args.blockchainPublicKey32) b.push(x);
|
||||||
|
pushStrU32(b, args.blockchainName);
|
||||||
|
pushU64LE(b, args.usedBytes);
|
||||||
|
pushU32LE(b, args.lastBlockNumber);
|
||||||
|
pushVecU8(b, args.lastBlockHash32);
|
||||||
|
pushVecU8(b, args.lastBlockSignature64);
|
||||||
|
pushStrU32(b, args.arweaveTxId);
|
||||||
|
b.push(args.isServer ? 1 : 0);
|
||||||
|
for (const x of args.serverKey32) b.push(x);
|
||||||
|
pushStrU32(b, args.serverAddress);
|
||||||
|
pushVecStrU32(b, args.syncServers);
|
||||||
|
pushVecStrU32(b, args.accessServers);
|
||||||
|
b.push(Number(args.trustedCount || 0) & 0xff);
|
||||||
|
pushVecU8(b, args.rootSignature64);
|
||||||
|
return new Uint8Array(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUsersEconomyConfig(dataBytes) {
|
||||||
|
const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
|
||||||
|
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
|
||||||
|
return {
|
||||||
|
version: v.getUint8(0),
|
||||||
|
registrationFeeLamports: v.getBigUint64(1, true),
|
||||||
|
lamportsPerLimitStep: v.getBigUint64(9, true),
|
||||||
|
startBonusLimit: v.getBigUint64(17, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
|
||||||
|
const endpoint = String(solanaEndpoint || '').trim();
|
||||||
|
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const connection = new solana.Connection(endpoint, 'confirmed');
|
||||||
|
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
||||||
|
const [economyPda] = solana.PublicKey.findProgramAddressSync(
|
||||||
|
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
|
||||||
|
usersProgram,
|
||||||
|
);
|
||||||
|
const ai = await connection.getAccountInfo(economyPda, 'confirmed');
|
||||||
|
if (!ai?.data) throw new Error('Economy config PDA не найден');
|
||||||
|
const economy = parseUsersEconomyConfig(ai.data);
|
||||||
|
return { endpoint, economyPda: economyPda.toBase58(), ...economy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
|
||||||
|
const cleanLogin = String(login || '').trim().toLowerCase();
|
||||||
|
const endpoint = String(solanaEndpoint || '').trim();
|
||||||
|
if (!cleanLogin) throw new Error('Не указан логин');
|
||||||
|
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const connection = new solana.Connection(endpoint, 'confirmed');
|
||||||
|
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
|
||||||
|
const ai = await connection.getAccountInfo(userPda, 'confirmed');
|
||||||
|
if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana');
|
||||||
|
const parsed = parseShineUserPda(ai.data);
|
||||||
|
const bch = parsed.blockchain;
|
||||||
|
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
userPda: userPda.toBase58(),
|
||||||
|
login: parsed.login,
|
||||||
|
recordNumber: parsed.recordNumber,
|
||||||
|
paidLimitBytes: bch.paidLimitBytes,
|
||||||
|
usedBytes: bch.usedBytes,
|
||||||
|
leftBytes,
|
||||||
|
lastBlockNumber: bch.lastBlockNumber,
|
||||||
|
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShineUserPdaOnSolana({
|
||||||
|
login,
|
||||||
|
solanaEndpoint,
|
||||||
|
rootPrivatePkcs8B64,
|
||||||
|
devicePrivatePkcs8B64,
|
||||||
|
blockchainPrivatePkcs8B64,
|
||||||
|
additionalLimitBytes = 0n,
|
||||||
|
nextUsedBytes,
|
||||||
|
nextLastBlockNumber,
|
||||||
|
nextLastBlockHashHex,
|
||||||
|
}) {
|
||||||
|
const cleanLogin = String(login || '').trim().toLowerCase();
|
||||||
|
if (!cleanLogin) throw new Error('Не указан логин');
|
||||||
|
const endpoint = String(solanaEndpoint || '').trim();
|
||||||
|
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
|
||||||
|
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const connection = new solana.Connection(endpoint, 'confirmed');
|
||||||
|
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
||||||
|
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
||||||
|
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
|
||||||
|
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
|
||||||
|
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
|
||||||
|
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
|
||||||
|
|
||||||
|
const userAi = await connection.getAccountInfo(userPda, 'confirmed');
|
||||||
|
if (!userAi?.data) throw new Error('PDA пользователя не найден');
|
||||||
|
const current = parseShineUserPda(userAi.data);
|
||||||
|
const currentBch = current.blockchain;
|
||||||
|
|
||||||
|
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
||||||
|
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
||||||
|
const effectiveLastHash = nextLastBlockHashHex
|
||||||
|
? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
|
||||||
|
: currentBch.lastBlockHash;
|
||||||
|
|
||||||
|
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
||||||
|
const addLimit = BigInt(additionalLimitBytes || 0);
|
||||||
|
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
|
||||||
|
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
|
||||||
|
|
||||||
|
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
||||||
|
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
|
||||||
|
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
|
||||||
|
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
||||||
|
|
||||||
|
const updatedAtMs = BigInt(Date.now());
|
||||||
|
const newPaid = currentBch.paidLimitBytes + addLimit;
|
||||||
|
const newRecordNumber = current.recordNumber + 1;
|
||||||
|
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
|
||||||
|
|
||||||
|
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed);
|
||||||
|
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
||||||
|
const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
...current,
|
||||||
|
updatedAtMs,
|
||||||
|
recordNumber: newRecordNumber,
|
||||||
|
prevRecordHash: prevHash,
|
||||||
|
blockchain: {
|
||||||
|
...currentBch,
|
||||||
|
paidLimitBytes: newPaid,
|
||||||
|
usedBytes: effectiveUsed,
|
||||||
|
lastBlockNumber: effectiveLastNum,
|
||||||
|
lastBlockHash: effectiveLastHash,
|
||||||
|
lastBlockSignature: lastBlockSig64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const unsignedNext = serializeUnsignedRecordFromState(nextState);
|
||||||
|
const unsignedNextHash = await sha256Bytes(unsignedNext);
|
||||||
|
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
|
||||||
|
|
||||||
|
const ixData = serializeUpdateUserPdaArgs({
|
||||||
|
login: cleanLogin,
|
||||||
|
rootKey32: current.rootKey,
|
||||||
|
createdAtMs: current.createdAtMs,
|
||||||
|
updatedAtMs,
|
||||||
|
version: newRecordNumber,
|
||||||
|
prevHash32: prevHash,
|
||||||
|
additionalLimitBytes: addLimit,
|
||||||
|
deviceKey32: current.deviceKey,
|
||||||
|
blockchainPublicKey32: currentBch.blockchainPublicKey,
|
||||||
|
blockchainName: currentBch.blockchainName,
|
||||||
|
usedBytes: effectiveUsed,
|
||||||
|
lastBlockNumber: effectiveLastNum,
|
||||||
|
lastBlockHash32: effectiveLastHash,
|
||||||
|
lastBlockSignature64: lastBlockSig64,
|
||||||
|
arweaveTxId: currentBch.arweaveTxId,
|
||||||
|
isServer: current.isServer,
|
||||||
|
serverKey32: current.serverKey,
|
||||||
|
serverAddress: current.serverAddress,
|
||||||
|
syncServers: current.syncServers,
|
||||||
|
accessServers: current.accessServers,
|
||||||
|
trustedCount: current.trustedCount,
|
||||||
|
rootSignature64: rootSig64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edIxRoot = new solana.TransactionInstruction({
|
||||||
|
programId: ed25519Program,
|
||||||
|
keys: [],
|
||||||
|
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
|
||||||
|
});
|
||||||
|
const edIxBch = new solana.TransactionInstruction({
|
||||||
|
programId: ed25519Program,
|
||||||
|
keys: [],
|
||||||
|
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
|
||||||
|
});
|
||||||
|
const updIx = new solana.TransactionInstruction({
|
||||||
|
programId: usersProgram,
|
||||||
|
keys: [
|
||||||
|
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: userPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: inflowVault, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: economyPda, isSigner: false, isWritable: false },
|
||||||
|
],
|
||||||
|
data: ixData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = await solana.sendAndConfirmTransaction(
|
||||||
|
connection,
|
||||||
|
new solana.Transaction().add(edIxRoot, edIxBch, updIx),
|
||||||
|
[deviceKeypair],
|
||||||
|
{ commitment: 'confirmed' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
signature,
|
||||||
|
userPda: userPda.toBase58(),
|
||||||
|
paidLimitBytes: newPaid,
|
||||||
|
usedBytes: effectiveUsed,
|
||||||
|
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
|
||||||
|
lastBlockNumber: effectiveLastNum,
|
||||||
|
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
|
||||||
|
const add = BigInt(additionalLimitBytes || 0);
|
||||||
|
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
|
||||||
|
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
|
||||||
|
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
|
||||||
|
return (add / LIMIT_STEP) * pricePerStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLimitStepBytes() {
|
||||||
|
return LIMIT_STEP;
|
||||||
|
}
|
||||||
@ -265,6 +265,23 @@ export async function precheckLoginClassOnSolana({ login, solanaEndpoint }) {
|
|||||||
return { classCode: classValue, className: 'unknown' };
|
return { classCode: classValue, className: 'unknown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
||||||
|
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const loginNorm = String(login || '').trim().toLowerCase();
|
||||||
|
if (!loginNorm) {
|
||||||
|
throw new Error('EMPTY_LOGIN');
|
||||||
|
}
|
||||||
|
const [userPda] = solana.PublicKey.findProgramAddressSync(
|
||||||
|
[enc.encode('login='), enc.encode(loginNorm)],
|
||||||
|
usersProgram,
|
||||||
|
);
|
||||||
|
const ai = await connection.getAccountInfo(userPda, 'confirmed');
|
||||||
|
return { exists: !!ai, userPda: userPda.toBase58() };
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
|
||||||
|
|||||||
@ -48,11 +48,6 @@ public final class UserCreateDAO {
|
|||||||
boolean oldAuto = c.getAutoCommit();
|
boolean oldAuto = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
|
|
||||||
// BEGIN IMMEDIATE — чтобы сразу взять write-lock и не ловить гонки
|
|
||||||
try (Statement st = c.createStatement()) {
|
|
||||||
st.execute("BEGIN IMMEDIATE");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1) solana_users
|
// 1) solana_users
|
||||||
SolanaUserEntry u = new SolanaUserEntry();
|
SolanaUserEntry u = new SolanaUserEntry();
|
||||||
|
|||||||
@ -89,6 +89,9 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
resp.setServerLastGlobalNumber(lastNum);
|
resp.setServerLastGlobalNumber(lastNum);
|
||||||
resp.setServerLastGlobalHash(toHex32(lastHash));
|
resp.setServerLastGlobalHash(toHex32(lastHash));
|
||||||
|
resp.setServerBlockchainSizeBytes(st.getFileSizeBytes());
|
||||||
|
resp.setServerBlockchainSizeLimitBytes(st.getSizeLimit());
|
||||||
|
resp.setServerBlocksCount(Math.max(0, lastNum + 1));
|
||||||
|
|
||||||
log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName());
|
log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName());
|
||||||
return resp;
|
return resp;
|
||||||
|
|||||||
@ -41,6 +41,9 @@ public class Net_GetUser_Response extends Net_Response {
|
|||||||
private String deviceKey;
|
private String deviceKey;
|
||||||
private Integer serverLastGlobalNumber;
|
private Integer serverLastGlobalNumber;
|
||||||
private String serverLastGlobalHash;
|
private String serverLastGlobalHash;
|
||||||
|
private Long serverBlockchainSizeBytes;
|
||||||
|
private Long serverBlockchainSizeLimitBytes;
|
||||||
|
private Integer serverBlocksCount;
|
||||||
|
|
||||||
public Boolean getExists() { return exists; }
|
public Boolean getExists() { return exists; }
|
||||||
public void setExists(Boolean exists) { this.exists = exists; }
|
public void setExists(Boolean exists) { this.exists = exists; }
|
||||||
@ -65,4 +68,13 @@ public class Net_GetUser_Response extends Net_Response {
|
|||||||
|
|
||||||
public String getServerLastGlobalHash() { return serverLastGlobalHash; }
|
public String getServerLastGlobalHash() { return serverLastGlobalHash; }
|
||||||
public void setServerLastGlobalHash(String serverLastGlobalHash) { this.serverLastGlobalHash = serverLastGlobalHash; }
|
public void setServerLastGlobalHash(String serverLastGlobalHash) { this.serverLastGlobalHash = serverLastGlobalHash; }
|
||||||
|
|
||||||
|
public Long getServerBlockchainSizeBytes() { return serverBlockchainSizeBytes; }
|
||||||
|
public void setServerBlockchainSizeBytes(Long serverBlockchainSizeBytes) { this.serverBlockchainSizeBytes = serverBlockchainSizeBytes; }
|
||||||
|
|
||||||
|
public Long getServerBlockchainSizeLimitBytes() { return serverBlockchainSizeLimitBytes; }
|
||||||
|
public void setServerBlockchainSizeLimitBytes(Long serverBlockchainSizeLimitBytes) { this.serverBlockchainSizeLimitBytes = serverBlockchainSizeLimitBytes; }
|
||||||
|
|
||||||
|
public Integer getServerBlocksCount() { return serverBlocksCount; }
|
||||||
|
public void setServerBlocksCount(Integer serverBlocksCount) { this.serverBlocksCount = serverBlocksCount; }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user