Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
f9dd245481 | ||
|
|
20677a9090 | ||
|
|
d54cb7507a | ||
|
|
d6b313b582 | ||
|
|
f31cdb413b | ||
|
|
69faf55b2b | ||
|
|
4d9e42205f | ||
|
|
5df69d73c7 | ||
|
|
4704f8485b | ||
|
|
9a7019a2ce | ||
|
|
07b1623c5e | ||
|
|
681437acb1 | ||
|
|
a183a586cb | ||
|
|
af62b51ef5 | ||
|
|
f640451257 | ||
|
|
a56252c361 | ||
|
|
38141ea5c7 | ||
|
|
d24d1c178b | ||
|
|
93673e5786 | ||
|
|
2f3b1571e5 | ||
|
|
65703f0fc4 | ||
|
|
4e674af4ed | ||
|
|
f9c2d7c63a | ||
|
|
968d85d738 | ||
|
|
d230b61c0b | ||
|
|
59047a8d0e | ||
|
|
81493ac4be |
@@ -106,7 +106,9 @@ ESP32/**/*.a
|
|||||||
deploy/backup/archive/**
|
deploy/backup/archive/**
|
||||||
!deploy/backup/archive/.gitkeep
|
!deploy/backup/archive/.gitkeep
|
||||||
|
|
||||||
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
|
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
.claude/
|
.claude/
|
||||||
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
||||||
*.bak.png
|
*.bak.png
|
||||||
|
|||||||
@@ -14,14 +14,15 @@
|
|||||||
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||||
- точка входа `shine-UI/server-ui.html`;
|
- точка входа `shine-UI/server-ui.html`;
|
||||||
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||||
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
|
||||||
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||||
|
|
||||||
## Сервис агента-кодера
|
## Сервис агента-кодера
|
||||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
|
||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
|
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
|
||||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
|
||||||
|
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
||||||
|
|
||||||
## ESP32 UI homeserver
|
## ESP32 UI homeserver
|
||||||
@@ -44,6 +45,11 @@
|
|||||||
- `docs/Solana_Architecture/README.md`
|
- `docs/Solana_Architecture/README.md`
|
||||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
||||||
|
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||||
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
|
- `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md`
|
||||||
|
|
||||||
## Документация блокчейна
|
## Документация блокчейна
|
||||||
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`.
|
||||||
@@ -78,12 +84,8 @@
|
|||||||
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`.
|
||||||
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория.
|
||||||
- `client.version` — версия клиентского UI.
|
|
||||||
- `server.version` — версия серверной части.
|
|
||||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
|
||||||
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
|
- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`.
|
||||||
@@ -92,7 +94,6 @@
|
|||||||
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
|
- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`.
|
||||||
- В deploy-документах и скриптах использовать домены, а не IP.
|
- В deploy-документах и скриптах использовать домены, а не IP.
|
||||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||||
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
|
||||||
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
|
- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя.
|
||||||
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
|
- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`.
|
||||||
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
|
- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@shine-UI/AGENTS.md
|
@shine-UI/AGENTS.md
|
||||||
|
|
||||||
## Справка по подпроектам
|
## Справка по подпроектам
|
||||||
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
|
- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
||||||
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||||
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Правила коммитов, merge и версионирования
|
||||||
|
|
||||||
|
Этот файл является единым источником правил для:
|
||||||
|
- коммитов;
|
||||||
|
- merge в `main`;
|
||||||
|
- изменения версий в `VERSION.properties`;
|
||||||
|
- `git push` из этого репозитория.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Пояснения к коммитам, PR и merge-запросам писать на русском языке.
|
||||||
|
|
||||||
|
## Где хранится версия
|
||||||
|
|
||||||
|
- Единый файл версий проекта: `VERSION.properties` в корне репозитория.
|
||||||
|
- Основные поля:
|
||||||
|
- `client.version` — версия клиентского UI.
|
||||||
|
- `server.version` — версия серверной части.
|
||||||
|
|
||||||
|
## Базовое правило для обычных коммитов
|
||||||
|
|
||||||
|
- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`.
|
||||||
|
- Если менялся только UI, увеличивать только `client.version`.
|
||||||
|
- Если менялся только сервер, увеличивать только `server.version`.
|
||||||
|
- Если менялись и UI, и сервер, увеличивать обе версии.
|
||||||
|
- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту.
|
||||||
|
- Пример: `1.2.346` → `1.2.347`.
|
||||||
|
|
||||||
|
## Правило для `main`
|
||||||
|
|
||||||
|
- Ветка `main` предназначена только для стабильных версий.
|
||||||
|
- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки.
|
||||||
|
- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение.
|
||||||
|
|
||||||
|
## Версионирование при merge или прямом коммите в `main`
|
||||||
|
|
||||||
|
- Для попадания изменений в `main` действует отдельная схема инкремента.
|
||||||
|
- Нужно увеличивать вторую цифру версии и обнулять третью.
|
||||||
|
- Пример: `1.2.346` → `1.3.0`.
|
||||||
|
- Если в наборе изменений менялся только UI, обновлять только `client.version`.
|
||||||
|
- Если в наборе изменений менялся только сервер, обновлять только `server.version`.
|
||||||
|
- Если соответствующая часть не менялась, её версию не трогать.
|
||||||
|
|
||||||
|
## Правила для git commit и git push
|
||||||
|
|
||||||
|
- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется.
|
||||||
|
- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Задание для Айдара: навести порядок в инструкциях агентов SHiNE
|
|
||||||
|
|
||||||
## Кратко
|
|
||||||
Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками.
|
|
||||||
|
|
||||||
## Зачем это нужно
|
|
||||||
Сейчас проект состоит из нескольких связанных, но разных частей:
|
|
||||||
|
|
||||||
- основной сервер `SHiNE-server/`;
|
|
||||||
- UI `shine-UI/`;
|
|
||||||
- Solana/Anchor-модуль `shine-solana/shine/`;
|
|
||||||
- Telegram-агент-кодер `SHiNE-agent-bot-coder/`;
|
|
||||||
- TURN-сервер;
|
|
||||||
- документация `docs/`;
|
|
||||||
- отдельные рабочие папки игроков `Players/`.
|
|
||||||
|
|
||||||
Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций.
|
|
||||||
|
|
||||||
## Что предлагается сделать
|
|
||||||
1. Утвердить корневой `AGENTS.md` как главный набор правил проекта.
|
|
||||||
2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`.
|
|
||||||
3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`.
|
|
||||||
4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`.
|
|
||||||
5. Явно закрепить режим игроков:
|
|
||||||
- игроки могут задавать вопросы, просить анализ, идеи и ТЗ;
|
|
||||||
- игроки не меняют код проекта напрямую;
|
|
||||||
- материалы игроков сохраняются только в `Players/<username>/`.
|
|
||||||
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
|
|
||||||
7. Добавить простой процесс согласования изменений инструкций:
|
|
||||||
- Дима или другой участник готовит предложение;
|
|
||||||
- Айдар получает уведомление/заявку;
|
|
||||||
- Айдар отвечает: одобрить, отклонить или попросить доработать;
|
|
||||||
- только после одобрения агент вносит изменения в проектные инструкции.
|
|
||||||
|
|
||||||
## Предлагаемая логика уведомления Айдару
|
|
||||||
Минимальный вариант без сложной разработки:
|
|
||||||
|
|
||||||
1. Агент готовит текст заявки.
|
|
||||||
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
|
|
||||||
3. В заявке явно указаны варианты ответа:
|
|
||||||
- `одобрить`;
|
|
||||||
- `отклонить`;
|
|
||||||
- `доработать: ...`.
|
|
||||||
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
|
|
||||||
|
|
||||||
Более удобный вариант на будущее:
|
|
||||||
|
|
||||||
- добавить в Telegram-бота команду или сценарий согласования задач, например:
|
|
||||||
- `/approve <id>`;
|
|
||||||
- `/reject <id> причина`;
|
|
||||||
- `/revise <id> комментарий`.
|
|
||||||
|
|
||||||
Но для начала достаточно простого текстового согласования через Telegram.
|
|
||||||
|
|
||||||
## Что нужно от Айдара
|
|
||||||
Подтвердить, что такой порядок подходит:
|
|
||||||
|
|
||||||
1. Корневой `AGENTS.md` остается главным правилом проекта.
|
|
||||||
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
|
|
||||||
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
|
|
||||||
4. Изменения инструкций выполняются только после явного одобрения Айдара.
|
|
||||||
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
После одобрения:
|
|
||||||
|
|
||||||
- агенты будут стабильнее понимать границы проекта;
|
|
||||||
- снизится риск случайных изменений не в той части системы;
|
|
||||||
- появится понятный порядок согласования задач от игроков;
|
|
||||||
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
TELEGRAM_BOT_TOKEN=replace_me
|
|
||||||
OPENAI_API_KEY=replace_me
|
|
||||||
ALLOWED_TELEGRAM_USERNAME=AidarKC
|
|
||||||
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
|
|
||||||
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
|
|
||||||
BOT_USERNAME=aidar_su_bot
|
|
||||||
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
|
||||||
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
|
|
||||||
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_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
|
||||||
CODEX_TIMEOUT_SECONDS=900
|
|
||||||
MAX_RETRIES=3
|
|
||||||
DATA_DIR=./data
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.env
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
run/
|
|
||||||
__pycache__/
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# AGENT.md для SHiNE-agent-bot-coder
|
|
||||||
|
|
||||||
Ты запущен как обработчик входящего Telegram-сообщения от пользователя.
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с этим проектом.
|
|
||||||
- Сервис принимает входящие сообщения от пользователя Telegram, сохраняет историю, ставит задачи в очередь и последовательно запускает Codex CLI в рабочем проекте.
|
|
||||||
- Текстовые сообщения обрабатываются напрямую, voice/audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача.
|
|
||||||
- История диалога хранится в JSONL-файле, путь передаётся в промпте.
|
|
||||||
- Сообщение может быть текстом или результатом распознавания голосового.
|
|
||||||
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
|
|
||||||
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
|
|
||||||
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
|
|
||||||
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
|
||||||
|
|
||||||
## Авторитет команд и история
|
|
||||||
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
|
||||||
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
|
|
||||||
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
|
|
||||||
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
|
|
||||||
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
|
|
||||||
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
|
||||||
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
|
|
||||||
|
|
||||||
## Очередь и состояние
|
|
||||||
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
|
||||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
|
||||||
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
|
||||||
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
|
||||||
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
|
|
||||||
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
|
||||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
|
||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
|
||||||
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
|
|
||||||
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
|
|
||||||
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию.
|
|
||||||
- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа.
|
|
||||||
- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше.
|
|
||||||
- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения.
|
|
||||||
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать.
|
|
||||||
- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке.
|
|
||||||
|
|
||||||
## Правила голосовой версии ответа
|
|
||||||
- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали.
|
|
||||||
- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS.
|
|
||||||
- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух.
|
|
||||||
- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое.
|
|
||||||
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
|
|
||||||
|
|
||||||
## Планы и отложенные фичи
|
|
||||||
- Планы проекта по отложенным фичам хранятся в `TODO/`.
|
|
||||||
- Внутри есть три горизонта:
|
|
||||||
- `near/` - ближайшие планы, обычно сегодня/завтра;
|
|
||||||
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
|
|
||||||
- `far/` - дальнее будущее без понятного срока.
|
|
||||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
|
|
||||||
- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя.
|
|
||||||
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `docs/Pending_Features/`.
|
|
||||||
|
|
||||||
## Центр задач и предложений
|
|
||||||
- Сервис хранит простые задачи и предложения в `data/task_center/items.json`.
|
|
||||||
- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы».
|
|
||||||
- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...».
|
|
||||||
- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`.
|
|
||||||
- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`.
|
|
||||||
- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения.
|
|
||||||
|
|
||||||
## Локальный запуск и systemd
|
|
||||||
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
|
||||||
- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится.
|
|
||||||
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
|
||||||
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
|
||||||
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
|
||||||
- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`.
|
|
||||||
|
|
||||||
## Правила ответа
|
|
||||||
- Пиши содержательно и коротко.
|
|
||||||
- Не упоминай внутренние служебные детали, файловую систему и технические логи.
|
|
||||||
- Если запрос требует действий с кодом/проектом, выполняй их в рабочей директории.
|
|
||||||
- Если для ответа данных недостаточно, задай ровно один уточняющий вопрос.
|
|
||||||
- Если была ошибка предыдущего запуска, в промпте будет пометка retry — учти это и продолжи с учётом текущего состояния проекта.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# AGENTS
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
|
|
||||||
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
|
|
||||||
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
|
|
||||||
|
|
||||||
## Связанные инструкции
|
|
||||||
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
|
|
||||||
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
|
|
||||||
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
|
|
||||||
|
|
||||||
## Планы и задачи
|
|
||||||
- Отложенные задачи проекта лежат в `../TODO/`.
|
|
||||||
- Точка входа по планам: `../TODO/README.md`.
|
|
||||||
- Горизонты планов:
|
|
||||||
- `near/` - ближайшие планы;
|
|
||||||
- `medium/` - среднесрочные планы;
|
|
||||||
- `far/` - дальнее будущее.
|
|
||||||
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
|
|
||||||
- Не начинать реализацию задач из `TODO` без явной команды пользователя.
|
|
||||||
|
|
||||||
## Проверка после изменений
|
|
||||||
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
|
|
||||||
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@AGENT.md
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Промпты для режима игроков (на согласование)
|
|
||||||
|
|
||||||
## 1) Базовый служебный промпт (добавка к задаче игрока)
|
|
||||||
|
|
||||||
```text
|
|
||||||
Режим игрока (обязательно):
|
|
||||||
- Пользователь: <Имя> (@<username>).
|
|
||||||
- Рабочая папка игрока: <project>/Players/<username>
|
|
||||||
- Код проекта не изменять.
|
|
||||||
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
|
|
||||||
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2) Приветственное сообщение игроку (один раз)
|
|
||||||
|
|
||||||
```text
|
|
||||||
Привет, <Имя>.
|
|
||||||
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
|
|
||||||
Команда /new начинает новую сессию и архивирует текущую историю.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3) Отказ неизвестному пользователю
|
|
||||||
|
|
||||||
```text
|
|
||||||
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
|
|
||||||
```
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# SHiNE-agent-bot-coder
|
|
||||||
|
|
||||||
Локальный Telegram-бот-сервис для пользователя `ai`:
|
|
||||||
- принимает сообщения от `@AidarKC`;
|
|
||||||
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
|
|
||||||
- ведёт историю диалога в `JSONL`;
|
|
||||||
- ставит задачи в файловую очередь;
|
|
||||||
- обрабатывает задачи строго последовательно;
|
|
||||||
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
|
|
||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
|
||||||
- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам;
|
|
||||||
- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
|
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
|
||||||
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
|
|
||||||
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
|
||||||
|
|
||||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
- `.env` — локальные секреты и параметры запуска (не коммитится);
|
|
||||||
- `data/py_queue.jsonl` — очередь Python-сервиса;
|
|
||||||
- `data/py_state.json` — текущее состояние Python-сервиса;
|
|
||||||
- `data/py_processed_updates.log` — дедуп входящих update;
|
|
||||||
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
|
|
||||||
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
|
|
||||||
|
|
||||||
## Локальный запуск
|
|
||||||
1. Скопировать пример:
|
|
||||||
- `cp .env.example .env`
|
|
||||||
2. Заполнить секреты в `.env`.
|
|
||||||
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
|
||||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
|
||||||
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
|
|
||||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
|
||||||
- `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда.
|
|
||||||
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
|
||||||
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
|
||||||
- `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`.
|
|
||||||
- `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд.
|
|
||||||
- `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды.
|
|
||||||
- `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`.
|
|
||||||
- `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`.
|
|
||||||
- `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`.
|
|
||||||
- `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. Запуск:
|
|
||||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
|
||||||
|
|
||||||
## Быстрый self-test Codex (без Telegram)
|
|
||||||
```bash
|
|
||||||
python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Длинные voice/audio
|
|
||||||
- Если аудио короткое, бот отправляет его в OpenAI как раньше.
|
|
||||||
- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно.
|
|
||||||
- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI.
|
|
||||||
- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`.
|
|
||||||
|
|
||||||
## Статусы в личке
|
|
||||||
- Для `private`-чата бот поддерживает персональную настройку режима ответа.
|
|
||||||
- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам.
|
|
||||||
- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно.
|
|
||||||
- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа.
|
|
||||||
- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением.
|
|
||||||
|
|
||||||
## Запуск как systemd-сервис
|
|
||||||
Файлы для установки:
|
|
||||||
- `scripts/systemd/shine-agent-bot-coder.service`
|
|
||||||
- `scripts/systemd/install-local-systemd.sh`
|
|
||||||
|
|
||||||
Установка:
|
|
||||||
- `bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
- `systemctl --user status shine-agent-bot-coder --no-pager`
|
|
||||||
- `journalctl --user -u shine-agent-bot-coder -f`
|
|
||||||
|
|
||||||
Перезапуск после изменений:
|
|
||||||
- `systemctl --user restart shine-agent-bot-coder`
|
|
||||||
|
|
||||||
## Telegram-команды
|
|
||||||
- `/status` — активная задача и размер очереди.
|
|
||||||
- `/settings` — текущие пользовательские настройки и команды для их изменения.
|
|
||||||
- `/queue` — список задач в очереди.
|
|
||||||
- `/stop` — остановить текущую задачу.
|
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
|
||||||
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
|
|
||||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
|
||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
|
||||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
|
||||||
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
|
|
||||||
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
|
|
||||||
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
|
|
||||||
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
|
|
||||||
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="/home/ai/work/SHiNE/SHiNE-server-sha256"
|
|
||||||
SERVICE_DIR="${ROOT_DIR}/SHiNE-agent-bot-coder"
|
|
||||||
UNIT_SRC="${SERVICE_DIR}/scripts/systemd/shine-agent-bot-coder.service"
|
|
||||||
UNIT_DST="${HOME}/.config/systemd/user/shine-agent-bot-coder.service"
|
|
||||||
|
|
||||||
echo "[1/6] Проверка python3..."
|
|
||||||
command -v python3 >/dev/null 2>&1 || { echo "python3 не найден"; exit 1; }
|
|
||||||
|
|
||||||
echo "[2/6] Подготовка папки логов..."
|
|
||||||
mkdir -p "${SERVICE_DIR}/logs"
|
|
||||||
|
|
||||||
echo "[3/6] Копирование user systemd unit..."
|
|
||||||
mkdir -p "$(dirname "${UNIT_DST}")"
|
|
||||||
cp "${UNIT_SRC}" "${UNIT_DST}"
|
|
||||||
|
|
||||||
echo "[4/6] daemon-reload..."
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
echo "[5/6] enable + start..."
|
|
||||||
systemctl --user enable --now shine-agent-bot-coder
|
|
||||||
|
|
||||||
echo "[6/6] Статус:"
|
|
||||||
systemctl --user status shine-agent-bot-coder --no-pager
|
|
||||||
|
|
||||||
echo "Готово. Логи: journalctl --user -u shine-agent-bot-coder -f"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker)
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder
|
|
||||||
EnvironmentFile=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/.env
|
|
||||||
ExecStart=/usr/bin/python3 /home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/py_bot_service.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
TimeoutStopSec=20
|
|
||||||
SuccessExitStatus=143 0
|
|
||||||
StandardOutput=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
|
|
||||||
StandardError=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -59,6 +59,8 @@ public final class AppConfig {
|
|||||||
public String getParam(String name) {
|
public String getParam(String name) {
|
||||||
String fromSystem = System.getProperty(name);
|
String fromSystem = System.getProperty(name);
|
||||||
if (fromSystem != null) return fromSystem;
|
if (fromSystem != null) return fromSystem;
|
||||||
|
String fromEnv = System.getenv(toEnvName(name));
|
||||||
|
if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim();
|
||||||
return properties.getProperty(name);
|
return properties.getProperty(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,4 +80,11 @@ public final class AppConfig {
|
|||||||
String v = properties.getProperty(name);
|
String v = properties.getProperty(name);
|
||||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String toEnvName(String name) {
|
||||||
|
return name
|
||||||
|
.replace('.', '_')
|
||||||
|
.replace('-', '_')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -641,6 +641,7 @@ public final class DatabaseInitializer {
|
|||||||
origin_session_id TEXT,
|
origin_session_id TEXT,
|
||||||
receipt_ref_base_key TEXT,
|
receipt_ref_base_key TEXT,
|
||||||
receipt_ref_type INTEGER,
|
receipt_ref_type INTEGER,
|
||||||
|
read_at_ms INTEGER,
|
||||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
private static volatile SqliteDbController instance;
|
||||||
private static final int LATEST_SCHEMA_VERSION = 11;
|
private static final int LATEST_SCHEMA_VERSION = 12;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ public final class SqliteDbController {
|
|||||||
case 9 -> migrateToV9();
|
case 9 -> migrateToV9();
|
||||||
case 10 -> migrateToV10();
|
case 10 -> migrateToV10();
|
||||||
case 11 -> migrateToV11();
|
case 11 -> migrateToV11();
|
||||||
|
case 12 -> migrateToV12();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +330,26 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateToV12() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureSignedMessagesReadAtColumn(c, st);
|
||||||
|
backfillSignedMessagesReadAt(st);
|
||||||
|
setSchemaVersion(c, 12);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v12 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v12 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||||
@@ -463,6 +484,33 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
|
||||||
|
if (!tableExists(c, "signed_messages_v2")) return;
|
||||||
|
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
|
||||||
|
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
UPDATE signed_messages_v2 AS content
|
||||||
|
SET read_at_ms = (
|
||||||
|
SELECT MIN(receipt.time_ms)
|
||||||
|
FROM signed_messages_v2 AS receipt
|
||||||
|
WHERE receipt.message_type IN (3, 4)
|
||||||
|
AND receipt.receipt_ref_base_key = content.base_key
|
||||||
|
)
|
||||||
|
WHERE content.message_type IN (1, 2)
|
||||||
|
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM signed_messages_v2 AS receipt
|
||||||
|
WHERE receipt.message_type IN (3, 4)
|
||||||
|
AND receipt.receipt_ref_base_key = content.base_key
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
||||||
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesV2DAO {
|
||||||
|
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
|
||||||
|
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
|
||||||
|
|
||||||
public enum ApplyStatus {
|
public enum ApplyStatus {
|
||||||
APPLIED,
|
APPLIED,
|
||||||
DUPLICATE_OR_OLDER,
|
DUPLICATE_OR_OLDER,
|
||||||
@@ -37,6 +41,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
@@ -46,17 +51,23 @@ public final class SignedMessagesV2DAO {
|
|||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
if (status.applied()) {
|
||||||
|
markMessageReadByReceipt(c, e);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
@@ -64,6 +75,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
int insertedFirst = insertStrict(c, first);
|
int insertedFirst = insertStrict(c, first);
|
||||||
int insertedSecond = insertStrict(c, second);
|
int insertedSecond = insertStrict(c, second);
|
||||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||||
|
markMessageReadByReceipt(c, first);
|
||||||
|
markMessageReadByReceipt(c, second);
|
||||||
c.commit();
|
c.commit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -79,9 +92,11 @@ public final class SignedMessagesV2DAO {
|
|||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
@@ -104,6 +119,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
|
|
||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
upsertMessage(c, outgoing);
|
upsertMessage(c, outgoing);
|
||||||
|
markMessageReadByReceipt(c, incoming);
|
||||||
|
markMessageReadByReceipt(c, outgoing);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
|
||||||
@@ -116,9 +133,11 @@ public final class SignedMessagesV2DAO {
|
|||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
@@ -140,6 +159,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
|
markMessageReadByReceipt(c, incoming);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
@@ -150,9 +170,11 @@ public final class SignedMessagesV2DAO {
|
|||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
@@ -179,9 +201,11 @@ public final class SignedMessagesV2DAO {
|
|||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
@@ -205,6 +229,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||||
@@ -214,7 +239,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
FROM signed_messages_v2
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
""";
|
""";
|
||||||
@@ -235,6 +260,12 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||||
|
ensureDeliveryRows(messageKey, List.of(sessionId), nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ensureDeliveryRows(String messageKey, List<String> sessionIds, long nowMs) throws Exception {
|
||||||
|
if (sessionIds == null || sessionIds.isEmpty()) return;
|
||||||
|
withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
@@ -242,43 +273,49 @@ public final class SignedMessagesV2DAO {
|
|||||||
) VALUES (?, ?, 0, NULL, ?)
|
) VALUES (?, ?, 0, NULL, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (String sessionId : sessionIds) {
|
||||||
|
if (sessionId == null || sessionId.isBlank()) continue;
|
||||||
ps.setString(1, messageKey);
|
ps.setString(1, messageKey);
|
||||||
ps.setString(2, sessionId);
|
ps.setString(2, sessionId);
|
||||||
ps.setLong(3, nowMs);
|
ps.setLong(3, nowMs);
|
||||||
ps.executeUpdate();
|
ps.addBatch();
|
||||||
}
|
}
|
||||||
|
ps.executeBatch();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||||
|
withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String insertSql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT INTO signed_message_session_delivery (
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
) VALUES (?, ?, 0, NULL, ?)
|
) VALUES (?, ?, 1, ?, ?)
|
||||||
|
ON CONFLICT(message_key, session_id) DO UPDATE SET
|
||||||
|
delivered = 1,
|
||||||
|
delivered_at_ms = CASE
|
||||||
|
WHEN signed_message_session_delivery.delivered_at_ms IS NULL THEN excluded.delivered_at_ms
|
||||||
|
WHEN signed_message_session_delivery.delivered_at_ms > excluded.delivered_at_ms THEN excluded.delivered_at_ms
|
||||||
|
ELSE signed_message_session_delivery.delivered_at_ms
|
||||||
|
END
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(insertSql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, messageKey);
|
ps.setString(1, messageKey);
|
||||||
ps.setString(2, sessionId);
|
ps.setString(2, sessionId);
|
||||||
ps.setLong(3, deliveredAtMs);
|
ps.setLong(3, deliveredAtMs);
|
||||||
|
ps.setLong(4, deliveredAtMs);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
String updateSql = """
|
|
||||||
UPDATE signed_message_session_delivery
|
|
||||||
SET delivered = 1, delivered_at_ms = ?
|
|
||||||
WHERE message_key = ? AND session_id = ?
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(updateSql)) {
|
|
||||||
ps.setLong(1, deliveredAtMs);
|
|
||||||
ps.setString(2, messageKey);
|
|
||||||
ps.setString(3, sessionId);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||||
|
return withBusyRetry(() -> {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String fillSql = """
|
String fillSql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
@@ -309,7 +346,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||||
m.receipt_ref_base_key, m.receipt_ref_type
|
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2 m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
@@ -325,6 +362,57 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SignedMessageV2Entry> listConversationPage(
|
||||||
|
String login,
|
||||||
|
String peerLogin,
|
||||||
|
long beforeTimeMs,
|
||||||
|
String beforeMessageKey,
|
||||||
|
int limit
|
||||||
|
) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE target_login = ? COLLATE NOCASE
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
AND (
|
||||||
|
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
? <= 0
|
||||||
|
OR time_ms < ?
|
||||||
|
OR (time_ms = ? AND (? = '' OR message_key < ?))
|
||||||
|
)
|
||||||
|
ORDER BY time_ms DESC, message_key DESC
|
||||||
|
LIMIT ?
|
||||||
|
""";
|
||||||
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.setString(3, peerLogin);
|
||||||
|
ps.setString(4, peerLogin);
|
||||||
|
ps.setString(5, login);
|
||||||
|
ps.setLong(6, beforeTimeMs);
|
||||||
|
ps.setLong(7, beforeTimeMs);
|
||||||
|
ps.setLong(8, beforeTimeMs);
|
||||||
|
ps.setString(9, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||||
|
ps.setString(10, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||||
|
ps.setInt(11, limit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
|
||||||
@@ -333,8 +421,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(message_key) DO UPDATE SET
|
ON CONFLICT(message_key) DO UPDATE SET
|
||||||
base_key = excluded.base_key,
|
base_key = excluded.base_key,
|
||||||
target_login = excluded.target_login,
|
target_login = excluded.target_login,
|
||||||
@@ -350,7 +438,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
source_api = excluded.source_api,
|
source_api = excluded.source_api,
|
||||||
origin_session_id = excluded.origin_session_id,
|
origin_session_id = excluded.origin_session_id,
|
||||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||||
receipt_ref_type = excluded.receipt_ref_type
|
receipt_ref_type = excluded.receipt_ref_type,
|
||||||
|
read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -358,6 +447,32 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
||||||
|
if (entry == null) return;
|
||||||
|
int messageType = entry.getMessageType();
|
||||||
|
if (messageType != 3 && messageType != 4) return;
|
||||||
|
String receiptRefBaseKey = String.valueOf(entry.getReceiptRefBaseKey() == null ? "" : entry.getReceiptRefBaseKey()).trim();
|
||||||
|
if (receiptRefBaseKey.isEmpty()) return;
|
||||||
|
long readAtMs = entry.getTimeMs();
|
||||||
|
if (readAtMs <= 0) return;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
|
UPDATE signed_messages_v2
|
||||||
|
SET read_at_ms = CASE
|
||||||
|
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||||
|
WHEN read_at_ms > ? THEN ?
|
||||||
|
ELSE read_at_ms
|
||||||
|
END
|
||||||
|
WHERE base_key = ?
|
||||||
|
AND message_type IN (1, 2)
|
||||||
|
""")) {
|
||||||
|
ps.setLong(1, readAtMs);
|
||||||
|
ps.setLong(2, readAtMs);
|
||||||
|
ps.setLong(3, readAtMs);
|
||||||
|
ps.setString(4, receiptRefBaseKey);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT revision_time_ms, reencrypted_at_ms
|
SELECT revision_time_ms, reencrypted_at_ms
|
||||||
@@ -536,8 +651,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
receipt_ref_base_key, receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -563,6 +678,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
ps.setString(15, e.getReceiptRefBaseKey());
|
ps.setString(15, e.getReceiptRefBaseKey());
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||||
else ps.setInt(16, e.getReceiptRefType());
|
else ps.setInt(16, e.getReceiptRefType());
|
||||||
|
if (e.getReadAtMs() == null) ps.setObject(17, null);
|
||||||
|
else ps.setLong(17, e.getReadAtMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||||
@@ -586,6 +703,45 @@ public final class SignedMessagesV2DAO {
|
|||||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isBusyLock(SQLException ex) {
|
||||||
|
Throwable current = ex;
|
||||||
|
while (current != null) {
|
||||||
|
String msg = String.valueOf(current.getMessage()).toLowerCase();
|
||||||
|
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
|
||||||
|
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
|
||||||
|
try {
|
||||||
|
Thread.sleep(delayMs);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
|
||||||
|
throw sqlEx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
||||||
|
SQLException lastBusy = null;
|
||||||
|
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
return work.run();
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
lastBusy = ex;
|
||||||
|
sleepBeforeBusyRetry(attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
|
||||||
|
}
|
||||||
|
|
||||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||||
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||||
if (revisionCompare != 0) return revisionCompare;
|
if (revisionCompare != 0) return revisionCompare;
|
||||||
@@ -611,6 +767,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||||
|
long maybeReadAt = rs.getLong("read_at_ms");
|
||||||
|
e.setReadAtMs(rs.wasNull() ? null : maybeReadAt);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,4 +777,9 @@ public final class SignedMessagesV2DAO {
|
|||||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface SqlWork<T> {
|
||||||
|
T run() throws Exception;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class SignedMessageV2Entry {
|
|||||||
private String originSessionId;
|
private String originSessionId;
|
||||||
private String receiptRefBaseKey;
|
private String receiptRefBaseKey;
|
||||||
private Integer receiptRefType;
|
private Integer receiptRefType;
|
||||||
|
private Long readAtMs;
|
||||||
|
|
||||||
public String getMessageKey() { return messageKey; }
|
public String getMessageKey() { return messageKey; }
|
||||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||||
@@ -50,4 +51,6 @@ public class SignedMessageV2Entry {
|
|||||||
public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
||||||
public Integer getReceiptRefType() { return receiptRefType; }
|
public Integer getReceiptRefType() { return receiptRefType; }
|
||||||
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
|
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
|
||||||
|
public Long getReadAtMs() { return readAtMs; }
|
||||||
|
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
|||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||||
@@ -102,6 +103,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
|||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||||
@@ -200,6 +202,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
|
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||||
@@ -280,6 +283,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||||
|
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
|
||||||
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
|
|
||||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
@@ -51,8 +50,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
||||||
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
|
||||||
|
|
||||||
public static final long ALLOWED_SKEW_MS = 30_000L;
|
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -424,7 +421,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
|
||||||
|
|
||||||
// --- формируем ответ ---
|
// --- формируем ответ ---
|
||||||
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
|||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
|
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
|
||||||
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
|
|
||||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
@@ -44,8 +43,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
||||||
|
|
||||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
|
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
|
||||||
@@ -302,7 +299,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
|||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
|
||||||
|
|
||||||
// ответ
|
// ответ
|
||||||
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.messages.entyties.Net_GetDirectMessages_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_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;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class);
|
||||||
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||||
|
Net_GetDirectMessages_Request req = (Net_GetDirectMessages_Request) baseRequest;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
|
}
|
||||||
|
if (req.getPeerLogin() == null || req.getPeerLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "peerLogin обязателен");
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit();
|
||||||
|
if (limit <= 0 || limit > MAX_LIMIT) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_LIMIT", "limit должен быть в диапазоне 1.." + MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
String peerLogin = req.getPeerLogin().trim();
|
||||||
|
long beforeTimeMs = req.getBeforeTimeMs() == null ? 0L : req.getBeforeTimeMs();
|
||||||
|
String beforeMessageKey = req.getBeforeMessageKey() == null ? "" : req.getBeforeMessageKey().trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<SignedMessageV2Entry> page = SignedMessagesV2DAO.getInstance().listConversationPage(
|
||||||
|
login,
|
||||||
|
peerLogin,
|
||||||
|
beforeTimeMs,
|
||||||
|
beforeMessageKey,
|
||||||
|
limit + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean hasMore = page.size() > limit;
|
||||||
|
if (hasMore) {
|
||||||
|
page = new ArrayList<>(page.subList(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(login);
|
||||||
|
resp.setPeerLogin(peerLogin);
|
||||||
|
resp.setLimit(limit);
|
||||||
|
resp.setHasMore(hasMore);
|
||||||
|
|
||||||
|
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||||
|
for (SignedMessageV2Entry entry : page) {
|
||||||
|
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||||
|
item.setMessageKey(entry.getMessageKey());
|
||||||
|
item.setBaseKey(entry.getBaseKey());
|
||||||
|
item.setFromLogin(entry.getFromLogin());
|
||||||
|
item.setToLogin(entry.getToLogin());
|
||||||
|
item.setMessageType(entry.getMessageType());
|
||||||
|
item.setTimeMs(entry.getTimeMs());
|
||||||
|
item.setNonce(entry.getNonce());
|
||||||
|
item.setRevisionTimeMs(entry.getRevisionTimeMs());
|
||||||
|
item.setReencryptedAtMs(entry.getReencryptedAtMs());
|
||||||
|
item.setCreatedAtMs(entry.getCreatedAtMs());
|
||||||
|
item.setReadAtMs(entry.getReadAtMs());
|
||||||
|
item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock()));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
resp.setMessages(items);
|
||||||
|
|
||||||
|
if (hasMore && !items.isEmpty()) {
|
||||||
|
Net_GetDirectMessages_Response.MessageItem last = items.get(items.size() - 1);
|
||||||
|
resp.setNextBeforeTimeMs(last.getTimeMs());
|
||||||
|
resp.setNextBeforeMessageKey(last.getMessageKey());
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("GetDirectMessages failed for login={} peerLogin={}", login, peerLogin, e);
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,12 +50,20 @@ public final class SignedMessagesRealtime {
|
|||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
for (String targetLogin : targetLoginsForMessage(message)) {
|
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||||
|
List<String> sessionIdsToTrack = new ArrayList<>();
|
||||||
|
for (ActiveSessionEntry s : sessions) {
|
||||||
|
String sessionId = s.getSessionId();
|
||||||
|
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sessionIdsToTrack.add(sessionId);
|
||||||
|
}
|
||||||
|
SignedMessagesV2DAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now);
|
||||||
for (ActiveSessionEntry s : sessions) {
|
for (ActiveSessionEntry s : sessions) {
|
||||||
String sessionId = s.getSessionId();
|
String sessionId = s.getSessionId();
|
||||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
|
||||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||||
if (deliveredOnline) {
|
if (deliveredOnline) {
|
||||||
counters.wsDelivered++;
|
counters.wsDelivered++;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
public class Net_GetDirectMessages_Request extends Net_Request {
|
||||||
|
private String peerLogin;
|
||||||
|
private Integer limit;
|
||||||
|
private Long beforeTimeMs;
|
||||||
|
private String beforeMessageKey;
|
||||||
|
|
||||||
|
public String getPeerLogin() { return peerLogin; }
|
||||||
|
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Long getBeforeTimeMs() { return beforeTimeMs; }
|
||||||
|
public void setBeforeTimeMs(Long beforeTimeMs) { this.beforeTimeMs = beforeTimeMs; }
|
||||||
|
public String getBeforeMessageKey() { return beforeMessageKey; }
|
||||||
|
public void setBeforeMessageKey(String beforeMessageKey) { this.beforeMessageKey = beforeMessageKey; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Net_GetDirectMessages_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private String peerLogin;
|
||||||
|
private int limit;
|
||||||
|
private boolean hasMore;
|
||||||
|
private Long nextBeforeTimeMs;
|
||||||
|
private String nextBeforeMessageKey;
|
||||||
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getPeerLogin() { return peerLogin; }
|
||||||
|
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||||
|
public int getLimit() { return limit; }
|
||||||
|
public void setLimit(int limit) { this.limit = limit; }
|
||||||
|
public boolean isHasMore() { return hasMore; }
|
||||||
|
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
|
||||||
|
public Long getNextBeforeTimeMs() { return nextBeforeTimeMs; }
|
||||||
|
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||||
|
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||||
|
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||||
|
public List<MessageItem> getMessages() { return messages; }
|
||||||
|
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||||
|
|
||||||
|
public static class MessageItem {
|
||||||
|
private String messageKey;
|
||||||
|
private String baseKey;
|
||||||
|
private String fromLogin;
|
||||||
|
private String toLogin;
|
||||||
|
private int messageType;
|
||||||
|
private long timeMs;
|
||||||
|
private long nonce;
|
||||||
|
private long revisionTimeMs;
|
||||||
|
private long reencryptedAtMs;
|
||||||
|
private long createdAtMs;
|
||||||
|
private Long readAtMs;
|
||||||
|
private String blobB64;
|
||||||
|
|
||||||
|
public String getMessageKey() { return messageKey; }
|
||||||
|
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||||
|
public String getBaseKey() { return baseKey; }
|
||||||
|
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||||
|
public String getFromLogin() { return fromLogin; }
|
||||||
|
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
public int getMessageType() { return messageType; }
|
||||||
|
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||||
|
public long getTimeMs() { return timeMs; }
|
||||||
|
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||||
|
public long getNonce() { return nonce; }
|
||||||
|
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||||
|
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||||
|
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||||
|
public long getReencryptedAtMs() { return reencryptedAtMs; }
|
||||||
|
public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; }
|
||||||
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||||
|
public Long getReadAtMs() { return readAtMs; }
|
||||||
|
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
|
||||||
|
public String getBlobB64() { return blobB64; }
|
||||||
|
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'shine'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':shine-server-config')
|
||||||
|
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
|
||||||
|
implementation 'org.postgresql:postgresql:42.7.7'
|
||||||
|
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||||
|
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||||
|
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package sync.config;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
public record AppConfig(
|
||||||
|
String rpcUrl,
|
||||||
|
String websocketUrl,
|
||||||
|
String programId,
|
||||||
|
String databaseUrl,
|
||||||
|
String databaseUser,
|
||||||
|
String databasePassword,
|
||||||
|
Duration pollInterval,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static final String DEFAULT_PROGRAM_ID =
|
||||||
|
"SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6";
|
||||||
|
|
||||||
|
public static final String FIXED_COMMITMENT =
|
||||||
|
"confirmed";
|
||||||
|
|
||||||
|
public static final String ENABLED_KEY =
|
||||||
|
"solana.users.sync.enabled";
|
||||||
|
public static final String RPC_URL_KEY =
|
||||||
|
"solana.users.sync.rpcUrl";
|
||||||
|
public static final String WEBSOCKET_URL_KEY =
|
||||||
|
"solana.users.sync.wsUrl";
|
||||||
|
public static final String PROGRAM_ID_KEY =
|
||||||
|
"solana.users.sync.programId";
|
||||||
|
public static final String DATABASE_URL_KEY =
|
||||||
|
"solana.users.sync.databaseUrl";
|
||||||
|
public static final String DATABASE_USER_KEY =
|
||||||
|
"solana.users.sync.dbUser";
|
||||||
|
public static final String DATABASE_PASSWORD_KEY =
|
||||||
|
"solana.users.sync.dbPassword";
|
||||||
|
public static final String POLL_INTERVAL_KEY =
|
||||||
|
"solana.users.sync.pollIntervalSeconds";
|
||||||
|
public static final String LEGACY_SOLANA_RPC_URL_KEY =
|
||||||
|
"solana.rpcUrl";
|
||||||
|
|
||||||
|
public static boolean isEnabled(
|
||||||
|
utils.config.AppConfig serverConfig
|
||||||
|
) {
|
||||||
|
String value =
|
||||||
|
trimToNull(
|
||||||
|
serverConfig.getParam(ENABLED_KEY)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean.parseBoolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppConfig fromServerConfig(
|
||||||
|
utils.config.AppConfig serverConfig
|
||||||
|
) {
|
||||||
|
|
||||||
|
String rpcUrl =
|
||||||
|
firstRequired(
|
||||||
|
serverConfig,
|
||||||
|
"Solana users sync RPC URL",
|
||||||
|
RPC_URL_KEY,
|
||||||
|
LEGACY_SOLANA_RPC_URL_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
String websocketUrl =
|
||||||
|
requireParam(
|
||||||
|
serverConfig,
|
||||||
|
WEBSOCKET_URL_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
String programId =
|
||||||
|
optionalParam(
|
||||||
|
serverConfig,
|
||||||
|
PROGRAM_ID_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (programId == null) {
|
||||||
|
programId =
|
||||||
|
DEFAULT_PROGRAM_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
String databaseUrl =
|
||||||
|
requireParam(
|
||||||
|
serverConfig,
|
||||||
|
DATABASE_URL_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
String databaseUser =
|
||||||
|
requireParam(
|
||||||
|
serverConfig,
|
||||||
|
DATABASE_USER_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
String databasePassword =
|
||||||
|
requireParam(
|
||||||
|
serverConfig,
|
||||||
|
DATABASE_PASSWORD_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
long pollIntervalSeconds =
|
||||||
|
parsePositiveLong(
|
||||||
|
optionalParam(
|
||||||
|
serverConfig,
|
||||||
|
POLL_INTERVAL_KEY
|
||||||
|
),
|
||||||
|
300L,
|
||||||
|
POLL_INTERVAL_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AppConfig(
|
||||||
|
rpcUrl,
|
||||||
|
websocketUrl,
|
||||||
|
programId,
|
||||||
|
databaseUrl,
|
||||||
|
databaseUser,
|
||||||
|
databasePassword,
|
||||||
|
Duration.ofSeconds(
|
||||||
|
pollIntervalSeconds
|
||||||
|
),
|
||||||
|
FIXED_COMMITMENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parsePositiveLong(
|
||||||
|
String rawValue,
|
||||||
|
long defaultValue,
|
||||||
|
String envName
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (rawValue == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
long value =
|
||||||
|
Long.parseLong(
|
||||||
|
rawValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value <= 0L) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
envName + " must be > 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
envName + " must be a positive integer",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstRequired(
|
||||||
|
utils.config.AppConfig serverConfig,
|
||||||
|
String humanName,
|
||||||
|
String... names
|
||||||
|
) {
|
||||||
|
|
||||||
|
for (String name : names) {
|
||||||
|
String value =
|
||||||
|
optionalParam(
|
||||||
|
serverConfig,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Missing required server config: "
|
||||||
|
+ humanName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireParam(
|
||||||
|
utils.config.AppConfig serverConfig,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
|
||||||
|
String value =
|
||||||
|
optionalParam(
|
||||||
|
serverConfig,
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Missing required server config: "
|
||||||
|
+ name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optionalParam(
|
||||||
|
utils.config.AppConfig serverConfig,
|
||||||
|
String name
|
||||||
|
) {
|
||||||
|
|
||||||
|
String value =
|
||||||
|
serverConfig.getParam(name);
|
||||||
|
|
||||||
|
return trimToNull(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(
|
||||||
|
String value
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed =
|
||||||
|
value.trim();
|
||||||
|
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package sync.model;
|
||||||
|
|
||||||
|
public record ProgramAccountUpdate(
|
||||||
|
String address,
|
||||||
|
String owner,
|
||||||
|
long lamports,
|
||||||
|
long slot,
|
||||||
|
String dataBase64,
|
||||||
|
boolean executable,
|
||||||
|
Long rentEpoch
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package sync.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SnapshotResult(
|
||||||
|
long snapshotSlot,
|
||||||
|
List<ProgramAccountUpdate> accounts
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
package sync.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sync.codec.ShineUsersCodec;
|
||||||
|
import sync.config.AppConfig;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
import sync.source.AccountUpdateListener;
|
||||||
|
import sync.source.ConnectionListener;
|
||||||
|
import sync.source.rpc.SolanaRpcClient;
|
||||||
|
import sync.source.rpc.SolanaWebSocketClient;
|
||||||
|
import sync.storage.postgres.PostgresStorageRepository;
|
||||||
|
import sync.util.SolanaPdaUtil;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public final class SolanaUsersSyncService
|
||||||
|
implements AutoCloseable {
|
||||||
|
|
||||||
|
private static final int FULL_SNAPSHOT_PROGRESS_STEP =
|
||||||
|
100;
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(
|
||||||
|
SolanaUsersSyncService.class
|
||||||
|
);
|
||||||
|
|
||||||
|
private final AppConfig config;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final PostgresStorageRepository storage;
|
||||||
|
private final SolanaRpcClient rpcClient;
|
||||||
|
private final SolanaWebSocketClient webSocketClient;
|
||||||
|
private final ExecutorService syncExecutor;
|
||||||
|
private final ScheduledExecutorService pollScheduler;
|
||||||
|
private final CompletableFuture<Void> readyFuture =
|
||||||
|
new CompletableFuture<>();
|
||||||
|
private final AtomicBoolean closed =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean syncRequested =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean syncWorkerScheduled =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
private final String economyConfigPda;
|
||||||
|
|
||||||
|
private volatile boolean initialSyncCompleted =
|
||||||
|
false;
|
||||||
|
|
||||||
|
public SolanaUsersSyncService(
|
||||||
|
AppConfig config
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
this.config =
|
||||||
|
config;
|
||||||
|
|
||||||
|
this.mapper =
|
||||||
|
new ObjectMapper();
|
||||||
|
|
||||||
|
this.storage =
|
||||||
|
new PostgresStorageRepository(
|
||||||
|
config.databaseUrl(),
|
||||||
|
config.databaseUser(),
|
||||||
|
config.databasePassword(),
|
||||||
|
mapper
|
||||||
|
);
|
||||||
|
|
||||||
|
this.rpcClient =
|
||||||
|
new SolanaRpcClient(
|
||||||
|
config.rpcUrl(),
|
||||||
|
config.programId(),
|
||||||
|
config.commitment()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.webSocketClient =
|
||||||
|
new SolanaWebSocketClient(
|
||||||
|
config.websocketUrl(),
|
||||||
|
config.programId(),
|
||||||
|
config.commitment()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.syncExecutor =
|
||||||
|
Executors.newSingleThreadExecutor(
|
||||||
|
runnable -> {
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
runnable,
|
||||||
|
"solana-users-sync-worker"
|
||||||
|
);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pollScheduler =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(
|
||||||
|
runnable -> {
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
runnable,
|
||||||
|
"solana-users-sync-periodic"
|
||||||
|
);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.economyConfigPda =
|
||||||
|
SolanaPdaUtil.findProgramAddress(
|
||||||
|
List.of(
|
||||||
|
"shine_users_economy_config"
|
||||||
|
.getBytes(StandardCharsets.UTF_8)
|
||||||
|
),
|
||||||
|
config.programId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Starting sync service. programId={} economyConfigPda={} pollInterval={}",
|
||||||
|
config.programId(),
|
||||||
|
economyConfigPda,
|
||||||
|
config.pollInterval()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLifecycleState(
|
||||||
|
"STARTING",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
webSocketClient.start(
|
||||||
|
new AccountUpdateListener() {
|
||||||
|
@Override
|
||||||
|
public void onAccountUpdate(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
) {
|
||||||
|
log.debug(
|
||||||
|
"Realtime notification received. address={} slot={}",
|
||||||
|
update.address(),
|
||||||
|
update.slot()
|
||||||
|
);
|
||||||
|
requestSync(
|
||||||
|
"realtime"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new ConnectionListener() {
|
||||||
|
@Override
|
||||||
|
public void onConnected(
|
||||||
|
boolean firstConnection
|
||||||
|
) {
|
||||||
|
log.info(
|
||||||
|
"Solana websocket connected. firstConnection={}",
|
||||||
|
firstConnection
|
||||||
|
);
|
||||||
|
requestSync(
|
||||||
|
firstConnection
|
||||||
|
? "initial-connect"
|
||||||
|
: "reconnect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
) {
|
||||||
|
log.warn(
|
||||||
|
"Solana websocket disconnected: {}",
|
||||||
|
cause == null
|
||||||
|
? "unknown"
|
||||||
|
: cause.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
long periodSeconds =
|
||||||
|
config.pollInterval()
|
||||||
|
.getSeconds();
|
||||||
|
|
||||||
|
pollScheduler.scheduleWithFixedDelay(
|
||||||
|
() -> requestSync(
|
||||||
|
"periodic"
|
||||||
|
),
|
||||||
|
periodSeconds,
|
||||||
|
periodSeconds,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void awaitReady()
|
||||||
|
throws Exception {
|
||||||
|
readyFuture.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReady() {
|
||||||
|
return readyFuture.isDone()
|
||||||
|
&& !readyFuture.isCompletedExceptionally();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestSync(
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncRequested.set(true);
|
||||||
|
|
||||||
|
if (!syncWorkerScheduled.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExecutor.submit(
|
||||||
|
() -> runSyncLoop(reason)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runSyncLoop(
|
||||||
|
String firstReason
|
||||||
|
) {
|
||||||
|
|
||||||
|
String reason =
|
||||||
|
firstReason;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
while (!closed.get()) {
|
||||||
|
|
||||||
|
boolean shouldRun =
|
||||||
|
syncRequested.getAndSet(false);
|
||||||
|
|
||||||
|
if (!shouldRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performSync(reason);
|
||||||
|
reason = "coalesced";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"Sync loop failed",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.updateLifecycleState(
|
||||||
|
"FAILED",
|
||||||
|
false,
|
||||||
|
exception.getMessage(),
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} catch (Exception storageException) {
|
||||||
|
log.error(
|
||||||
|
"Failed to persist sync failure state",
|
||||||
|
storageException
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
readyFuture.completeExceptionally(
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
|
||||||
|
syncWorkerScheduled.set(false);
|
||||||
|
|
||||||
|
if (syncRequested.get()
|
||||||
|
&& !closed.get()
|
||||||
|
&& syncWorkerScheduled.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
syncExecutor.submit(
|
||||||
|
() -> runSyncLoop("rescheduled")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performSync(
|
||||||
|
String reason
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
long nowMs =
|
||||||
|
System.currentTimeMillis();
|
||||||
|
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot state =
|
||||||
|
storage.loadState();
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Starting history sync. reason={} lastSeenSignature={}",
|
||||||
|
reason,
|
||||||
|
state.lastSeenSignature()
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.updateLifecycleState(
|
||||||
|
initialSyncCompleted
|
||||||
|
? "SYNCING"
|
||||||
|
: "BOOTSTRAPPING",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
nowMs,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
SolanaRpcClient.SignatureFetchResult fetchResult =
|
||||||
|
rpcClient.getSignaturesForAddressSince(
|
||||||
|
economyConfigPda,
|
||||||
|
state.lastSeenSignature()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.lastSeenSignature() != null
|
||||||
|
&& !fetchResult.anchorFound()) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"History anchor signature not found anymore: {}. Running current-state full snapshot fallback.",
|
||||||
|
state.lastSeenSignature()
|
||||||
|
);
|
||||||
|
|
||||||
|
runFullSnapshotFallback(
|
||||||
|
state,
|
||||||
|
fetchResult,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
markReadyAfterSync(
|
||||||
|
state,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchResult.signatures().isEmpty()) {
|
||||||
|
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot newState =
|
||||||
|
new PostgresStorageRepository.SyncStateSnapshot(
|
||||||
|
"READY",
|
||||||
|
true,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
state.lastSeenSignature(),
|
||||||
|
state.lastSeenSlot(),
|
||||||
|
state.lastRelevantSignature(),
|
||||||
|
state.lastRelevantSlot(),
|
||||||
|
null,
|
||||||
|
state.economyConfigState(),
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.applyHistoryBatch(
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
newState
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"History sync completed with no new transactions."
|
||||||
|
);
|
||||||
|
|
||||||
|
markReadyAfterSync(
|
||||||
|
newState,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SolanaRpcClient.SignatureRecord> chronologicalSignatures =
|
||||||
|
new ArrayList<>(
|
||||||
|
fetchResult.signatures()
|
||||||
|
);
|
||||||
|
|
||||||
|
Collections.reverse(
|
||||||
|
chronologicalSignatures
|
||||||
|
);
|
||||||
|
|
||||||
|
List<ParsedTxEnvelope> envelopes =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
Set<String> updatePdaAddresses =
|
||||||
|
new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) {
|
||||||
|
|
||||||
|
JsonNode transaction =
|
||||||
|
rpcClient.getTransactionJsonParsed(
|
||||||
|
signatureRecord.signature()
|
||||||
|
);
|
||||||
|
|
||||||
|
ParsedTxEnvelope envelope =
|
||||||
|
parseTransactionEnvelope(
|
||||||
|
signatureRecord,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
envelopes.add(
|
||||||
|
envelope
|
||||||
|
);
|
||||||
|
|
||||||
|
if (envelope.parsedInstruction() != null
|
||||||
|
&& envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA
|
||||||
|
&& envelope.parsedInstruction().affectedPdaAddress() != null) {
|
||||||
|
updatePdaAddresses.add(
|
||||||
|
envelope.parsedInstruction()
|
||||||
|
.affectedPdaAddress()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, ShineUsersCodec.UserPdaSnapshot> currentSnapshots =
|
||||||
|
storage.getCurrentSnapshots(
|
||||||
|
updatePdaAddresses
|
||||||
|
);
|
||||||
|
|
||||||
|
List<PostgresStorageRepository.TxHistoryEntry> txEntries =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
List<ShineUsersCodec.UserPdaSnapshot> snapshotsToPersist =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
ShineUsersCodec.EconomyConfigState economyState =
|
||||||
|
state.economyConfigState();
|
||||||
|
|
||||||
|
String lastRelevantSignature =
|
||||||
|
state.lastRelevantSignature();
|
||||||
|
|
||||||
|
Long lastRelevantSlot =
|
||||||
|
state.lastRelevantSlot();
|
||||||
|
|
||||||
|
for (ParsedTxEnvelope envelope : envelopes) {
|
||||||
|
|
||||||
|
ShineUsersCodec.ParsedInstruction parsedInstruction =
|
||||||
|
envelope.parsedInstruction();
|
||||||
|
|
||||||
|
String txKind =
|
||||||
|
parsedInstruction == null
|
||||||
|
? "failed_or_unavailable"
|
||||||
|
: parsedInstruction.kind().name();
|
||||||
|
|
||||||
|
boolean relevant =
|
||||||
|
false;
|
||||||
|
|
||||||
|
String affectedPdaAddress =
|
||||||
|
null;
|
||||||
|
|
||||||
|
String affectedLogin =
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (parsedInstruction != null) {
|
||||||
|
|
||||||
|
if (parsedInstruction.kind() == ShineUsersCodec.TxKind.INIT_USERS_ECONOMY_CONFIG
|
||||||
|
|| parsedInstruction.kind() == ShineUsersCodec.TxKind.UPDATE_USERS_ECONOMY_CONFIG) {
|
||||||
|
economyState =
|
||||||
|
parsedInstruction.economyConfigState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedInstruction.relevant()
|
||||||
|
&& parsedInstruction.userPdaMutation() != null) {
|
||||||
|
|
||||||
|
relevant = true;
|
||||||
|
affectedPdaAddress = parsedInstruction.affectedPdaAddress();
|
||||||
|
affectedLogin = parsedInstruction.affectedLogin();
|
||||||
|
|
||||||
|
ShineUsersCodec.UserPdaSnapshot snapshot;
|
||||||
|
|
||||||
|
if (parsedInstruction.kind() == ShineUsersCodec.TxKind.CREATE_USER_PDA) {
|
||||||
|
|
||||||
|
if (economyState == null) {
|
||||||
|
economyState =
|
||||||
|
ShineUsersCodec.EconomyConfigState.initial();
|
||||||
|
log.warn(
|
||||||
|
"Economy config state was absent while processing create tx {}. Falling back to initial constants.",
|
||||||
|
envelope.signatureRecord().signature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot =
|
||||||
|
ShineUsersCodec.buildCreateSnapshot(
|
||||||
|
parsedInstruction.userPdaMutation(),
|
||||||
|
economyState,
|
||||||
|
envelope.signatureRecord().signature(),
|
||||||
|
envelope.signatureRecord().slot()
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
ShineUsersCodec.UserPdaSnapshot previous =
|
||||||
|
currentSnapshots.get(
|
||||||
|
affectedPdaAddress
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previous == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Missing previous snapshot for update PDA " +
|
||||||
|
affectedPdaAddress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot =
|
||||||
|
ShineUsersCodec.buildUpdateSnapshot(
|
||||||
|
parsedInstruction.userPdaMutation(),
|
||||||
|
previous,
|
||||||
|
envelope.signatureRecord().signature(),
|
||||||
|
envelope.signatureRecord().slot()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSnapshots.put(
|
||||||
|
snapshot.pdaAddress(),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshotsToPersist.add(
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
lastRelevantSignature =
|
||||||
|
envelope.signatureRecord().signature();
|
||||||
|
|
||||||
|
lastRelevantSlot =
|
||||||
|
envelope.signatureRecord().slot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
txEntries.add(
|
||||||
|
new PostgresStorageRepository.TxHistoryEntry(
|
||||||
|
envelope.signatureRecord().signature(),
|
||||||
|
envelope.signatureRecord().slot(),
|
||||||
|
envelope.signatureRecord().blockTime(),
|
||||||
|
txKind,
|
||||||
|
relevant,
|
||||||
|
affectedPdaAddress,
|
||||||
|
affectedLogin,
|
||||||
|
envelope.rawTransactionJson(),
|
||||||
|
nowMs
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SolanaRpcClient.SignatureRecord newestSeen =
|
||||||
|
fetchResult.signatures()
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot newState =
|
||||||
|
new PostgresStorageRepository.SyncStateSnapshot(
|
||||||
|
"READY",
|
||||||
|
true,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
newestSeen.signature(),
|
||||||
|
newestSeen.slot(),
|
||||||
|
lastRelevantSignature,
|
||||||
|
lastRelevantSlot,
|
||||||
|
null,
|
||||||
|
economyState,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.applyHistoryBatch(
|
||||||
|
txEntries,
|
||||||
|
snapshotsToPersist,
|
||||||
|
newState
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"History sync completed. txCount={} relevantCount={} latestSignature={}",
|
||||||
|
txEntries.size(),
|
||||||
|
snapshotsToPersist.size(),
|
||||||
|
newestSeen.signature()
|
||||||
|
);
|
||||||
|
|
||||||
|
markReadyAfterSync(
|
||||||
|
newState,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runFullSnapshotFallback(
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot state,
|
||||||
|
SolanaRpcClient.SignatureFetchResult fetchResult,
|
||||||
|
long nowMs
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
"Starting full snapshot fallback because incremental history anchor is unavailable."
|
||||||
|
);
|
||||||
|
|
||||||
|
SnapshotResult snapshotResult =
|
||||||
|
rpcClient.loadFullSnapshot();
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Full snapshot downloaded. snapshotSlot={} rawAccounts={}",
|
||||||
|
snapshotResult.snapshotSlot(),
|
||||||
|
snapshotResult.accounts().size()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<ShineUsersCodec.UserPdaSnapshot> currentSnapshots =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
int processedAccounts =
|
||||||
|
0;
|
||||||
|
|
||||||
|
for (ProgramAccountUpdate account : snapshotResult.accounts()) {
|
||||||
|
|
||||||
|
processedAccounts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentSnapshots.add(
|
||||||
|
ShineUsersCodec.parseUserPdaAccount(
|
||||||
|
account.address(),
|
||||||
|
account.slot(),
|
||||||
|
account.dataBase64(),
|
||||||
|
state.lastSeenSignature()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedAccounts == 1
|
||||||
|
|| processedAccounts % FULL_SNAPSHOT_PROGRESS_STEP == 0
|
||||||
|
|| processedAccounts == snapshotResult.accounts().size()) {
|
||||||
|
log.info(
|
||||||
|
"Full snapshot parse progress: {}/{} accounts, {} user PDA snapshots accepted.",
|
||||||
|
processedAccounts,
|
||||||
|
snapshotResult.accounts().size(),
|
||||||
|
currentSnapshots.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String newestSignature =
|
||||||
|
fetchResult.signatures().isEmpty()
|
||||||
|
? state.lastSeenSignature()
|
||||||
|
: fetchResult.signatures().get(0).signature();
|
||||||
|
|
||||||
|
Long newestSlot =
|
||||||
|
fetchResult.signatures().isEmpty()
|
||||||
|
? state.lastSeenSlot()
|
||||||
|
: fetchResult.signatures().get(0).slot();
|
||||||
|
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot newState =
|
||||||
|
new PostgresStorageRepository.SyncStateSnapshot(
|
||||||
|
"READY",
|
||||||
|
true,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
newestSignature,
|
||||||
|
newestSlot,
|
||||||
|
state.lastRelevantSignature(),
|
||||||
|
state.lastRelevantSlot(),
|
||||||
|
"history_anchor_missing_full_snapshot_fallback",
|
||||||
|
state.economyConfigState(),
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.replaceCurrentFromFullSnapshot(
|
||||||
|
currentSnapshots,
|
||||||
|
newState
|
||||||
|
);
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
"Full snapshot fallback completed. currentSnapshots={} newestSignature={} newestSlot={}",
|
||||||
|
currentSnapshots.size(),
|
||||||
|
newestSignature,
|
||||||
|
newestSlot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markReadyAfterSync(
|
||||||
|
PostgresStorageRepository.SyncStateSnapshot state,
|
||||||
|
long nowMs
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
initialSyncCompleted = true;
|
||||||
|
|
||||||
|
if (!readyFuture.isDone()) {
|
||||||
|
readyFuture.complete(null);
|
||||||
|
log.info(
|
||||||
|
"Sync service entered READY state."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.updateLifecycleState(
|
||||||
|
"READY",
|
||||||
|
true,
|
||||||
|
state.lastError(),
|
||||||
|
nowMs,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParsedTxEnvelope parseTransactionEnvelope(
|
||||||
|
SolanaRpcClient.SignatureRecord signatureRecord,
|
||||||
|
JsonNode transaction
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (signatureRecord.failed()) {
|
||||||
|
return new ParsedTxEnvelope(
|
||||||
|
signatureRecord,
|
||||||
|
new ShineUsersCodec.ParsedInstruction(
|
||||||
|
ShineUsersCodec.TxKind.OTHER,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
),
|
||||||
|
transaction == null
|
||||||
|
? "{\"failed\":true,\"error\":" +
|
||||||
|
String.valueOf(signatureRecord.errorJson()) + "}"
|
||||||
|
: transaction.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction == null
|
||||||
|
|| transaction.isNull()) {
|
||||||
|
return new ParsedTxEnvelope(
|
||||||
|
signatureRecord,
|
||||||
|
null,
|
||||||
|
"{\"transaction\":null}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode instructions =
|
||||||
|
transaction.path("transaction")
|
||||||
|
.path("message")
|
||||||
|
.path("instructions");
|
||||||
|
|
||||||
|
if (instructions.isArray()) {
|
||||||
|
for (JsonNode instruction : instructions) {
|
||||||
|
ShineUsersCodec.ParsedInstruction parsedInstruction =
|
||||||
|
ShineUsersCodec.parseShineUsersInstruction(
|
||||||
|
instruction,
|
||||||
|
config.programId()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsedInstruction != null) {
|
||||||
|
return new ParsedTxEnvelope(
|
||||||
|
signatureRecord,
|
||||||
|
parsedInstruction,
|
||||||
|
transaction.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedTxEnvelope(
|
||||||
|
signatureRecord,
|
||||||
|
new ShineUsersCodec.ParsedInstruction(
|
||||||
|
ShineUsersCodec.TxKind.OTHER,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
),
|
||||||
|
transaction.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
if (!closed.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
pollScheduler.shutdownNow();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
webSocketClient.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncExecutor.shutdownNow();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
rpcClient.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParsedTxEnvelope(
|
||||||
|
SolanaRpcClient.SignatureRecord signatureRecord,
|
||||||
|
ShineUsersCodec.ParsedInstruction parsedInstruction,
|
||||||
|
String rawTransactionJson
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package sync.source;
|
||||||
|
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AccountUpdateListener {
|
||||||
|
|
||||||
|
void onAccountUpdate(
|
||||||
|
ProgramAccountUpdate update
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package sync.source;
|
||||||
|
|
||||||
|
public interface ConnectionListener {
|
||||||
|
|
||||||
|
void onConnected(
|
||||||
|
boolean firstConnection
|
||||||
|
);
|
||||||
|
|
||||||
|
void onDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
package sync.source.rpc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import okhttp3.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.model.SnapshotResult;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public final class SolanaRpcClient
|
||||||
|
implements AutoCloseable {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(
|
||||||
|
SolanaRpcClient.class
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final MediaType JSON =
|
||||||
|
MediaType.get("application/json");
|
||||||
|
|
||||||
|
private static final int SIGNATURE_PAGE_SIZE =
|
||||||
|
1000;
|
||||||
|
|
||||||
|
private static final int ACCOUNT_BATCH_SIZE =
|
||||||
|
100;
|
||||||
|
|
||||||
|
private final String rpcUrl;
|
||||||
|
private final String programId;
|
||||||
|
private final String commitment;
|
||||||
|
private final ObjectMapper mapper =
|
||||||
|
new ObjectMapper();
|
||||||
|
private final OkHttpClient httpClient =
|
||||||
|
new OkHttpClient.Builder()
|
||||||
|
.callTimeout(Duration.ofMinutes(2))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public SolanaRpcClient(
|
||||||
|
String rpcUrl,
|
||||||
|
String programId,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
this.rpcUrl = rpcUrl;
|
||||||
|
this.programId = programId;
|
||||||
|
this.commitment = commitment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SnapshotResult loadFullSnapshot()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Requesting full snapshot via getProgramAccounts. programId={}",
|
||||||
|
programId
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 100,
|
||||||
|
"method", "getProgramAccounts",
|
||||||
|
"params", List.of(
|
||||||
|
programId,
|
||||||
|
Map.of(
|
||||||
|
"encoding", "base64",
|
||||||
|
"commitment", commitment,
|
||||||
|
"withContext", true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Full snapshot RPC response received. Parsing account list..."
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root.path("result");
|
||||||
|
|
||||||
|
long snapshotSlot =
|
||||||
|
result.path("context")
|
||||||
|
.path("slot")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (snapshotSlot < 0) {
|
||||||
|
throw new IOException(
|
||||||
|
"Missing context.slot in getProgramAccounts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
result.path("value");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getProgramAccounts response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ProgramAccountUpdate> accounts =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
for (JsonNode item : values) {
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
item.path("account");
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.add(
|
||||||
|
parseAccount(
|
||||||
|
item.path("pubkey")
|
||||||
|
.asText(),
|
||||||
|
account,
|
||||||
|
snapshotSlot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Full snapshot RPC parsed. accounts={} snapshotSlot={}",
|
||||||
|
accounts.size(),
|
||||||
|
snapshotSlot
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SnapshotResult(
|
||||||
|
snapshotSlot,
|
||||||
|
accounts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SignatureFetchResult getSignaturesForAddressSince(
|
||||||
|
String address,
|
||||||
|
String knownSignature
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
List<SignatureRecord> signatures =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
String before =
|
||||||
|
null;
|
||||||
|
|
||||||
|
boolean anchorFound =
|
||||||
|
knownSignature == null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
|
||||||
|
Map<String, Object> options =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
|
options.put(
|
||||||
|
"limit",
|
||||||
|
SIGNATURE_PAGE_SIZE
|
||||||
|
);
|
||||||
|
|
||||||
|
options.put(
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
);
|
||||||
|
|
||||||
|
if (before != null) {
|
||||||
|
options.put(
|
||||||
|
"before",
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 103,
|
||||||
|
"method", "getSignaturesForAddress",
|
||||||
|
"params", List.of(
|
||||||
|
address,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
root.path("result");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getSignaturesForAddress response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode item : values) {
|
||||||
|
|
||||||
|
String signature =
|
||||||
|
item.path("signature")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
if (signature.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownSignature != null
|
||||||
|
&& knownSignature.equals(signature)) {
|
||||||
|
anchorFound = true;
|
||||||
|
return new SignatureFetchResult(
|
||||||
|
signatures,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long blockTime =
|
||||||
|
item.hasNonNull("blockTime")
|
||||||
|
? item.get("blockTime").asLong()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
signatures.add(
|
||||||
|
new SignatureRecord(
|
||||||
|
signature,
|
||||||
|
item.path("slot")
|
||||||
|
.asLong(-1),
|
||||||
|
blockTime,
|
||||||
|
item.hasNonNull("err"),
|
||||||
|
item.path("err").toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode last =
|
||||||
|
values.get(values.size() - 1);
|
||||||
|
|
||||||
|
before =
|
||||||
|
last.path("signature")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
if (before.isBlank()
|
||||||
|
|| values.size() < SIGNATURE_PAGE_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SignatureFetchResult(
|
||||||
|
signatures,
|
||||||
|
anchorFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode getTransactionJsonParsed(
|
||||||
|
String signature
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 104,
|
||||||
|
"method", "getTransaction",
|
||||||
|
"params", List.of(
|
||||||
|
signature,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"jsonParsed",
|
||||||
|
"commitment",
|
||||||
|
commitment,
|
||||||
|
"maxSupportedTransactionVersion",
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root.get("result");
|
||||||
|
|
||||||
|
if (result == null
|
||||||
|
|| result.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCurrentSlot()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 101,
|
||||||
|
"method", "getSlot",
|
||||||
|
"params", List.of(
|
||||||
|
Map.of(
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
long slot =
|
||||||
|
root.path("result")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (slot < 0) {
|
||||||
|
throw new IOException(
|
||||||
|
"Invalid getSlot response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountBatchResult getCurrentAccounts(
|
||||||
|
Collection<String> addresses,
|
||||||
|
long recoverySlot
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
List<ProgramAccountUpdate> updates =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
List<String> missingAddresses =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
List<String> addressList =
|
||||||
|
new ArrayList<>(addresses);
|
||||||
|
|
||||||
|
for (int offset = 0;
|
||||||
|
offset < addressList.size();
|
||||||
|
offset += ACCOUNT_BATCH_SIZE) {
|
||||||
|
|
||||||
|
int end =
|
||||||
|
Math.min(
|
||||||
|
offset + ACCOUNT_BATCH_SIZE,
|
||||||
|
addressList.size()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> batch =
|
||||||
|
addressList.subList(
|
||||||
|
offset,
|
||||||
|
end
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 105,
|
||||||
|
"method", "getMultipleAccounts",
|
||||||
|
"params", List.of(
|
||||||
|
batch,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"base64",
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
root.path("result")
|
||||||
|
.path("value");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getMultipleAccounts response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.size() != batch.size()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getMultipleAccounts account count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < batch.size(); i++) {
|
||||||
|
|
||||||
|
String address =
|
||||||
|
batch.get(i);
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
values.get(i);
|
||||||
|
|
||||||
|
if (account == null
|
||||||
|
|| account.isNull()) {
|
||||||
|
missingAddresses.add(address);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String owner =
|
||||||
|
account.path("owner")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
if (!programId.equals(owner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected account.data format for "
|
||||||
|
+ address
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.add(
|
||||||
|
parseAccount(
|
||||||
|
address,
|
||||||
|
account,
|
||||||
|
recoverySlot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AccountBatchResult(
|
||||||
|
updates,
|
||||||
|
missingAddresses
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFirstAvailableBlock()
|
||||||
|
throws IOException {
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 102,
|
||||||
|
"method", "getFirstAvailableBlock"
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
return root.path("result")
|
||||||
|
.asLong(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SignatureInfo> getSignaturesAfterSlot(
|
||||||
|
long fromSlot,
|
||||||
|
long targetSlot
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
List<SignatureInfo> signatures =
|
||||||
|
new ArrayList<>();
|
||||||
|
|
||||||
|
String before =
|
||||||
|
null;
|
||||||
|
|
||||||
|
boolean lowerBoundaryReached =
|
||||||
|
false;
|
||||||
|
|
||||||
|
while (!lowerBoundaryReached) {
|
||||||
|
|
||||||
|
Map<String, Object> options =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
|
options.put("limit", SIGNATURE_PAGE_SIZE);
|
||||||
|
options.put("commitment", commitment);
|
||||||
|
|
||||||
|
if (before != null) {
|
||||||
|
options.put("before", before);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc", "2.0",
|
||||||
|
"id", 106,
|
||||||
|
"method", "getSignaturesForAddress",
|
||||||
|
"params", List.of(
|
||||||
|
programId,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
executeRpc(payload);
|
||||||
|
|
||||||
|
JsonNode values =
|
||||||
|
root.path("result");
|
||||||
|
|
||||||
|
if (!values.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Unexpected getSignaturesForAddress response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode item : values) {
|
||||||
|
|
||||||
|
long slot =
|
||||||
|
item.path("slot")
|
||||||
|
.asLong(-1);
|
||||||
|
|
||||||
|
if (slot < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot <= fromSlot) {
|
||||||
|
lowerBoundaryReached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot > targetSlot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String signature =
|
||||||
|
item.path("signature")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
if (signature.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode error =
|
||||||
|
item.get("err");
|
||||||
|
|
||||||
|
if (error != null
|
||||||
|
&& !error.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
signatures.add(
|
||||||
|
new SignatureInfo(
|
||||||
|
signature,
|
||||||
|
slot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode last =
|
||||||
|
values.get(values.size() - 1);
|
||||||
|
|
||||||
|
before =
|
||||||
|
last.path("signature")
|
||||||
|
.asText("");
|
||||||
|
|
||||||
|
if (before.isBlank()
|
||||||
|
|| values.size() < SIGNATURE_PAGE_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> getTouchedAddresses(
|
||||||
|
List<SignatureInfo> signatures
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Set<String> addresses =
|
||||||
|
new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (SignatureInfo signatureInfo : signatures) {
|
||||||
|
|
||||||
|
JsonNode transaction =
|
||||||
|
getTransactionJsonParsed(
|
||||||
|
signatureInfo.signature()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transaction == null
|
||||||
|
|| transaction.isNull()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Transaction unavailable during recovery: "
|
||||||
|
+ signatureInfo.signature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode accountKeys =
|
||||||
|
transaction.path("transaction")
|
||||||
|
.path("message")
|
||||||
|
.path("accountKeys");
|
||||||
|
|
||||||
|
if (!accountKeys.isArray()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Missing accountKeys for transaction: "
|
||||||
|
+ signatureInfo.signature()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode keyNode : accountKeys) {
|
||||||
|
|
||||||
|
String pubkey;
|
||||||
|
|
||||||
|
if (keyNode.isTextual()) {
|
||||||
|
pubkey = keyNode.asText();
|
||||||
|
} else {
|
||||||
|
pubkey = keyNode.path("pubkey")
|
||||||
|
.asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pubkey.isBlank()) {
|
||||||
|
addresses.add(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses.remove(programId);
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgramAccountUpdate parseAccount(
|
||||||
|
String address,
|
||||||
|
JsonNode account,
|
||||||
|
long slot
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
return new ProgramAccountUpdate(
|
||||||
|
address,
|
||||||
|
account.path("owner")
|
||||||
|
.asText(),
|
||||||
|
account.path("lamports")
|
||||||
|
.asLong(),
|
||||||
|
slot,
|
||||||
|
data.get(0)
|
||||||
|
.asText(),
|
||||||
|
account.path("executable")
|
||||||
|
.asBoolean(false),
|
||||||
|
account.hasNonNull("rentEpoch")
|
||||||
|
? account.get("rentEpoch").asLong()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode executeRpc(
|
||||||
|
Map<String, Object> payload
|
||||||
|
) throws IOException {
|
||||||
|
|
||||||
|
Request request =
|
||||||
|
new Request.Builder()
|
||||||
|
.url(rpcUrl)
|
||||||
|
.post(
|
||||||
|
RequestBody.create(
|
||||||
|
mapper.writeValueAsBytes(payload),
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response =
|
||||||
|
httpClient.newCall(request).execute()) {
|
||||||
|
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC HTTP error: " + response.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseBody body =
|
||||||
|
response.body();
|
||||||
|
|
||||||
|
if (body == null) {
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC returned empty body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
mapper.readTree(
|
||||||
|
body.string()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (root.has("error")) {
|
||||||
|
throw new IOException(
|
||||||
|
"Solana RPC error: " + root.get("error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
httpClient.dispatcher()
|
||||||
|
.executorService()
|
||||||
|
.shutdown();
|
||||||
|
httpClient.connectionPool()
|
||||||
|
.evictAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignatureRecord(
|
||||||
|
String signature,
|
||||||
|
long slot,
|
||||||
|
Long blockTime,
|
||||||
|
boolean failed,
|
||||||
|
String errorJson
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignatureFetchResult(
|
||||||
|
List<SignatureRecord> signatures,
|
||||||
|
boolean anchorFound
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AccountBatchResult(
|
||||||
|
List<ProgramAccountUpdate> updates,
|
||||||
|
List<String> missingAddresses
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignatureInfo(
|
||||||
|
String signature,
|
||||||
|
long slot
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
package sync.source.rpc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import okhttp3.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sync.model.ProgramAccountUpdate;
|
||||||
|
import sync.source.AccountUpdateListener;
|
||||||
|
import sync.source.ConnectionListener;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
public final class SolanaWebSocketClient
|
||||||
|
extends WebSocketListener
|
||||||
|
implements AutoCloseable {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(
|
||||||
|
SolanaWebSocketClient.class
|
||||||
|
);
|
||||||
|
|
||||||
|
private final String websocketUrl;
|
||||||
|
private final String programId;
|
||||||
|
private final String commitment;
|
||||||
|
|
||||||
|
private final ObjectMapper mapper =
|
||||||
|
new ObjectMapper();
|
||||||
|
|
||||||
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
|
private final ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
private final AtomicBoolean closed =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private final AtomicInteger reconnectAttempt =
|
||||||
|
new AtomicInteger(0);
|
||||||
|
|
||||||
|
private final AtomicBoolean everSubscribed =
|
||||||
|
new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private volatile WebSocket webSocket;
|
||||||
|
|
||||||
|
private volatile AccountUpdateListener
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
private volatile ConnectionListener
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
public SolanaWebSocketClient(
|
||||||
|
String websocketUrl,
|
||||||
|
String programId,
|
||||||
|
String commitment
|
||||||
|
) {
|
||||||
|
this.websocketUrl = websocketUrl;
|
||||||
|
this.programId = programId;
|
||||||
|
this.commitment = commitment;
|
||||||
|
|
||||||
|
this.httpClient =
|
||||||
|
new OkHttpClient.Builder()
|
||||||
|
.readTimeout(
|
||||||
|
Duration.ZERO
|
||||||
|
)
|
||||||
|
.pingInterval(
|
||||||
|
Duration.ofSeconds(20)
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.scheduler =
|
||||||
|
Executors
|
||||||
|
.newSingleThreadScheduledExecutor(
|
||||||
|
runnable -> {
|
||||||
|
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
runnable,
|
||||||
|
"solana-rpc-reconnect"
|
||||||
|
);
|
||||||
|
|
||||||
|
thread.setDaemon(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(
|
||||||
|
AccountUpdateListener accountUpdateListener,
|
||||||
|
ConnectionListener connectionListener
|
||||||
|
) {
|
||||||
|
|
||||||
|
this.accountUpdateListener =
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
this.connectionListener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connect() {
|
||||||
|
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Connecting to Solana WebSocket: {}",
|
||||||
|
websocketUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
Request request =
|
||||||
|
new Request.Builder()
|
||||||
|
.url(
|
||||||
|
websocketUrl
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.webSocket =
|
||||||
|
httpClient.newWebSocket(
|
||||||
|
request,
|
||||||
|
this
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(
|
||||||
|
WebSocket webSocket,
|
||||||
|
Response response
|
||||||
|
) {
|
||||||
|
|
||||||
|
reconnectAttempt.set(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"WebSocket connected"
|
||||||
|
);
|
||||||
|
|
||||||
|
sendProgramSubscribe(
|
||||||
|
webSocket
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendProgramSubscribe(
|
||||||
|
WebSocket webSocket
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
Map<String, Object> request =
|
||||||
|
Map.of(
|
||||||
|
"jsonrpc",
|
||||||
|
"2.0",
|
||||||
|
"id",
|
||||||
|
1,
|
||||||
|
"method",
|
||||||
|
"programSubscribe",
|
||||||
|
"params",
|
||||||
|
List.of(
|
||||||
|
programId,
|
||||||
|
Map.of(
|
||||||
|
"encoding",
|
||||||
|
"base64",
|
||||||
|
"commitment",
|
||||||
|
commitment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
String payload =
|
||||||
|
mapper.writeValueAsString(
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!webSocket.send(
|
||||||
|
payload
|
||||||
|
)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"WebSocket rejected subscription request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Subscription request sent for program: {}",
|
||||||
|
programId
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"Failed to send subscription request",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
|
||||||
|
webSocket.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(
|
||||||
|
WebSocket webSocket,
|
||||||
|
String text
|
||||||
|
) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
JsonNode root =
|
||||||
|
mapper.readTree(
|
||||||
|
text
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
root.has("id")
|
||||||
|
&& root.has("result")
|
||||||
|
&& root.get("id").asInt() == 1
|
||||||
|
) {
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Subscribed successfully. subscriptionId={}",
|
||||||
|
root.get("result").asText()
|
||||||
|
);
|
||||||
|
|
||||||
|
boolean firstConnection =
|
||||||
|
everSubscribed
|
||||||
|
.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
ConnectionListener listener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onConnected(
|
||||||
|
firstConnection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.has("error")) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"Solana websocket RPC error: {}",
|
||||||
|
root.get("error")
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"programNotification"
|
||||||
|
.equals(
|
||||||
|
root
|
||||||
|
.path("method")
|
||||||
|
.asText()
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgramAccountUpdate update =
|
||||||
|
parseProgramNotification(
|
||||||
|
root
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"Account update. address={} slot={} base64Chars={}",
|
||||||
|
update.address(),
|
||||||
|
update.slot(),
|
||||||
|
update.dataBase64().length()
|
||||||
|
);
|
||||||
|
|
||||||
|
AccountUpdateListener listener =
|
||||||
|
accountUpdateListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onAccountUpdate(
|
||||||
|
update
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception exception) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"Failed to process WebSocket message. raw={}",
|
||||||
|
text,
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgramAccountUpdate parseProgramNotification(
|
||||||
|
JsonNode root
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode result =
|
||||||
|
root
|
||||||
|
.path("params")
|
||||||
|
.path("result");
|
||||||
|
|
||||||
|
JsonNode context =
|
||||||
|
result.path("context");
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
result.path("value");
|
||||||
|
|
||||||
|
JsonNode account =
|
||||||
|
value.path("account");
|
||||||
|
|
||||||
|
JsonNode data =
|
||||||
|
account.path("data");
|
||||||
|
|
||||||
|
if (!data.isArray()
|
||||||
|
|| data.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unexpected account.data format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProgramAccountUpdate(
|
||||||
|
requiredText(
|
||||||
|
value,
|
||||||
|
"pubkey"
|
||||||
|
),
|
||||||
|
requiredText(
|
||||||
|
account,
|
||||||
|
"owner"
|
||||||
|
),
|
||||||
|
requiredLong(
|
||||||
|
account,
|
||||||
|
"lamports"
|
||||||
|
),
|
||||||
|
requiredLong(
|
||||||
|
context,
|
||||||
|
"slot"
|
||||||
|
),
|
||||||
|
data.get(0).asText(),
|
||||||
|
account.path(
|
||||||
|
"executable"
|
||||||
|
).asBoolean(false),
|
||||||
|
account.hasNonNull(
|
||||||
|
"rentEpoch"
|
||||||
|
)
|
||||||
|
? account
|
||||||
|
.get("rentEpoch")
|
||||||
|
.asLong()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(
|
||||||
|
JsonNode node,
|
||||||
|
String field
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
node.get(field);
|
||||||
|
|
||||||
|
if (value == null
|
||||||
|
|| value.isNull()
|
||||||
|
|| value.asText().isBlank()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Missing field: "
|
||||||
|
+ field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long requiredLong(
|
||||||
|
JsonNode node,
|
||||||
|
String field
|
||||||
|
) {
|
||||||
|
|
||||||
|
JsonNode value =
|
||||||
|
node.get(field);
|
||||||
|
|
||||||
|
if (value == null
|
||||||
|
|| !value.isNumber()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Missing numeric field: "
|
||||||
|
+ field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosed(
|
||||||
|
WebSocket webSocket,
|
||||||
|
int code,
|
||||||
|
String reason
|
||||||
|
) {
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
"WebSocket closed. code={} reason={}",
|
||||||
|
code,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyDisconnected(
|
||||||
|
new IllegalStateException(
|
||||||
|
"WebSocket closed: "
|
||||||
|
+ reason
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(
|
||||||
|
WebSocket webSocket,
|
||||||
|
Throwable throwable,
|
||||||
|
Response response
|
||||||
|
) {
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
"WebSocket failure",
|
||||||
|
throwable
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
log.error(
|
||||||
|
"WebSocket HTTP status: {}",
|
||||||
|
response.code()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyDisconnected(
|
||||||
|
throwable
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyDisconnected(
|
||||||
|
Throwable cause
|
||||||
|
) {
|
||||||
|
|
||||||
|
ConnectionListener listener =
|
||||||
|
connectionListener;
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
listener.onDisconnected(
|
||||||
|
cause
|
||||||
|
);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
log.error(
|
||||||
|
"Connection listener failed",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleReconnect() {
|
||||||
|
|
||||||
|
if (closed.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int attempt =
|
||||||
|
reconnectAttempt
|
||||||
|
.incrementAndGet();
|
||||||
|
|
||||||
|
long delaySeconds =
|
||||||
|
switch (
|
||||||
|
Math.min(
|
||||||
|
attempt,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
case 1 -> 1;
|
||||||
|
case 2 -> 2;
|
||||||
|
case 3 -> 5;
|
||||||
|
case 4 -> 10;
|
||||||
|
default -> 30;
|
||||||
|
};
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
"Reconnect scheduled in {} seconds (attempt {})",
|
||||||
|
delaySeconds,
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
|
||||||
|
scheduler.schedule(
|
||||||
|
this::connect,
|
||||||
|
delaySeconds,
|
||||||
|
TimeUnit.SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
if (!closed.compareAndSet(
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocket socket =
|
||||||
|
this.webSocket;
|
||||||
|
|
||||||
|
if (socket != null) {
|
||||||
|
socket.close(
|
||||||
|
1000,
|
||||||
|
"Application shutdown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.dispatcher()
|
||||||
|
.executorService()
|
||||||
|
.shutdown();
|
||||||
|
|
||||||
|
httpClient
|
||||||
|
.connectionPool()
|
||||||
|
.evictAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package sync.util;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public final class Base58Util {
|
||||||
|
|
||||||
|
private static final char[] ALPHABET =
|
||||||
|
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||||
|
.toCharArray();
|
||||||
|
|
||||||
|
private static final int[] INDEXES =
|
||||||
|
new int[128];
|
||||||
|
|
||||||
|
static {
|
||||||
|
Arrays.fill(
|
||||||
|
INDEXES,
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < ALPHABET.length; i++) {
|
||||||
|
INDEXES[ALPHABET[i]] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Base58Util() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String encode(
|
||||||
|
byte[] input
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (input == null
|
||||||
|
|| input.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] copy =
|
||||||
|
Arrays.copyOf(
|
||||||
|
input,
|
||||||
|
input.length
|
||||||
|
);
|
||||||
|
|
||||||
|
int zeros =
|
||||||
|
0;
|
||||||
|
|
||||||
|
while (zeros < copy.length
|
||||||
|
&& copy[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded =
|
||||||
|
new byte[copy.length * 2];
|
||||||
|
|
||||||
|
int outputStart =
|
||||||
|
encoded.length;
|
||||||
|
|
||||||
|
int inputStart =
|
||||||
|
zeros;
|
||||||
|
|
||||||
|
while (inputStart < copy.length) {
|
||||||
|
|
||||||
|
int remainder =
|
||||||
|
divMod58(
|
||||||
|
copy,
|
||||||
|
inputStart
|
||||||
|
);
|
||||||
|
|
||||||
|
if (copy[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded[--outputStart] =
|
||||||
|
(byte) ALPHABET[remainder];
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputStart < encoded.length
|
||||||
|
&& encoded[outputStart] == ALPHABET[0]) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (--zeros >= 0) {
|
||||||
|
encoded[--outputStart] =
|
||||||
|
(byte) ALPHABET[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new String(
|
||||||
|
encoded,
|
||||||
|
outputStart,
|
||||||
|
encoded.length - outputStart,
|
||||||
|
StandardCharsets.US_ASCII
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] decode(
|
||||||
|
String input
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (input == null
|
||||||
|
|| input.isBlank()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
char[] chars =
|
||||||
|
input.trim()
|
||||||
|
.toCharArray();
|
||||||
|
|
||||||
|
byte[] input58 =
|
||||||
|
new byte[chars.length];
|
||||||
|
|
||||||
|
for (int i = 0; i < chars.length; i++) {
|
||||||
|
|
||||||
|
char c = chars[i];
|
||||||
|
|
||||||
|
if (c >= INDEXES.length
|
||||||
|
|| INDEXES[c] < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Invalid Base58 character: " + c
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
input58[i] =
|
||||||
|
(byte) INDEXES[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
int zeros =
|
||||||
|
0;
|
||||||
|
|
||||||
|
while (zeros < input58.length
|
||||||
|
&& input58[zeros] == 0) {
|
||||||
|
zeros++;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decoded =
|
||||||
|
new byte[chars.length];
|
||||||
|
|
||||||
|
int outputStart =
|
||||||
|
decoded.length;
|
||||||
|
|
||||||
|
int inputStart =
|
||||||
|
zeros;
|
||||||
|
|
||||||
|
while (inputStart < input58.length) {
|
||||||
|
|
||||||
|
int remainder =
|
||||||
|
divMod256(
|
||||||
|
input58,
|
||||||
|
inputStart
|
||||||
|
);
|
||||||
|
|
||||||
|
if (input58[inputStart] == 0) {
|
||||||
|
inputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded[--outputStart] =
|
||||||
|
(byte) remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (outputStart < decoded.length
|
||||||
|
&& decoded[outputStart] == 0) {
|
||||||
|
outputStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arrays.copyOfRange(
|
||||||
|
decoded,
|
||||||
|
outputStart - zeros,
|
||||||
|
decoded.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 int divMod256(
|
||||||
|
byte[] number58,
|
||||||
|
int startAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
int remainder =
|
||||||
|
0;
|
||||||
|
|
||||||
|
for (int i = startAt; i < number58.length; i++) {
|
||||||
|
|
||||||
|
int digit58 =
|
||||||
|
number58[i] & 0xFF;
|
||||||
|
|
||||||
|
int temp =
|
||||||
|
remainder * 58
|
||||||
|
+ digit58;
|
||||||
|
|
||||||
|
number58[i] =
|
||||||
|
(byte) (temp / 256);
|
||||||
|
|
||||||
|
remainder =
|
||||||
|
temp % 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sync.util;
|
||||||
|
|
||||||
|
import org.bouncycastle.jcajce.provider.digest.SHA256;
|
||||||
|
import org.bouncycastle.math.ec.rfc8032.Ed25519;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class SolanaPdaUtil {
|
||||||
|
|
||||||
|
private static final byte[] PROGRAM_DERIVED_ADDRESS_DOMAIN =
|
||||||
|
"ProgramDerivedAddress"
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
private SolanaPdaUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String findProgramAddress(
|
||||||
|
List<byte[]> seeds,
|
||||||
|
String programIdBase58
|
||||||
|
) {
|
||||||
|
|
||||||
|
byte[] programId =
|
||||||
|
Base58Util.decode(
|
||||||
|
programIdBase58
|
||||||
|
);
|
||||||
|
|
||||||
|
if (programId.length != 32) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Program id must decode to 32 bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int bump = 255; bump >= 0; bump--) {
|
||||||
|
|
||||||
|
List<byte[]> attemptSeeds =
|
||||||
|
new ArrayList<>(
|
||||||
|
seeds
|
||||||
|
);
|
||||||
|
|
||||||
|
attemptSeeds.add(
|
||||||
|
new byte[]{(byte) bump}
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] address =
|
||||||
|
createProgramAddress(
|
||||||
|
attemptSeeds,
|
||||||
|
programId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOnCurve(address)) {
|
||||||
|
return Base58Util.encode(
|
||||||
|
address
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unable to find viable program address"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] createProgramAddress(
|
||||||
|
List<byte[]> seeds,
|
||||||
|
byte[] programId
|
||||||
|
) {
|
||||||
|
|
||||||
|
SHA256.Digest digest =
|
||||||
|
new SHA256.Digest();
|
||||||
|
|
||||||
|
for (byte[] seed : seeds) {
|
||||||
|
digest.update(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
digest.update(programId);
|
||||||
|
digest.update(PROGRAM_DERIVED_ADDRESS_DOMAIN);
|
||||||
|
|
||||||
|
return digest.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOnCurve(
|
||||||
|
byte[] publicKey
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (publicKey.length != 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ed25519.validatePublicKeyFull(
|
||||||
|
publicKey,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import sync.service.SolanaUsersSyncService;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
public final class SolanaUsersSyncStartupService {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(
|
||||||
|
SolanaUsersSyncStartupService.class
|
||||||
|
);
|
||||||
|
|
||||||
|
private static volatile SolanaUsersSyncService service;
|
||||||
|
|
||||||
|
private SolanaUsersSyncStartupService() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized void startOrThrow()
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
if (service != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig serverConfig =
|
||||||
|
AppConfig.getInstance();
|
||||||
|
|
||||||
|
if (!sync.config.AppConfig.isEnabled(serverConfig)) {
|
||||||
|
log.info(
|
||||||
|
"Solana users sync is disabled. Param {} is false or absent.",
|
||||||
|
sync.config.AppConfig.ENABLED_KEY
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sync.config.AppConfig syncConfig =
|
||||||
|
sync.config.AppConfig.fromServerConfig(
|
||||||
|
serverConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
service =
|
||||||
|
new SolanaUsersSyncService(
|
||||||
|
syncConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
Runtime.getRuntime()
|
||||||
|
.addShutdownHook(
|
||||||
|
new Thread(
|
||||||
|
SolanaUsersSyncStartupService::closeQuietly,
|
||||||
|
"solana-users-sync-shutdown"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Starting Solana users sync before remaining server startup..."
|
||||||
|
);
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
service.awaitReady();
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Solana users sync is READY. Server startup may continue."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized void closeQuietly() {
|
||||||
|
|
||||||
|
if (service == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service.close();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
log.warn(
|
||||||
|
"Failed to close Solana users sync service cleanly",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
service = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import server.debug.DebugApiConfigurator;
|
import server.debug.DebugApiConfigurator;
|
||||||
import server.sync.BlockchainResyncRecoveryOnStartup;
|
import server.sync.BlockchainResyncRecoveryOnStartup;
|
||||||
import server.sync.PeriodicBlockchainSyncService;
|
import server.sync.PeriodicBlockchainSyncService;
|
||||||
|
import server.sync.SolanaUsersSyncStartupService;
|
||||||
import server.sync.SyncServersBootstrapService;
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
@@ -49,6 +50,11 @@ public final class WsServer {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1.0) Синхронизация пользовательских Solana PDA
|
||||||
|
// ============================================================
|
||||||
|
SolanaUsersSyncStartupService.startOrThrow();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 1) Настройки порта
|
// 1) Настройки порта
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ db.path=data/shine.sqlite
|
|||||||
server.SHiNE.login=shineupme
|
server.SHiNE.login=shineupme
|
||||||
solana.cluster=mainnet-beta
|
solana.cluster=mainnet-beta
|
||||||
solana.rpcUrl=https://api.mainnet-beta.solana.com
|
solana.rpcUrl=https://api.mainnet-beta.solana.com
|
||||||
|
solana.users.sync.enabled=false
|
||||||
|
solana.users.sync.rpcUrl=
|
||||||
|
solana.users.sync.wsUrl=
|
||||||
|
solana.users.sync.programId=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
|
||||||
|
solana.users.sync.databaseUrl=
|
||||||
|
solana.users.sync.dbUser=
|
||||||
|
solana.users.sync.dbPassword=
|
||||||
|
solana.users.sync.pollIntervalSeconds=300
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Межсерверная синхронизация: как создавать локальную запись пользователя,
|
# Межсерверная синхронизация: как создавать локальную запись пользователя,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
This file lists third-party components and assets used by SHiNE. It is a practical attribution file for repository publication; individual dependency artifacts may include additional license text in their own packages.
|
||||||
|
|
||||||
|
## Twemoji
|
||||||
|
|
||||||
|
SHiNE UI uses Twemoji graphics for emoji rendering in the chat emoji picker and emoji-only messages.
|
||||||
|
|
||||||
|
- Source: https://github.com/twitter/twemoji
|
||||||
|
- Graphics license: Creative Commons Attribution 4.0 International (CC BY 4.0)
|
||||||
|
- Code license: MIT
|
||||||
|
- License text: https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|
||||||
|
Attribution:
|
||||||
|
|
||||||
|
Emoji graphics by Twemoji, licensed under CC BY 4.0.
|
||||||
|
|
||||||
|
Changes in SHiNE:
|
||||||
|
|
||||||
|
- Emoji are stored and sent as normal Unicode text.
|
||||||
|
- Twemoji SVG graphics are used only as a visual rendering layer in the web UI.
|
||||||
|
- The UI loads Twemoji SVG assets from the pinned `twitter/twemoji@14.0.2` package via jsDelivr.
|
||||||
|
|
||||||
|
## QR Code Generator
|
||||||
|
|
||||||
|
SHiNE UI includes `shine-UI/js/vendor-qrcode-generator.js`.
|
||||||
|
|
||||||
|
- Project: QR Code Generator for JavaScript
|
||||||
|
- Copyright: Kazuhiko Arase
|
||||||
|
- License: MIT
|
||||||
|
- Source: http://www.d-project.com/
|
||||||
|
|
||||||
|
## SHiNE project-owned visual assets
|
||||||
|
|
||||||
|
SHiNE logos, icons and generated visual assets in `shine-UI/assets/` and `shine-UI/img/` are project-owned assets, unless a specific file says otherwise.
|
||||||
|
|
||||||
|
## Solana JavaScript libraries
|
||||||
|
|
||||||
|
SHiNE UI and Solana tooling use Solana JavaScript libraries, including `@solana/web3.js`.
|
||||||
|
|
||||||
|
- Package: `@solana/web3.js`
|
||||||
|
- License: MIT
|
||||||
|
- Source: https://github.com/solana-foundation/solana-web3.js
|
||||||
|
|
||||||
|
Some Solana JavaScript dependency trees include additional packages under permissive licenses such as MIT, Apache-2.0, BSD, ISC, 0BSD and CC0-1.0.
|
||||||
|
|
||||||
|
Known stricter dependency:
|
||||||
|
|
||||||
|
- Package: `rpc-websockets`
|
||||||
|
- License: LGPL-3.0-only
|
||||||
|
- Used transitively through Solana JavaScript dependencies in `shine-solana/shine` and `SHiNE-browser-plugin-wallet`.
|
||||||
|
|
||||||
|
## Noble cryptography libraries
|
||||||
|
|
||||||
|
SHiNE browser wallet/vendor bundles include Noble cryptography code.
|
||||||
|
|
||||||
|
- Packages: `@noble/curves`, `@noble/hashes`
|
||||||
|
- License: MIT
|
||||||
|
- Source: https://github.com/paulmillr/noble-curves and https://github.com/paulmillr/noble-hashes
|
||||||
|
|
||||||
|
## Java server dependencies
|
||||||
|
|
||||||
|
The SHiNE Java server uses third-party dependencies from Maven Central, including:
|
||||||
|
|
||||||
|
- Eclipse Jetty (`org.eclipse.jetty:*`) - EPL-2.0 / Apache-2.0 family licensing
|
||||||
|
- Bouncy Castle (`org.bouncycastle:bcprov-jdk18on`) - Bouncy Castle permissive license
|
||||||
|
- Jackson (`com.fasterxml.jackson.core:jackson-databind`) - Apache-2.0
|
||||||
|
- Logback (`ch.qos.logback:logback-classic`) - EPL-1.0 / LGPL-2.1
|
||||||
|
- SLF4J (`org.slf4j:slf4j-api`) - MIT
|
||||||
|
- SQLite JDBC (`org.xerial:sqlite-jdbc`) - Apache-2.0
|
||||||
|
- Web Push Java library (`nl.martijndwars:web-push`) - Apache-2.0
|
||||||
|
- JUnit (`org.junit:*`) - EPL-2.0
|
||||||
|
|
||||||
|
## Gradle Wrapper
|
||||||
|
|
||||||
|
The repository includes Gradle wrapper scripts.
|
||||||
|
|
||||||
|
- License: Apache License 2.0
|
||||||
|
- Source: https://gradle.org/
|
||||||
|
|
||||||
|
## Espressif code snippets
|
||||||
|
|
||||||
|
The ESP32 prototype area includes Espressif audio codec helper files with SPDX/license headers.
|
||||||
|
|
||||||
|
- Files include `ESPRESSIF MIT License` and `SPDX-License-Identifier: Apache-2.0` notices.
|
||||||
|
- Original copyright notices are preserved in the source files.
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||||
|
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - подготовить перевод серверной БД с `SQLite` на `PostgreSQL` для более серьёзной конкурентной нагрузки и дальнейшего масштабирования.
|
||||||
|
|
||||||
### dao_запуск
|
### dao_запуск
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
# ESP32 как аппаратный кошелёк (device-сессия)
|
|
||||||
|
|
||||||
## Суть фичи
|
|
||||||
|
|
||||||
ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения.
|
|
||||||
|
|
||||||
## Два ключевых сценария
|
|
||||||
|
|
||||||
### Сценарий 1 — Создание делегированной сессии
|
|
||||||
1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X»
|
|
||||||
2. Сервер → ESP32 (device-сессия): «запрос на одобрение»
|
|
||||||
3. Пользователь нажимает «Да» на сенсорном экране ESP32
|
|
||||||
4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера
|
|
||||||
|
|
||||||
### Сценарий 2 — Подпись транзакции / блока
|
|
||||||
1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это»
|
|
||||||
2. ESP32 показывает запрос на экране, пользователь подтверждает
|
|
||||||
3. ESP32 подписывает нужным ключом → ответ через сервер → браузер
|
|
||||||
|
|
||||||
## Что нужно сделать
|
|
||||||
|
|
||||||
### ESP32 (основная работа)
|
|
||||||
- [ ] Инициализация WiFi (SSID/пароль в NVS)
|
|
||||||
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
|
|
||||||
- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS
|
|
||||||
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
|
|
||||||
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
|
|
||||||
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)
|
|
||||||
- [ ] Переподключение при разрыве (reconnect loop)
|
|
||||||
|
|
||||||
### Сервер (минимальные изменения)
|
|
||||||
- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions`
|
|
||||||
- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии
|
|
||||||
- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено)
|
|
||||||
- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ
|
|
||||||
- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда
|
|
||||||
|
|
||||||
### Клиент (отдельный этап)
|
|
||||||
- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest`
|
|
||||||
|
|
||||||
## Что уже готово (переиспользуем)
|
|
||||||
|
|
||||||
- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него.
|
|
||||||
- **Ed25519 на ESP32** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
|
|
||||||
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
|
|
||||||
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
|
|
||||||
- **Аутентификация** — схема `AuthChallenge` → `CreateAuthSession` через Ed25519 уже полностью реализована.
|
|
||||||
|
|
||||||
## Оценка сложности
|
|
||||||
|
|
||||||
| Компонент | Сложность |
|
|
||||||
|---|---|
|
|
||||||
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
|
|
||||||
| ESP32: обработчик входящих + UI подтверждений | Средняя |
|
|
||||||
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
|
|
||||||
| Браузерное расширение | Высокая (отдельный этап) |
|
|
||||||
|
|
||||||
**Итого фазы ESP32 + сервер: ~1–1.5 недели.**
|
|
||||||
|
|
||||||
## С чего начинать
|
|
||||||
|
|
||||||
1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`.
|
|
||||||
2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI.
|
|
||||||
3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают.
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Переход с SQLite на PostgreSQL
|
||||||
|
|
||||||
|
## Зачем
|
||||||
|
|
||||||
|
Текущая серверная база на `SQLite` удобна для простого односерверного режима, но она хуже подходит для большого числа параллельных записей, роста нагрузки и дальнейшего масштабирования сервера.
|
||||||
|
|
||||||
|
`PostgreSQL` нужен как следующий уровень серверной БД для более надёжной конкурентной записи, более предсказуемой работы под нагрузкой и дальнейшего роста проекта.
|
||||||
|
|
||||||
|
## Что сделать
|
||||||
|
|
||||||
|
- Подготовить план переноса серверной БД с `SQLite` на `PostgreSQL`.
|
||||||
|
- Найти все места, где код завязан на особенности `SQLite`.
|
||||||
|
- Проверить все DAO и SQL-запросы на совместимость с `PostgreSQL`.
|
||||||
|
- Продумать схему миграции существующей production/test базы без потери данных.
|
||||||
|
- Отдельно проверить транзакции, `UPSERT`, индексы, case-insensitive сравнения и миграции схемы.
|
||||||
|
- После этого подготовить отдельный этап внедрения и переключения сервера.
|
||||||
|
|
||||||
|
## Что уже есть в коде
|
||||||
|
|
||||||
|
- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту.
|
||||||
|
- Основная серверная логика уже разделена по модулям.
|
||||||
|
- Но SQL и миграции сейчас написаны под `SQLite` и потребуют отдельного прохода.
|
||||||
|
|
||||||
|
## Откуда продолжать
|
||||||
|
|
||||||
|
- Начать с инвентаризации всех DAO и схемы БД.
|
||||||
|
- После этого сделать отдельный документ с оценкой объёма работ по переносу.
|
||||||
|
- Затем решить, будет ли это:
|
||||||
|
- полный перевод сервера на `PostgreSQL`;
|
||||||
|
- или поддержка двух драйверов на переходный период.
|
||||||
|
|
||||||
|
## Что потом обновить
|
||||||
|
|
||||||
|
- Серверную документацию по БД и миграциям.
|
||||||
|
- Инструкции по локальному запуску сервера.
|
||||||
|
- Скрипты деплоя и настройки окружения.
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# TODO: Будущие доработки
|
|
||||||
|
|
||||||
## 1) Полный переход на `ReceiveOutcomingMessage`
|
|
||||||
- Сейчас в UI используется `ReceiveOutcomingMessage` с fallback на `SendMessagePair`.
|
|
||||||
- Fallback нужен только временно для совместимости со старыми серверами.
|
|
||||||
- После обновления всех серверов:
|
|
||||||
- убрать вызов `SendMessagePair` из UI,
|
|
||||||
- убрать регистрацию `SendMessagePair` на сервере (оставить только `ReceiveOutcomingMessage`).
|
|
||||||
|
|
||||||
## 2) Реальная мультисерверная доставка
|
|
||||||
- Сейчас фактически предполагается 1 сервер на пользователя.
|
|
||||||
- Нужно реализовать штатную мультисерверную схему:
|
|
||||||
- пересылка исходящих сообщений между серверами пользователя A,
|
|
||||||
- пересылка входящих сообщений между серверами пользователя B,
|
|
||||||
- дедупликация на уровне БД для затухания дублей.
|
|
||||||
|
|
||||||
## 3) Надёжная доставка при перезапуске сервера
|
|
||||||
- Сейчас возможен сценарий: запись уже сохранена в БД, но сервер не успел переслать дальше из-за перезапуска.
|
|
||||||
- Нужно добавить механизм «store + guaranteed forward»:
|
|
||||||
- очередь/аутбокс для межсерверной пересылки,
|
|
||||||
- фоновый ретрай до подтверждения отправки,
|
|
||||||
- корректная остановка (graceful shutdown) с дожатием критичных задач.
|
|
||||||
|
|
||||||
## 4) Политика идемпотентности
|
|
||||||
- Сохранить принцип: пара (`incoming`, `outgoing`) пишется одной транзакцией, либо обе, либо ни одной.
|
|
||||||
- Не допускать частичного состояния, при котором в БД есть только один блок пары.
|
|
||||||
|
|
||||||
## 5) Наблюдаемость и аналитика
|
|
||||||
- Добавить метрики по доставке:
|
|
||||||
- количество дублей,
|
|
||||||
- количество успешных вставок пар,
|
|
||||||
- доля доставок в WS/push,
|
|
||||||
- количество ретраев межсерверной пересылки.
|
|
||||||
|
|
||||||
## 6) Ограничение текущих звонков (важно)
|
|
||||||
- Сейчас звонки работают только в рамках одного сигнального сервера (или единого контура, где обе стороны уже подключены).
|
|
||||||
- Сценарий «пользователь A на своих серверах, пользователь B на других серверах» пока не поддержан.
|
|
||||||
- TODO на будущее:
|
|
||||||
- временная межсерверная авторизация/сессия для старта звонка,
|
|
||||||
- отправка сигнальных сообщений между разными серверами пользователей,
|
|
||||||
- аккуратное завершение временной сессии после установления/завершения звонка.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# TODO: Звонки и межсерверность
|
|
||||||
|
|
||||||
## Текущее ограничение
|
|
||||||
- Текущая реализация звонков фактически работает в одном сигнальном контуре (один сервер/единый кластер, где обе стороны уже присутствуют).
|
|
||||||
- Если пользователь A подключён к серверу A, а пользователь B к серверу B (и между ними нет общего сигнального слоя), `CallInviteBroadcast`/`CallSignalToSession` не смогут полноценно провести звонок между ними.
|
|
||||||
|
|
||||||
## Почему так сейчас
|
|
||||||
- Сигналинг звонка привязан к активным сессиям и событиям на конкретном сервере.
|
|
||||||
- Выбор целевой сессии (`sessionId`) и обмен `OFFER/ANSWER/ICE` происходит в рамках текущего сигнального контура.
|
|
||||||
- Push решает только «разбудить/уведомить», но не заменяет межсерверный сигнальный канал.
|
|
||||||
|
|
||||||
## Что можно сделать дальше
|
|
||||||
- Добавить временное межсерверное подключение именно для старта и ведения звонка:
|
|
||||||
- инициатор получает short-lived access на сервер callee (или через доверенный межсерверный gateway),
|
|
||||||
- в рамках короткой сессии отправляет invite/signal для конкретного `callId`,
|
|
||||||
- после завершения звонка временная сессия закрывается автоматически.
|
|
||||||
|
|
||||||
## Что нужно доработать для этого
|
|
||||||
1. Межсерверная доверенная модель:
|
|
||||||
- подпись/верификация межсерверных вызовов,
|
|
||||||
- allowlist доверенных серверов и ротация ключей.
|
|
||||||
|
|
||||||
2. Короткоживущая «call-only» авторизация:
|
|
||||||
- отдельный тип токена/сессии с TTL (например 1–3 минуты),
|
|
||||||
- минимальные права только на `CallInviteBroadcast/CallSignalToSession`.
|
|
||||||
|
|
||||||
3. Маршрутизация сессий пользователя между серверами:
|
|
||||||
- где находится активная сессия callee,
|
|
||||||
- как доставлять `stop_call` и terminal-сигналы на все устройства callee.
|
|
||||||
|
|
||||||
4. Идемпотентность и дедупликация:
|
|
||||||
- защита от повторов межсерверных сигналов по `callId + eventId`,
|
|
||||||
- корректная обработка out-of-order событий.
|
|
||||||
|
|
||||||
5. Наблюдаемость:
|
|
||||||
- метрики межсерверной доставки сигналов,
|
|
||||||
- диагностика по стадиям звонка и причинам срыва.
|
|
||||||
|
|
||||||
## Временный рабочий подход (до межсерверности)
|
|
||||||
- Держать звонки в одном сигнальном контуре.
|
|
||||||
- Использовать WebPush как fallback-уведомление (`incoming_call`/`stop_call`) для офлайн-сессий.
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.331
|
client.version=1.2.354
|
||||||
server.version=1.2.303
|
server.version=1.2.319
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response
|
implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response
|
||||||
implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов
|
implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов
|
||||||
|
implementation project(':shine-server-solana-users-sync')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.env
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
run/
|
|
||||||
__pycache__/
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# 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 его не пропустит.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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/<username>/`.
|
|
||||||
- Архив истории после `/new`: `data/history/<username>/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|all>` — удалить задачу по id или очистить очередь.
|
|
||||||
- `/restart` и `/restart_service` — отложенный рестарт после текущей задачи.
|
|
||||||
- `/restart_hard`, `/restart_now`, `/restart_force` — жёсткий рестарт прямо сейчас.
|
|
||||||
|
|
||||||
## Правила ответа
|
|
||||||
- Пиши содержательно и коротко.
|
|
||||||
- Не упоминай внутренние служебные детали, файловую систему и технические логи, если это не нужно пользователю.
|
|
||||||
- Если запрос требует действий с кодом или файлами, выполняй их в рабочей директории `CODEX_WORKDIR`.
|
|
||||||
- Если данных недостаточно, задай ровно один уточняющий вопрос.
|
|
||||||
- Если в промпте есть пометка retry, учитывай текущее состояние и продолжай аккуратно, а не начинай заново без причины.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
GITHUB_USER="ai5590"
|
|
||||||
TOKEN_VAR_NAME="GIT_AI5590_CLASSIC_API_KEY"
|
|
||||||
|
|
||||||
print_line() {
|
|
||||||
echo "------------------------------------------------------------"
|
|
||||||
}
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
echo
|
|
||||||
echo "Ошибка: $1" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
require_command() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || abort "Не найдена команда '$1'. Установи её и запусти скрипт снова."
|
|
||||||
}
|
|
||||||
|
|
||||||
get_token() {
|
|
||||||
if [[ -z "${GIT_AI5590_CLASSIC_API_KEY:-}" ]]; then
|
|
||||||
abort "Не задана переменная окружения ${TOKEN_VAR_NAME}.
|
|
||||||
Перед запуском выполни:
|
|
||||||
export ${TOKEN_VAR_NAME}=\"ТВОЙ_GITHUB_TOKEN\""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
show_intro() {
|
|
||||||
print_line
|
|
||||||
echo "Этот скрипт создаст новый репозиторий в GitHub в аккаунте '${GITHUB_USER}',"
|
|
||||||
echo "затем инициализирует git в текущей папке (если нужно),"
|
|
||||||
echo "добавит файлы, кроме самого этого скрипта, создаст первый commit и отправит проект в GitHub."
|
|
||||||
echo
|
|
||||||
echo "Скрипт работает с содержимым ТЕКУЩЕЙ папки:"
|
|
||||||
echo " $(pwd)"
|
|
||||||
echo
|
|
||||||
echo "Для авторизации используется переменная окружения:"
|
|
||||||
echo " ${TOKEN_VAR_NAME}"
|
|
||||||
print_line
|
|
||||||
echo
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_repo_name() {
|
|
||||||
local repo_name
|
|
||||||
read -r -p "Введите имя нового репозитория в GitHub: " repo_name
|
|
||||||
repo_name="$(echo "$repo_name" | xargs)"
|
|
||||||
|
|
||||||
[[ -n "$repo_name" ]] || abort "Имя репозитория не может быть пустым."
|
|
||||||
|
|
||||||
if [[ ! "$repo_name" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
abort "Имя репозитория содержит недопустимые символы.
|
|
||||||
Разрешены: буквы, цифры, точка, дефис, подчёркивание."
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_NAME="$repo_name"
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_visibility() {
|
|
||||||
local answer
|
|
||||||
echo
|
|
||||||
read -r -p "Сделать репозиторий публичным? [y/N]: " answer
|
|
||||||
answer="${answer:-N}"
|
|
||||||
|
|
||||||
case "$answer" in
|
|
||||||
y|Y|yes|YES|да|Да|ДА)
|
|
||||||
REPO_PRIVATE="false"
|
|
||||||
REPO_VISIBILITY_TEXT="public"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
REPO_PRIVATE="true"
|
|
||||||
REPO_VISIBILITY_TEXT="private"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_confirmation() {
|
|
||||||
echo
|
|
||||||
print_line
|
|
||||||
echo "Будет выполнено:"
|
|
||||||
echo "1. Создание GitHub-репозитория '${GITHUB_USER}/${REPO_NAME}' (${REPO_VISIBILITY_TEXT})"
|
|
||||||
echo "2. Подготовка git в текущей папке"
|
|
||||||
echo "3. Commit файлов из текущей папки, кроме самого этого скрипта"
|
|
||||||
echo "4. Push в ветку main"
|
|
||||||
print_line
|
|
||||||
echo
|
|
||||||
read -r -p "Продолжить? [y/N]: " confirm
|
|
||||||
confirm="${confirm:-N}"
|
|
||||||
|
|
||||||
case "$confirm" in
|
|
||||||
y|Y|yes|YES|да|Да|ДА) ;;
|
|
||||||
*) echo "Отменено пользователем."; exit 0 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
check_not_inside_wrong_git_repo() {
|
|
||||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
local top
|
|
||||||
top="$(git rev-parse --show-toplevel)"
|
|
||||||
if [[ "$top" != "$(pwd)" ]]; then
|
|
||||||
abort "Ты запустил скрипт внутри уже существующего git-репозитория, но не в его корне.
|
|
||||||
Корень репозитория:
|
|
||||||
$top
|
|
||||||
|
|
||||||
Либо перейди в корень этого репозитория, либо запусти скрипт в папке, которая не вложена в другой git-репозиторий."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
create_github_repo() {
|
|
||||||
echo
|
|
||||||
echo "Создаю репозиторий в GitHub..."
|
|
||||||
|
|
||||||
local http_code
|
|
||||||
local response_body_file
|
|
||||||
|
|
||||||
response_body_file="$(mktemp)"
|
|
||||||
|
|
||||||
http_code="$(
|
|
||||||
curl -sS \
|
|
||||||
-o "$response_body_file" \
|
|
||||||
-w "%{http_code}" \
|
|
||||||
-X POST "https://api.github.com/user/repos" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "Authorization: Bearer ${GIT_AI5590_CLASSIC_API_KEY}" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
-d "$(cat <<JSON
|
|
||||||
{
|
|
||||||
"name": "${REPO_NAME}",
|
|
||||||
"private": ${REPO_PRIVATE},
|
|
||||||
"auto_init": false
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
)"
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ "$http_code" != "201" ]]; then
|
|
||||||
echo
|
|
||||||
echo "GitHub API вернул ошибку. HTTP code: $http_code"
|
|
||||||
echo "Ответ сервера:"
|
|
||||||
cat "$response_body_file"
|
|
||||||
rm -f "$response_body_file"
|
|
||||||
abort "Не удалось создать репозиторий '${GITHUB_USER}/${REPO_NAME}'."
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$response_body_file"
|
|
||||||
echo "Репозиторий успешно создан: https://github.com/${GITHUB_USER}/${REPO_NAME}"
|
|
||||||
}
|
|
||||||
|
|
||||||
get_script_paths() {
|
|
||||||
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
|
|
||||||
PROJECT_PATH="$(pwd -P)"
|
|
||||||
|
|
||||||
SCRIPT_INSIDE_PROJECT="false"
|
|
||||||
SCRIPT_RELATIVE_PATH=""
|
|
||||||
|
|
||||||
case "$SCRIPT_PATH" in
|
|
||||||
"$PROJECT_PATH"/*)
|
|
||||||
SCRIPT_INSIDE_PROJECT="true"
|
|
||||||
SCRIPT_RELATIVE_PATH="${SCRIPT_PATH#$PROJECT_PATH/}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
SCRIPT_INSIDE_PROJECT="false"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
prepare_git_repo() {
|
|
||||||
echo
|
|
||||||
echo "Подготавливаю git в текущей папке..."
|
|
||||||
|
|
||||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
echo "Git уже инициализирован."
|
|
||||||
else
|
|
||||||
git init
|
|
||||||
echo "Git инициализирован."
|
|
||||||
fi
|
|
||||||
|
|
||||||
get_script_paths
|
|
||||||
|
|
||||||
if [[ "$SCRIPT_INSIDE_PROJECT" == "true" ]]; then
|
|
||||||
echo "Скрипт находится внутри проекта и будет исключён из commit:"
|
|
||||||
echo " $SCRIPT_RELATIVE_PATH"
|
|
||||||
git add . ":!$SCRIPT_RELATIVE_PATH"
|
|
||||||
else
|
|
||||||
git add .
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "В staged нет изменений. Возможно, файлы уже были закоммичены ранее."
|
|
||||||
else
|
|
||||||
git commit -m "Initial commit"
|
|
||||||
echo "Создан commit: Initial commit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git branch -M main
|
|
||||||
|
|
||||||
local remote_url="https://${GITHUB_USER}:${GIT_AI5590_CLASSIC_API_KEY}@github.com/${GITHUB_USER}/${REPO_NAME}.git"
|
|
||||||
|
|
||||||
if git remote get-url origin >/dev/null 2>&1; then
|
|
||||||
echo "Remote 'origin' уже существует. Обновляю URL..."
|
|
||||||
git remote set-url origin "$remote_url"
|
|
||||||
else
|
|
||||||
git remote add origin "$remote_url"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
push_to_github() {
|
|
||||||
echo
|
|
||||||
echo "Отправляю проект в GitHub..."
|
|
||||||
|
|
||||||
git push -u origin main
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Готово."
|
|
||||||
echo "Репозиторий: https://github.com/${GITHUB_USER}/${REPO_NAME}"
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup_remote_url() {
|
|
||||||
echo
|
|
||||||
echo "Убираю токен из remote URL, чтобы он не светился в git config..."
|
|
||||||
|
|
||||||
local safe_url="https://github.com/${GITHUB_USER}/${REPO_NAME}.git"
|
|
||||||
git remote set-url origin "$safe_url"
|
|
||||||
|
|
||||||
echo "Теперь origin = ${safe_url}"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
require_command git
|
|
||||||
require_command curl
|
|
||||||
require_command realpath
|
|
||||||
get_token
|
|
||||||
check_not_inside_wrong_git_repo
|
|
||||||
show_intro
|
|
||||||
ask_repo_name
|
|
||||||
ask_visibility
|
|
||||||
ask_confirmation
|
|
||||||
create_github_repo
|
|
||||||
prepare_git_repo
|
|
||||||
push_to_github
|
|
||||||
cleanup_remote_url
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Где находится сервис
|
## Где находится сервис
|
||||||
|
|
||||||
- Папка сервиса: `SHiNE-agent-bot-coder/`
|
- Папка сервиса находится рядом с репозиторием продукта: `../SHiNE-agent-bot-coder/`
|
||||||
- Systemd unit: `SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
|
- Systemd unit: `../SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
|
||||||
- Скрипт установки: `SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
- Скрипт установки: `../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
||||||
|
|
||||||
## Предусловия
|
## Предусловия
|
||||||
|
|
||||||
@@ -18,7 +18,13 @@
|
|||||||
Из корня репозитория:
|
Из корня репозитория:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh
|
bash ../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
В `.env` агента `CODEX_WORKDIR` должен указывать на этот репозиторий продукта:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт:
|
Скрипт:
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# PostgreSQL для серверов SHiNE
|
||||||
|
|
||||||
|
Этот документ фиксирует целевой стандарт PostgreSQL, который должен использоваться на всех серверных контурах SHiNE после ухода от SQLite.
|
||||||
|
|
||||||
|
Пока это не текущий production-state, а согласованная целевая схема, под которую можно писать миграции, deploy-скрипты и server-конфиги.
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
На каждом сервере SHiNE должна быть отдельная локальная PostgreSQL-база:
|
||||||
|
|
||||||
|
- запускается в Docker;
|
||||||
|
- использует `postgres:18`;
|
||||||
|
- хранит данные не внутри контейнера, а в примонтированной папке хоста;
|
||||||
|
- доступна только локально на сервере и из Docker-сети;
|
||||||
|
- не публикуется в интернет;
|
||||||
|
- автоматически перезапускается после reboot/crash;
|
||||||
|
- имеет healthcheck;
|
||||||
|
- использует отдельную БД приложения и отдельного пользователя приложения;
|
||||||
|
- пароли и секреты хранятся только в локальном `.env`/override-конфиге на сервере и не коммитятся в git.
|
||||||
|
|
||||||
|
## Базовый стандарт
|
||||||
|
|
||||||
|
- Версия PostgreSQL: `18.x`
|
||||||
|
- Docker image: `postgres:18`
|
||||||
|
- Имена по умолчанию:
|
||||||
|
- контейнер/сервис: `shine-postgres`
|
||||||
|
- база приложения: `shine_server_db`
|
||||||
|
- пользователь приложения: `shine_server`
|
||||||
|
- Политика перезапуска: `unless-stopped`
|
||||||
|
- Проверка готовности: `pg_isready`
|
||||||
|
|
||||||
|
## Сетевой доступ
|
||||||
|
|
||||||
|
PostgreSQL не должна быть доступна из интернета.
|
||||||
|
|
||||||
|
Разрешённые варианты:
|
||||||
|
|
||||||
|
- публиковать порт только на loopback:
|
||||||
|
- `127.0.0.1:5432:5432`
|
||||||
|
- либо не публиковать порт вообще, если клиент тоже живёт в Docker и ходит только по внутренней сети
|
||||||
|
|
||||||
|
Запрещено:
|
||||||
|
|
||||||
|
- `0.0.0.0:5432:5432`
|
||||||
|
- открытие `5432/tcp` через внешний firewall/NAT/public ingress
|
||||||
|
|
||||||
|
Рекомендация по умолчанию для SHiNE:
|
||||||
|
|
||||||
|
- использовать `127.0.0.1:5432:5432`
|
||||||
|
|
||||||
|
Это даёт:
|
||||||
|
|
||||||
|
- локальную диагностику через `psql` на самом сервере;
|
||||||
|
- отсутствие внешнего доступа из интернета;
|
||||||
|
- совместимость с приложением, если оно работает не в Docker.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
Данные PostgreSQL должны храниться в локальной папке хоста, а не во внутреннем Docker volume контейнера.
|
||||||
|
|
||||||
|
Причины:
|
||||||
|
|
||||||
|
- проще бэкапить;
|
||||||
|
- проще переносить между серверами;
|
||||||
|
- проще контролировать место хранения;
|
||||||
|
- одинаковая схема для dev/test/production;
|
||||||
|
- ниже риск потерять данные при пересоздании контейнера.
|
||||||
|
|
||||||
|
Рекомендуемая схема каталогов на сервере:
|
||||||
|
|
||||||
|
Production:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/player/SHiNE/postgres/shine_server_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Test/devnet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/player/tX/postgres/shine_server_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Если на одном сервере появится несколько баз SHiNE-сервисов, раскладывать их по отдельным каталогам:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/player/SHiNE/postgres/shine_server_db
|
||||||
|
/home/player/SHiNE/postgres/analytics_db
|
||||||
|
/home/player/SHiNE/postgres/other_service_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пользователи и права
|
||||||
|
|
||||||
|
Нужны два уровня доступа:
|
||||||
|
|
||||||
|
- системный `postgres` superuser для администрирования;
|
||||||
|
- рабочий пользователь приложения `shine_server` для подключения самого SHiNE server.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- приложение не должно работать под `postgres`;
|
||||||
|
- `shine_server` не должен быть `superuser`;
|
||||||
|
- `shine_server` должен владеть своей БД `shine_server_db`;
|
||||||
|
- для миграций по умолчанию использовать пользователя приложения, если ему хватает прав;
|
||||||
|
- административные операции выполнять отдельно под `postgres`.
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
В git нельзя хранить:
|
||||||
|
|
||||||
|
- `.env` с реальными паролями;
|
||||||
|
- connection strings с паролями;
|
||||||
|
- SQL-файлы с зашитыми production/test паролями.
|
||||||
|
|
||||||
|
Хранить на сервере локально, например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/player/SHiNE/postgres/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
или в другом root-only каталоге секретов хоста.
|
||||||
|
|
||||||
|
Минимально нужны:
|
||||||
|
|
||||||
|
- пароль `postgres`
|
||||||
|
- пароль `shine_server`
|
||||||
|
|
||||||
|
Допустимо, но не рекомендуется, временно использовать одинаковый пароль для локального dev. Для production и постоянных test-серверов лучше разные пароли.
|
||||||
|
|
||||||
|
## Пример целевого compose
|
||||||
|
|
||||||
|
Ниже пример целевой схемы, которую можно брать за основу для серверов:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
shine-postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: shine-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_SUPERUSER_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_SUPERUSER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ${SHINE_POSTGRES_DATA_DIR}:/var/lib/postgresql/data
|
||||||
|
- ./initdb:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- pg_isready -U "$${POSTGRES_SUPERUSER}" -d "$${POSTGRES_SUPERUSER_DB}"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендуемые переменные окружения
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
POSTGRES_SUPERUSER_DB=postgres
|
||||||
|
POSTGRES_SUPERUSER=postgres
|
||||||
|
POSTGRES_SUPERUSER_PASSWORD=change_me
|
||||||
|
SHINE_APP_DB=shine_server_db
|
||||||
|
SHINE_APP_USER=shine_server
|
||||||
|
SHINE_APP_PASSWORD=change_me_too
|
||||||
|
SHINE_POSTGRES_DATA_DIR=/home/player/SHiNE/postgres/shine_server_db
|
||||||
|
```
|
||||||
|
|
||||||
|
Для test/devnet путь адаптировать под конкретный контур:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SHINE_POSTGRES_DATA_DIR=/home/player/t2/postgres/shine_server_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Подключение приложения
|
||||||
|
|
||||||
|
SHiNE server должен подключаться именно к:
|
||||||
|
|
||||||
|
- host: `127.0.0.1`
|
||||||
|
- port: `5432`
|
||||||
|
- db: `shine_server_db`
|
||||||
|
- user: `shine_server`
|
||||||
|
|
||||||
|
Не подключать серверное приложение под `postgres`, кроме одноразовых административных операций вручную.
|
||||||
|
|
||||||
|
## Бэкапы
|
||||||
|
|
||||||
|
Так как данные лежат в bind-mount каталоге хоста, сама папка с данными должна попадать в серверную backup-стратегию.
|
||||||
|
|
||||||
|
Но для PostgreSQL предпочтителен не только файловый backup, а как минимум один из вариантов:
|
||||||
|
|
||||||
|
- регулярный `pg_dump`
|
||||||
|
- либо полноценный backup-скрипт с остановкой приложения/согласованным snapshot
|
||||||
|
|
||||||
|
Минимальное требование:
|
||||||
|
|
||||||
|
- перед серьёзными миграциями иметь свежий backup БД;
|
||||||
|
- перед production rollout новой серверной версии иметь проверяемый backup.
|
||||||
|
|
||||||
|
## Для будущей миграции SHiNE
|
||||||
|
|
||||||
|
При переводе SHiNE server с SQLite на PostgreSQL считать обязательным:
|
||||||
|
|
||||||
|
- сначала поднять PostgreSQL по этому стандарту;
|
||||||
|
- затем добавить серверные конфиги подключения;
|
||||||
|
- затем прогнать миграции схемы;
|
||||||
|
- только потом переключать runtime приложения на PostgreSQL.
|
||||||
|
|
||||||
|
До фактического rollout на конкретный сервер эта БД может ещё отсутствовать. Этот документ описывает не текущее наличие БД, а обязательный целевой стандарт для всех серверов SHiNE.
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
- `PRODUCTION_SERVERS.md` — production-контуры.
|
- `PRODUCTION_SERVERS.md` — production-контуры.
|
||||||
- `TEST_SERVERS.md` — test/devnet-контуры.
|
- `TEST_SERVERS.md` — test/devnet-контуры.
|
||||||
- `TURN_SERVERS.md` — TURN-серверы.
|
- `TURN_SERVERS.md` — TURN-серверы.
|
||||||
|
- `POSTGRESQL_SERVERS_STANDARD.md` — целевой стандарт PostgreSQL для всех серверов SHiNE.
|
||||||
|
- `SOLANA_USERS_SYNC_SERVER_SETUP.md` — интеграция синхронизации пользовательских Solana PDA в основной сервер.
|
||||||
- `CONFIGURE_TURN_IN_SHINE.md` — как подключить TURN к SHiNE backend.
|
- `CONFIGURE_TURN_IN_SHINE.md` — как подключить TURN к SHiNE backend.
|
||||||
- `SETUP_SERVER_FROM_ZERO.md` — настройка SHiNE-сервера и UI с нуля.
|
- `SETUP_SERVER_FROM_ZERO.md` — настройка SHiNE-сервера и UI с нуля.
|
||||||
- `SETUP_TURN_SERVER.md` — настройка TURN через Caddy/DNS/TLS.
|
- `SETUP_TURN_SERVER.md` — настройка TURN через Caddy/DNS/TLS.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ sudo apt install -y coturn caddy
|
|||||||
sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET
|
sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
|
По умолчанию этот скрипт настраивает relay-диапазон `49000-53999`.
|
||||||
|
Это стандарт SHiNE: 5000 UDP-портов под TURN relay.
|
||||||
|
|
||||||
## 3. coturn
|
## 3. coturn
|
||||||
|
|
||||||
Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override.
|
Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override.
|
||||||
@@ -44,6 +47,14 @@ no-multicast-peers
|
|||||||
no-cli
|
no-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для SHiNE держать relay-range большим сразу, чтобы не упираться в `create_relay_ioa_sockets: no available ports`.
|
||||||
|
Стандартный диапазон:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
min-port=49000
|
||||||
|
max-port=53999
|
||||||
|
```
|
||||||
|
|
||||||
Включить сервис:
|
Включить сервис:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,3 +94,4 @@ sudo ss -lntup | grep -E ':(3478|5349)'
|
|||||||
- TURN credentials и shared secret не коммитить.
|
- TURN credentials и shared secret не коммитить.
|
||||||
- Если TURN домен переносится на другой VPS, менять DNS, а не код приложения.
|
- Если TURN домен переносится на другой VPS, менять DNS, а не код приложения.
|
||||||
- После изменения TURN-адресов обновить серверные/UI настройки, где они используются.
|
- После изменения TURN-адресов обновить серверные/UI настройки, где они используются.
|
||||||
|
- Не сужать relay-range ниже 5000 UDP-портов без явной причины: для SHiNE это уже приводило к отказам звонков на проде `24 июля 2026`.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Интеграция синхронизации `shine_users` в основной сервер
|
||||||
|
|
||||||
|
Этот документ описывает, что нужно для встраивания Solana sync-модуля пользовательских PDA в основной SHiNE-server.
|
||||||
|
|
||||||
|
Основной архитектурный документ:
|
||||||
|
|
||||||
|
- [docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md](/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md)
|
||||||
|
|
||||||
|
## Что уже готово
|
||||||
|
|
||||||
|
Отдельный модуль `sync-solana` уже умеет:
|
||||||
|
|
||||||
|
- подключаться к Solana RPC и WebSocket;
|
||||||
|
- вычислять `users_economy_config_pda`;
|
||||||
|
- хранить checkpoint синхронизации в PostgreSQL;
|
||||||
|
- читать историю через `getSignaturesForAddress(users_economy_config_pda)`;
|
||||||
|
- поддерживать realtime через websocket;
|
||||||
|
- выполнять страховочный periodic poll раз в 5 минут;
|
||||||
|
- хранить:
|
||||||
|
- `solana_sync_state`
|
||||||
|
- `solana_sync_tx_history`
|
||||||
|
- `solana_user_pda_current`
|
||||||
|
- `solana_user_pda_history`
|
||||||
|
- блокировать дальнейший startup до входа в `READY`.
|
||||||
|
|
||||||
|
## Что нужно перенести в основной сервер
|
||||||
|
|
||||||
|
Из `sync-solana` в сервер нужно перенести рабочие классы:
|
||||||
|
|
||||||
|
- `sync-solana/src/main/java/sync-solana/config/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/service/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/source/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/source/rpc/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/storage/postgres/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/codec/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/model/`
|
||||||
|
- `sync-solana/src/main/java/sync-solana/util/`
|
||||||
|
|
||||||
|
`Main.java` нужен только как reference для bootstrap и как отдельный `main` в сервере уже не понадобится.
|
||||||
|
|
||||||
|
Рекомендуемый вариант:
|
||||||
|
|
||||||
|
- оформить это как отдельный Gradle submodule внутри `SHiNE-server`;
|
||||||
|
- запускать его из server startup как lifecycle-сервис.
|
||||||
|
|
||||||
|
## Порядок запуска в сервере
|
||||||
|
|
||||||
|
При старте основного сервера последовательность должна быть такой:
|
||||||
|
|
||||||
|
1. прочитать общий server config;
|
||||||
|
2. создать Solana users sync service;
|
||||||
|
3. вызвать `start()`;
|
||||||
|
4. вызвать `awaitReady()`;
|
||||||
|
5. только после этого продолжать остальной startup сервера:
|
||||||
|
- синхронизацию с другими нодами;
|
||||||
|
- запуск WS/HTTP;
|
||||||
|
- остальную серверную инициализацию.
|
||||||
|
|
||||||
|
Если Solana initial sync не дошёл до `READY`, startup сервера должен считаться неуспешным.
|
||||||
|
|
||||||
|
## Переменные окружения сервера
|
||||||
|
|
||||||
|
На сервере должны быть доступны:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOLANA_RPC_URL=
|
||||||
|
SOLANA_WS_URL=
|
||||||
|
SOLANA_PROGRAM_ID=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
|
||||||
|
SYNC_POLL_INTERVAL_SECONDS=300
|
||||||
|
```
|
||||||
|
|
||||||
|
Для PostgreSQL sync-модуль может использовать уже существующую server PostgreSQL-конфигурацию, если сервер уже предоставляет:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL=
|
||||||
|
PGUSER=
|
||||||
|
PGPASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
Если в сервере используется другая схема конфигов, нужно сделать адаптер на уровне server config, а не менять саму логику sync.
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
Sync-модуль должен писать в общие server logs через тот же `slf4j/logback`, что и основной сервер.
|
||||||
|
|
||||||
|
Минимум, который должен быть виден в логах:
|
||||||
|
|
||||||
|
- старт sync-модуля;
|
||||||
|
- вход в `READY`;
|
||||||
|
- realtime sync;
|
||||||
|
- periodic poll;
|
||||||
|
- reconnect websocket;
|
||||||
|
- fallback на full snapshot;
|
||||||
|
- ошибки RPC/WS/DB.
|
||||||
|
|
||||||
|
## Что потребуется по deploy
|
||||||
|
|
||||||
|
Отдельных deploy-скриптов для sync-модуля не требуется, если он встроен в основной server jar.
|
||||||
|
|
||||||
|
По deploy нужно:
|
||||||
|
|
||||||
|
- обновить server env/override-конфиг новыми переменными `SOLANA_*` и `SYNC_POLL_INTERVAL_SECONDS`;
|
||||||
|
- убедиться, что на сервере доступен PostgreSQL, в который модуль будет писать свои таблицы;
|
||||||
|
- при необходимости описать новые env в документации конкретного server-контура.
|
||||||
|
|
||||||
|
## Что ещё проверить после интеграции
|
||||||
|
|
||||||
|
После встраивания в основной сервер нужно отдельно проверить:
|
||||||
|
|
||||||
|
- startup сервера с ожиданием `awaitReady()`;
|
||||||
|
- создание таблиц в server PostgreSQL;
|
||||||
|
- initial sync после пустой БД;
|
||||||
|
- restart recovery после уже существующего checkpoint;
|
||||||
|
- realtime update через websocket;
|
||||||
|
- periodic poll без новых транзакций;
|
||||||
|
- fallback на full snapshot при потере history anchor.
|
||||||
@@ -17,6 +17,7 @@ TURN нужен для WebRTC-звонков, когда прямое peer-to-pe
|
|||||||
- DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN.
|
- DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN.
|
||||||
- TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки.
|
- TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки.
|
||||||
- `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`.
|
- `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`.
|
||||||
|
- Стандартный relay-range SHiNE: `49000-53999` (`5000` UDP-портов).
|
||||||
- Секреты TURN не хранить в git.
|
- Секреты TURN не хранить в git.
|
||||||
|
|
||||||
## Что проверять
|
## Что проверять
|
||||||
|
|||||||
@@ -10,6 +10,32 @@ RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
|
|||||||
|
|
||||||
mkdir -p "${DEST_DIR}"
|
mkdir -p "${DEST_DIR}"
|
||||||
|
|
||||||
|
remote_exists() {
|
||||||
|
ssh "${REMOTE_HOST}" "test -e '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_optional_dir() {
|
||||||
|
local remote_path="$1"
|
||||||
|
local local_path="$2"
|
||||||
|
if remote_exists "$remote_path"; then
|
||||||
|
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}/" "${local_path}/"
|
||||||
|
else
|
||||||
|
mkdir -p "${local_path}"
|
||||||
|
echo "missing: ${remote_path}" > "${local_path}/MISSING.txt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_optional_file() {
|
||||||
|
local remote_path="$1"
|
||||||
|
local local_path="$2"
|
||||||
|
if remote_exists "$remote_path"; then
|
||||||
|
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}"
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "${local_path}")"
|
||||||
|
echo "missing: ${remote_path}" > "${local_path}.MISSING.txt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
|
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
|
||||||
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
|
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
|
||||||
|
|
||||||
@@ -21,17 +47,21 @@ rsync -aH --delete \
|
|||||||
--exclude='**/*.log' \
|
--exclude='**/*.log' \
|
||||||
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
|
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
|
||||||
|
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/sites/" "${DEST_DIR}/home-player/sites/"
|
copy_optional_dir "/home/player/sites" "${DEST_DIR}/home-player/sites"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/"
|
copy_optional_dir "/home/player/gitea" "${DEST_DIR}/home-player/gitea"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/agent-memory/" "${DEST_DIR}/home-player/agent-memory/"
|
copy_optional_dir "/home/player/agent-memory" "${DEST_DIR}/home-player/agent-memory"
|
||||||
|
|
||||||
echo "[3/4] Копирую системные конфиги"
|
echo "[3/4] Копирую системные конфиги"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/"
|
copy_optional_dir "/etc/caddy" "${DEST_DIR}/etc-system/caddy"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/"
|
copy_optional_dir "/var/lib/caddy" "${DEST_DIR}/var-lib/caddy"
|
||||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
copy_optional_file "/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
||||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/"
|
copy_optional_file "/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/shine-server.service"
|
||||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/"
|
copy_optional_file "/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/agent-memory.service"
|
||||||
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
if ssh "${REMOTE_HOST}" 'command -v docker >/dev/null 2>&1 && sudo -n docker image inspect gitea/gitea:1.22.6 >/dev/null 2>&1'; then
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
||||||
|
else
|
||||||
|
echo "missing: docker image gitea/gitea:1.22.6" > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar.MISSING.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[4/4] Создаю манифест"
|
echo "[4/4] Создаю манифест"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
SECRET=""
|
SECRET=""
|
||||||
REALM="turn1.shineup.me"
|
REALM="turn1.shineup.me"
|
||||||
MIN_PORT="49160"
|
MIN_PORT="49000"
|
||||||
MAX_PORT="49200"
|
MAX_PORT="53999"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -23,11 +23,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--min-port)
|
--min-port)
|
||||||
MIN_PORT="${2:-49160}"
|
MIN_PORT="${2:-49000}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--max-port)
|
--max-port)
|
||||||
MAX_PORT="${2:-49200}"
|
MAX_PORT="${2:-53999}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -87,6 +87,8 @@ systemctl --no-pager --full status coturn
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "coturn настроен."
|
echo "coturn настроен."
|
||||||
|
echo "Стандартный relay-range SHiNE: ${MIN_PORT}-${MAX_PORT}."
|
||||||
|
echo "Рекомендуемый размер диапазона: не меньше 5000 UDP-портов."
|
||||||
echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp"
|
echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp"
|
||||||
echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:"
|
echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:"
|
||||||
echo " -Dcall.ice.turn.sharedSecret=${SECRET}"
|
echo " -Dcall.ice.turn.sharedSecret=${SECRET}"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||||
|
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
||||||
|
|||||||
@@ -176,7 +176,72 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. `AckSessionDelivery`
|
## 7. `GetDirectMessages`
|
||||||
|
|
||||||
|
Требует авторизации. Возвращает историю диалога с конкретным собеседником страницами.
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- начиная с 22 июля 2026 года сервер больше не высылает старую DM-историю автоматически при логине;
|
||||||
|
- после подключения клиент должен сам запросить первую страницу диалога;
|
||||||
|
- новые realtime-сообщения по-прежнему приходят событием `SignedMessageArrived`.
|
||||||
|
- в `GetDirectMessages` сервер отдаёт только обычные chat-сообщения (`type=1/2`), без read-receipt и delete/tombstone.
|
||||||
|
|
||||||
|
### Запрос
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetDirectMessages",
|
||||||
|
"requestId": "dm-history-001",
|
||||||
|
"payload": {
|
||||||
|
"peerLogin": "bob",
|
||||||
|
"limit": 50,
|
||||||
|
"beforeTimeMs": 1774700000123,
|
||||||
|
"beforeMessageKey": "alice|bob|1774700000123|123456789|1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`beforeTimeMs` и `beforeMessageKey` необязательны. Если их нет, сервер вернёт самую новую страницу.
|
||||||
|
|
||||||
|
### Успешный ответ
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetDirectMessages",
|
||||||
|
"requestId": "dm-history-001",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"login": "alice",
|
||||||
|
"peerLogin": "bob",
|
||||||
|
"limit": 50,
|
||||||
|
"hasMore": true,
|
||||||
|
"nextBeforeTimeMs": 1774699999000,
|
||||||
|
"nextBeforeMessageKey": "alice|bob|1774699999000|123456780|2",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"messageKey": "alice|bob|1774700000123|123456789|1",
|
||||||
|
"baseKey": "alice|bob|1774700000123|123456789",
|
||||||
|
"fromLogin": "alice",
|
||||||
|
"toLogin": "bob",
|
||||||
|
"messageType": 1,
|
||||||
|
"timeMs": 1774700000123,
|
||||||
|
"nonce": 123456789,
|
||||||
|
"revisionTimeMs": 0,
|
||||||
|
"reencryptedAtMs": 0,
|
||||||
|
"createdAtMs": 1774700001123,
|
||||||
|
"readAtMs": 1774700001456,
|
||||||
|
"blobB64": "BASE64_SIGNED_BLOCK"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
|
||||||
|
|
||||||
|
## 8. `AckSessionDelivery`
|
||||||
|
|
||||||
Требует авторизации. Подтверждает доставку в текущую сессию.
|
Требует авторизации. Подтверждает доставку в текущую сессию.
|
||||||
|
|
||||||
@@ -192,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. Событие `SignedMessageArrived`
|
## 9. Событие `SignedMessageArrived`
|
||||||
|
|
||||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||||
|
|
||||||
@@ -217,7 +282,7 @@
|
|||||||
|
|
||||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||||
|
|
||||||
## 9. `CallInviteBroadcast`
|
## 10. `CallInviteBroadcast`
|
||||||
|
|
||||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,35 @@ UI-следствие для клиента:
|
|||||||
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
||||||
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
|
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
|
||||||
|
|
||||||
|
### 10.5. `GetDirectMessages`
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- постранично отдавать историю одного личного диалога;
|
||||||
|
- не вываливать весь старый backlog автоматически в момент логина;
|
||||||
|
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
|
||||||
|
- клиент сам запрашивает первую страницу истории у нужного собеседника;
|
||||||
|
- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`;
|
||||||
|
- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`.
|
||||||
|
|
||||||
|
Содержимое страницы:
|
||||||
|
|
||||||
|
- сервер возвращает только контентные DM типов `1/2`;
|
||||||
|
- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются;
|
||||||
|
- сообщения идут страницами от новых к старым;
|
||||||
|
- лимит страницы считается по самим chat-сообщениям диалога.
|
||||||
|
|
||||||
|
Поле прочтения:
|
||||||
|
|
||||||
|
- для каждого контентного сообщения сервер может вернуть `readAtMs`;
|
||||||
|
- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу;
|
||||||
|
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
|
||||||
|
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
|
||||||
|
|
||||||
## 11. Межсерверная доставка
|
## 11. Межсерверная доставка
|
||||||
|
|
||||||
### 11.1. Клиентская сторона
|
### 11.1. Клиентская сторона
|
||||||
@@ -547,9 +576,12 @@ UI-следствие для клиента:
|
|||||||
В ней должны сохраняться:
|
В ней должны сохраняться:
|
||||||
|
|
||||||
- обычные контентные DM;
|
- обычные контентные DM;
|
||||||
|
- read-receipt DM;
|
||||||
- tombstone одного сообщения;
|
- tombstone одного сообщения;
|
||||||
- tombstone удаления переписки.
|
- tombstone удаления переписки.
|
||||||
|
|
||||||
|
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
|
||||||
|
|
||||||
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
|
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
|
||||||
|
|
||||||
- защищать от повторного приёма старых версий;
|
- защищать от повторного приёма старых версий;
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
# Модуль синхронизации `shine_users` из Solana
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Этот модуль нужен для локальной серверной синхронизации всех пользовательских PDA программы `shine_users`.
|
||||||
|
|
||||||
|
Цель модуля:
|
||||||
|
|
||||||
|
- при старте получить актуальное состояние всех пользовательских PDA;
|
||||||
|
- дальше держать локальную копию в актуальном состоянии;
|
||||||
|
- хранить историю просмотренных транзакций синхронизации;
|
||||||
|
- хранить историю всех версий пользовательских PDA;
|
||||||
|
- уметь после рестарта продолжать синхронизацию без потери изменений;
|
||||||
|
- в будущем без большого переписывания встраиваться в основной SHiNE-сервер и блокировать дальнейший startup до входа в состояние `READY`.
|
||||||
|
|
||||||
|
На текущем этапе модуль должен запускаться как отдельный Java-процесс со своим `main`, но внутренняя структура должна быть такой, чтобы потом его можно было перенести в сервер как обычный lifecycle-сервис.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Базовая идея
|
||||||
|
|
||||||
|
Модуль использует два источника данных:
|
||||||
|
|
||||||
|
1. realtime-поток через Solana WebSocket `programSubscribe` для программы `shine_users`;
|
||||||
|
2. историю транзакций через `getSignaturesForAddress(economy_config_pda)`.
|
||||||
|
|
||||||
|
Ключевая договорённость:
|
||||||
|
|
||||||
|
- каждая транзакция, которая создаёт или обновляет пользовательский `user_pda`, обязательно читает `users_economy_config_pda`;
|
||||||
|
- значит, история по `users_economy_config_pda` является полным журналом всех релевантных `create/update user_pda` транзакций;
|
||||||
|
- дополнительные транзакции, которые тоже читают или меняют `users_economy_config_pda`, допустимы и не мешают: модуль должен уметь распознавать, что такая транзакция не относится к изменению пользовательского PDA.
|
||||||
|
|
||||||
|
`users_economy_config_pda` вычисляется из:
|
||||||
|
|
||||||
|
- `program_id = SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||||
|
- seed = `shine_users_economy_config`
|
||||||
|
|
||||||
|
Источник в коде контракта:
|
||||||
|
|
||||||
|
- `settings::USERS_ECONOMY_CONFIG_SEED = b"shine_users_economy_config"`
|
||||||
|
- `create_user_pda` и `update_user_pda` принимают `users_economy_config_pda` как обязательный аккаунт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что считается состоянием готовности
|
||||||
|
|
||||||
|
Модуль должен иметь внутреннее состояние готовности `READY`.
|
||||||
|
|
||||||
|
В `READY` он входит только после того, как:
|
||||||
|
|
||||||
|
1. установлено websocket-подключение;
|
||||||
|
2. выполнена начальная актуализация истории;
|
||||||
|
3. локальная таблица актуальных PDA приведена в консистентное состояние;
|
||||||
|
4. таблица состояния синхронизации обновлена.
|
||||||
|
|
||||||
|
Пока модуль не вошёл в `READY`, будущий сервер при встраивании не должен продолжать собственный startup.
|
||||||
|
|
||||||
|
Для этого модуль должен поддерживать:
|
||||||
|
|
||||||
|
- `start()`
|
||||||
|
- `awaitReady()`
|
||||||
|
- `isReady()`
|
||||||
|
- `close()`
|
||||||
|
|
||||||
|
Для отдельного процесса `main` допустимо также печатать явный лог о входе в `READY`, но главным механизмом для будущего сервера должен быть Java API, а не парсинг логов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Почему нельзя опираться только на WebSocket
|
||||||
|
|
||||||
|
WebSocket `programSubscribe` хорош для realtime, но недостаточен как единственный источник истины:
|
||||||
|
|
||||||
|
- если websocket временно оборвался, часть событий может быть пропущена;
|
||||||
|
- если соединение формально живо, но какое-то событие было потеряно, это не всегда можно заметить сразу;
|
||||||
|
- если в интервале не было транзакций, websocket по определению ничего не пришлёт.
|
||||||
|
|
||||||
|
Поэтому нужен второй защитный контур:
|
||||||
|
|
||||||
|
- периодический аудит истории по `users_economy_config_pda`;
|
||||||
|
- запуск такого аудита раз в 5 минут.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Общий lifecycle модуля
|
||||||
|
|
||||||
|
### 1. Старт процесса
|
||||||
|
|
||||||
|
При старте процесса модуль:
|
||||||
|
|
||||||
|
1. читает конфиг;
|
||||||
|
2. инициализирует локальную PostgreSQL БД;
|
||||||
|
3. создаёт RPC-клиент;
|
||||||
|
4. создаёт WebSocket-клиент;
|
||||||
|
5. создаёт coordinator/service слой;
|
||||||
|
6. запускает realtime-подписку;
|
||||||
|
7. после подтверждённой подписки выполняет начальную актуализацию истории;
|
||||||
|
8. после успешной актуализации выставляет `READY`.
|
||||||
|
|
||||||
|
### 2. Работа в фоне
|
||||||
|
|
||||||
|
После входа в `READY` модуль одновременно:
|
||||||
|
|
||||||
|
- принимает realtime-обновления по websocket;
|
||||||
|
- раз в 5 минут запускает проверку истории через RPC;
|
||||||
|
- пишет журнал транзакций;
|
||||||
|
- пишет историю версий пользовательских PDA;
|
||||||
|
- обновляет актуальный снимок пользовательских PDA.
|
||||||
|
|
||||||
|
### 3. Реконнект
|
||||||
|
|
||||||
|
Если websocket оборвался:
|
||||||
|
|
||||||
|
1. запускается reconnect;
|
||||||
|
2. после успешного переподключения выполняется повторная актуализация истории;
|
||||||
|
3. модуль снова возвращается в нормальный режим.
|
||||||
|
|
||||||
|
### 4. Остановка
|
||||||
|
|
||||||
|
При остановке:
|
||||||
|
|
||||||
|
- закрывается websocket;
|
||||||
|
- останавливаются фоновые scheduler/worker потоки;
|
||||||
|
- закрывается RPC-клиент;
|
||||||
|
- закрывается БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Источник истории: только по сигнатурам
|
||||||
|
|
||||||
|
Основной checkpoint модуля должен строиться по сигнатурам транзакций, а не по времени.
|
||||||
|
|
||||||
|
Хранить нужно:
|
||||||
|
|
||||||
|
- последнюю просмотренную сигнатуру;
|
||||||
|
- последний просмотренный слот;
|
||||||
|
- последнюю релевантную сигнатуру;
|
||||||
|
- время последней успешной актуализации.
|
||||||
|
|
||||||
|
Время хранится только как вспомогательная диагностика. Продолжение истории должно идти по сигнатурам и слотам.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поведение начальной актуализации
|
||||||
|
|
||||||
|
### Сценарий A: локальная БД пустая
|
||||||
|
|
||||||
|
Если локальная БД ещё не содержит синхронизированных данных:
|
||||||
|
|
||||||
|
1. выполняется первичная загрузка пользовательских PDA;
|
||||||
|
2. после этого фиксируется начальная точка истории;
|
||||||
|
3. модуль переходит в `READY`.
|
||||||
|
|
||||||
|
Предпочтительный вариант:
|
||||||
|
|
||||||
|
- использовать историю транзакций по `users_economy_config_pda` как основной механизм синхронизации;
|
||||||
|
- full snapshot всех program accounts остаётся аварийным fallback, а не штатным путём.
|
||||||
|
|
||||||
|
### Сценарий B: локальная БД уже есть
|
||||||
|
|
||||||
|
Если БД не пуста:
|
||||||
|
|
||||||
|
1. берётся последняя обработанная сигнатура;
|
||||||
|
2. через `getSignaturesForAddress(users_economy_config_pda)` вытягивается история после неё;
|
||||||
|
3. новые транзакции разбираются и применяются;
|
||||||
|
4. после этого модуль входит в `READY`.
|
||||||
|
|
||||||
|
### Сценарий C: новых транзакций не было
|
||||||
|
|
||||||
|
Если после последней сигнатуры новых транзакций нет:
|
||||||
|
|
||||||
|
- это не ошибка;
|
||||||
|
- модуль всё равно обновляет `last_poll_at` и `last_successful_poll_at`;
|
||||||
|
- фиксирует, что актуализация успешно проверена;
|
||||||
|
- может переходить в `READY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Периодическая актуализация раз в 5 минут
|
||||||
|
|
||||||
|
Каждые 5 минут модуль должен:
|
||||||
|
|
||||||
|
1. вызвать `getSignaturesForAddress(users_economy_config_pda)`;
|
||||||
|
2. получить новые сигнатуры после последней сохранённой точки;
|
||||||
|
3. сохранить все найденные транзакции в журнал истории;
|
||||||
|
4. выделить релевантные транзакции `create/update user_pda`;
|
||||||
|
5. для релевантных транзакций извлечь адрес PDA и логин;
|
||||||
|
6. подтянуть актуальное состояние затронутых PDA;
|
||||||
|
7. обновить:
|
||||||
|
- `solana_user_pda_current`
|
||||||
|
- `solana_user_pda_history`
|
||||||
|
- `solana_sync_state`
|
||||||
|
|
||||||
|
Если новых транзакций нет:
|
||||||
|
|
||||||
|
- модуль ничего не меняет в зеркале PDA;
|
||||||
|
- но помечает, что polling выполнен успешно и состояние истории актуализировано.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делать с нерелевантными транзакциями
|
||||||
|
|
||||||
|
История должна храниться полностью, включая нерелевантные транзакции.
|
||||||
|
|
||||||
|
Примеры нерелевантных транзакций:
|
||||||
|
|
||||||
|
- `update_users_economy_config`
|
||||||
|
- служебные транзакции, где `economy_config_pda` присутствовал, но пользовательский `user_pda` не менялся
|
||||||
|
|
||||||
|
Такие транзакции:
|
||||||
|
|
||||||
|
- сохраняются в `solana_sync_tx_history`;
|
||||||
|
- помечаются `is_relevant = 0`;
|
||||||
|
- не приводят к обновлению пользовательских PDA.
|
||||||
|
|
||||||
|
Это важно для аудита и отладки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Как распознавать тип транзакции
|
||||||
|
|
||||||
|
Для каждой транзакции из истории нужно определить её тип.
|
||||||
|
|
||||||
|
Минимальный набор типов:
|
||||||
|
|
||||||
|
- `create_user_pda`
|
||||||
|
- `update_user_pda`
|
||||||
|
- `update_users_economy_config`
|
||||||
|
- `init_users_economy_config`
|
||||||
|
- `other`
|
||||||
|
|
||||||
|
Также для каждой транзакции нужно определять:
|
||||||
|
|
||||||
|
- `is_relevant = 1`, если транзакция создаёт или обновляет пользовательский `user_pda`;
|
||||||
|
- `is_relevant = 0`, если это транзакция истории, но она не меняет пользовательские PDA.
|
||||||
|
|
||||||
|
Для релевантных транзакций нужно дополнительно извлекать:
|
||||||
|
|
||||||
|
- `affected_pda_address`
|
||||||
|
- `affected_login`
|
||||||
|
|
||||||
|
Если транзакция затрагивает несколько пользовательских PDA, архитектура должна не запрещать хранить несколько связей, но на первом этапе можно исходить из одной пользовательской записи на одну транзакцию, если это соответствует текущему контракту.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Локальные таблицы
|
||||||
|
|
||||||
|
Модуль должен использовать три основные таблицы.
|
||||||
|
|
||||||
|
### 1. `solana_sync_state`
|
||||||
|
|
||||||
|
Одна строка состояния синхронизации.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- хранить текущий checkpoint истории;
|
||||||
|
- хранить время последней успешной актуализации;
|
||||||
|
- хранить технический статус синка.
|
||||||
|
|
||||||
|
Пример полей:
|
||||||
|
|
||||||
|
- `id INTEGER PRIMARY KEY CHECK (id = 1)`
|
||||||
|
- `status TEXT NOT NULL`
|
||||||
|
- `ready INTEGER NOT NULL DEFAULT 0`
|
||||||
|
- `last_poll_at_ms INTEGER`
|
||||||
|
- `last_successful_poll_at_ms INTEGER`
|
||||||
|
- `last_seen_signature TEXT`
|
||||||
|
- `last_seen_slot INTEGER`
|
||||||
|
- `last_relevant_signature TEXT`
|
||||||
|
- `last_relevant_slot INTEGER`
|
||||||
|
- `last_error TEXT`
|
||||||
|
- `updated_at_ms INTEGER NOT NULL`
|
||||||
|
|
||||||
|
### 2. `solana_sync_tx_history`
|
||||||
|
|
||||||
|
Append-only журнал всех просмотренных транзакций по `users_economy_config_pda`.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- хранить полную историю опроса;
|
||||||
|
- фиксировать, какие tx были релевантны;
|
||||||
|
- хранить связь tx -> пользовательский PDA / login;
|
||||||
|
- упрощать аудит и диагностику.
|
||||||
|
|
||||||
|
Пример полей:
|
||||||
|
|
||||||
|
- `signature TEXT PRIMARY KEY`
|
||||||
|
- `slot INTEGER NOT NULL`
|
||||||
|
- `block_time INTEGER`
|
||||||
|
- `tx_kind TEXT NOT NULL`
|
||||||
|
- `is_relevant INTEGER NOT NULL`
|
||||||
|
- `affected_pda_address TEXT`
|
||||||
|
- `affected_login TEXT`
|
||||||
|
- `processed_at_ms INTEGER NOT NULL`
|
||||||
|
- `raw_summary_json TEXT NOT NULL`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
|
||||||
|
- по `slot`
|
||||||
|
- по `is_relevant`
|
||||||
|
- по `affected_login`
|
||||||
|
- по `affected_pda_address`
|
||||||
|
|
||||||
|
### 3. `solana_user_pda_current`
|
||||||
|
|
||||||
|
Текущее актуальное состояние каждого пользовательского PDA.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- быстрый lookup текущих данных пользователя;
|
||||||
|
- будущая интеграция с сервером;
|
||||||
|
- опорная таблица для поиска текущих ключей и полей PDA.
|
||||||
|
|
||||||
|
Пример полей:
|
||||||
|
|
||||||
|
- `pda_address TEXT PRIMARY KEY`
|
||||||
|
- `login TEXT NOT NULL`
|
||||||
|
- `record_number INTEGER NOT NULL`
|
||||||
|
- `slot INTEGER NOT NULL`
|
||||||
|
- `last_tx_signature TEXT NOT NULL`
|
||||||
|
- `blockchain_name TEXT NOT NULL`
|
||||||
|
- `blockchain_key TEXT NOT NULL`
|
||||||
|
- `client_key TEXT NOT NULL`
|
||||||
|
- `paid_limit_bytes INTEGER NOT NULL`
|
||||||
|
- `used_bytes INTEGER NOT NULL`
|
||||||
|
- `last_block_number INTEGER NOT NULL`
|
||||||
|
- `last_block_hash TEXT`
|
||||||
|
- `arweave_tx_id TEXT`
|
||||||
|
- `is_server INTEGER NOT NULL DEFAULT 0`
|
||||||
|
- `server_address TEXT`
|
||||||
|
- `sync_servers_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `access_servers_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `sessions_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `trusted_count INTEGER NOT NULL DEFAULT 0`
|
||||||
|
- `created_at_ms INTEGER NOT NULL`
|
||||||
|
- `updated_at_ms INTEGER NOT NULL`
|
||||||
|
- `raw_data_base64 TEXT NOT NULL`
|
||||||
|
- `first_seen_at_ms INTEGER NOT NULL`
|
||||||
|
- `last_synced_at_ms INTEGER NOT NULL`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
|
||||||
|
- уникальный индекс на `login`
|
||||||
|
- индекс на `slot`
|
||||||
|
- индекс на `last_tx_signature`
|
||||||
|
|
||||||
|
### 4. `solana_user_pda_history`
|
||||||
|
|
||||||
|
Append-only история всех версий пользовательских PDA.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- хранить все старые публичные ключи и прочие поля прошлых версий;
|
||||||
|
- позволять видеть, когда и какая версия записи была актуальна;
|
||||||
|
- позволять разбирать изменения пользователя во времени.
|
||||||
|
|
||||||
|
Пример полей:
|
||||||
|
|
||||||
|
- `id INTEGER PRIMARY KEY`
|
||||||
|
- `tx_signature TEXT NOT NULL`
|
||||||
|
- `slot INTEGER NOT NULL`
|
||||||
|
- `block_time INTEGER`
|
||||||
|
- `pda_address TEXT NOT NULL`
|
||||||
|
- `login TEXT NOT NULL`
|
||||||
|
- `record_number INTEGER NOT NULL`
|
||||||
|
- `blockchain_name TEXT NOT NULL`
|
||||||
|
- `blockchain_key TEXT NOT NULL`
|
||||||
|
- `client_key TEXT NOT NULL`
|
||||||
|
- `paid_limit_bytes INTEGER NOT NULL`
|
||||||
|
- `used_bytes INTEGER NOT NULL`
|
||||||
|
- `last_block_number INTEGER NOT NULL`
|
||||||
|
- `last_block_hash TEXT`
|
||||||
|
- `arweave_tx_id TEXT`
|
||||||
|
- `is_server INTEGER NOT NULL DEFAULT 0`
|
||||||
|
- `server_address TEXT`
|
||||||
|
- `sync_servers_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `access_servers_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `sessions_json TEXT NOT NULL DEFAULT '[]'`
|
||||||
|
- `trusted_count INTEGER NOT NULL DEFAULT 0`
|
||||||
|
- `created_at_ms INTEGER NOT NULL`
|
||||||
|
- `updated_at_ms INTEGER NOT NULL`
|
||||||
|
- `raw_data_base64 TEXT NOT NULL`
|
||||||
|
- `saved_at_ms INTEGER NOT NULL`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
|
||||||
|
- уникальный индекс на `(pda_address, record_number)`
|
||||||
|
- индекс на `login`
|
||||||
|
- индекс на `slot`
|
||||||
|
- индекс на `tx_signature`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Зачем нужны и `current`, и `history`
|
||||||
|
|
||||||
|
Нужны обе таблицы:
|
||||||
|
|
||||||
|
- `solana_user_pda_current` хранит только последнюю актуальную версию и удобна для быстрых запросов;
|
||||||
|
- `solana_user_pda_history` хранит все версии и нужна для расследований и просмотра старых публичных ключей.
|
||||||
|
|
||||||
|
При обработке новой релевантной транзакции:
|
||||||
|
|
||||||
|
1. новая версия всегда добавляется в `solana_user_pda_history`;
|
||||||
|
2. актуальная строка в `solana_user_pda_current` вставляется или обновляется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что именно считается “историей пользователя”
|
||||||
|
|
||||||
|
История нужна не для секретных паролей, а для публичных данных PDA.
|
||||||
|
|
||||||
|
В частности, история должна позволять видеть старые значения:
|
||||||
|
|
||||||
|
- `client_key`
|
||||||
|
- `blockchain_key`
|
||||||
|
- `sessions`
|
||||||
|
- `access_servers`
|
||||||
|
- `sync_servers`
|
||||||
|
- `server_address`
|
||||||
|
- других публичных полей PDA
|
||||||
|
|
||||||
|
Секретные приватные ключи или настоящие пользовательские пароли этот модуль не хранит и хранить не должен.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Модуль должен использовать нормальный logger, а не `System.out.println`.
|
||||||
|
|
||||||
|
Требования к логированию:
|
||||||
|
|
||||||
|
- совместимость с будущей интеграцией в общие логи сервера;
|
||||||
|
- явные уровни `INFO`, `WARN`, `ERROR`, `DEBUG`;
|
||||||
|
- короткие, но диагностичные сообщения;
|
||||||
|
- каждый важный переход состояния должен логироваться.
|
||||||
|
|
||||||
|
Что обязательно логировать:
|
||||||
|
|
||||||
|
- старт процесса;
|
||||||
|
- чтение конфига;
|
||||||
|
- вычисление `users_economy_config_pda`;
|
||||||
|
- старт websocket-подписки;
|
||||||
|
- успешную подписку;
|
||||||
|
- начало initial sync;
|
||||||
|
- окончание initial sync;
|
||||||
|
- вход в `READY`;
|
||||||
|
- periodic poll;
|
||||||
|
- число найденных сигнатур;
|
||||||
|
- число релевантных tx;
|
||||||
|
- число обновлённых PDA;
|
||||||
|
- реконнекты websocket;
|
||||||
|
- ошибки RPC/WS;
|
||||||
|
- переход в `FAILED`, если он будет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
В конфиге должны остаться только обязательные для постоянной эксплуатации параметры:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOLANA_RPC_URL=
|
||||||
|
SOLANA_WS_URL=
|
||||||
|
SOLANA_PROGRAM_ID=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
|
||||||
|
|
||||||
|
DATABASE_URL=jdbc:postgresql://127.0.0.1:5432/shine_server_db
|
||||||
|
PGUSER=shine_server
|
||||||
|
PGPASSWORD=
|
||||||
|
SYNC_POLL_INTERVAL_SECONDS=300
|
||||||
|
```
|
||||||
|
|
||||||
|
Дополнительно:
|
||||||
|
|
||||||
|
- `SOLANA_COMMITMENT` не нужен как параметр;
|
||||||
|
- commitment должен быть зафиксирован в коде как `confirmed`;
|
||||||
|
- `HELIUS_API_KEY` не нужен;
|
||||||
|
- `SOLANA_NETWORK` не нужен.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поведение при ошибках
|
||||||
|
|
||||||
|
### Ошибка periodic poll
|
||||||
|
|
||||||
|
Если periodic poll не удался:
|
||||||
|
|
||||||
|
- это логируется как `WARN` или `ERROR`;
|
||||||
|
- `solana_sync_state.last_error` обновляется;
|
||||||
|
- модуль не должен сразу завершаться, если reconnect/retry ещё возможны.
|
||||||
|
|
||||||
|
### Ошибка initial sync
|
||||||
|
|
||||||
|
Если initial sync не удался:
|
||||||
|
|
||||||
|
- модуль не должен выставлять `READY`;
|
||||||
|
- отдельный процесс должен завершаться с ошибкой или оставаться в `FAILED`, в зависимости от выбранного runtime-режима;
|
||||||
|
- при встраивании в сервер основной startup должен считаться неуспешным.
|
||||||
|
|
||||||
|
### Аварийный fallback
|
||||||
|
|
||||||
|
Даже если основная логика опирается на историю по `users_economy_config_pda`, аварийный full snapshot всех `user_pda` можно оставить как последний защитный fallback на случай повреждённого или неполного RPC-ответа.
|
||||||
|
|
||||||
|
Но это должен быть именно крайний защитный сценарий, а не штатный рабочий путь.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Почему хранить всю tx-историю полезно
|
||||||
|
|
||||||
|
Полный журнал `solana_sync_tx_history` нужен не только для самого синка, но и для эксплуатации:
|
||||||
|
|
||||||
|
- видно, что именно вернул RPC;
|
||||||
|
- видно, какие tx были признаны релевантными;
|
||||||
|
- видно, какие tx были проигнорированы и почему;
|
||||||
|
- можно поднимать старые кейсы без повторного запроса в Solana;
|
||||||
|
- упрощается аудит и отладка после инцидентов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что должно получиться в итоге
|
||||||
|
|
||||||
|
В результате модуль должен работать так:
|
||||||
|
|
||||||
|
1. стартует как отдельный Java-процесс;
|
||||||
|
2. подписывается на realtime-обновления `shine_users`;
|
||||||
|
3. делает начальную актуализацию через историю `users_economy_config_pda`;
|
||||||
|
4. входит в `READY`;
|
||||||
|
5. раз в 5 минут делает дополнительный audit истории;
|
||||||
|
6. хранит:
|
||||||
|
- состояние синка;
|
||||||
|
- полную историю просмотренных транзакций;
|
||||||
|
- актуальное зеркало всех пользовательских PDA;
|
||||||
|
- историю всех версий пользовательских PDA;
|
||||||
|
7. после будущего переноса в SHiNE-сервер может использоваться как блокирующий startup-модуль:
|
||||||
|
- сначала синхронизируется Solana;
|
||||||
|
- потом сервер продолжает запуск остальных подсистем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Следующий шаг реализации
|
||||||
|
|
||||||
|
После утверждения этого документа модуль нужно доработать в коде:
|
||||||
|
|
||||||
|
1. упростить конфиг;
|
||||||
|
2. заменить текущее логирование на logger;
|
||||||
|
3. выделить lifecycle-сервис с `awaitReady()`;
|
||||||
|
4. реализовать вычисление `users_economy_config_pda`;
|
||||||
|
5. реализовать polling истории по сигнатурам;
|
||||||
|
6. добавить новые SQLite-таблицы;
|
||||||
|
7. добавить запись в `current` и `history`;
|
||||||
|
8. добавить periodic guard раз в 5 минут;
|
||||||
|
9. сохранить отдельный `main` для запуска как процесса.
|
||||||
@@ -8,6 +8,7 @@ include 'shine-server-blockchain'
|
|||||||
include 'shine-server-db'
|
include 'shine-server-db'
|
||||||
include 'shine-server-net-protocol'
|
include 'shine-server-net-protocol'
|
||||||
include 'shine-server-net-server'
|
include 'shine-server-net-server'
|
||||||
|
include 'shine-server-solana-users-sync'
|
||||||
|
|
||||||
project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log')
|
project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log')
|
||||||
project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config')
|
project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config')
|
||||||
@@ -17,3 +18,4 @@ project(':shine-server-blockchain').projectDir = file('SHiNE-server/shine-server
|
|||||||
project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db')
|
project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db')
|
||||||
project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol')
|
project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol')
|
||||||
project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server')
|
project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server')
|
||||||
|
project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync')
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# SHiNE TURN Server
|
|
||||||
|
|
||||||
This directory stores TURN setup scripts and operational instructions.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
- Install and configure coturn for SHiNE calls.
|
|
||||||
- Keep repeatable setup scripts for new TURN nodes.
|
|
||||||
- Keep TURN-related config templates.
|
|
||||||
|
|
||||||
## Current production model
|
|
||||||
- Multiple TURN servers are supported by backend config section:
|
|
||||||
- `call.ice.turn.servers.1.*`
|
|
||||||
- `call.ice.turn.servers.2.*`
|
|
||||||
- ...
|
|
||||||
- Each server can use:
|
|
||||||
- REST auth (`sharedSecret`) for temporary credentials, or
|
|
||||||
- static `username`/`password` (fallback).
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# Third-party notices
|
|
||||||
|
|
||||||
## Telegram Animated Emojis
|
|
||||||
|
|
||||||
Animated emoji previews in SHiNE use the public catalog from Tarikul-Islam-Anik.
|
|
||||||
|
|
||||||
Source: https://github.com/Tarikul-Islam-Anik/Telegram-Animated-Emojis
|
|
||||||
|
|
||||||
The repository README states that the media files are sourced from Emojipedia and that rights to the Telegram media belong to Telegram.org. Review those rights before public distribution.
|
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |