chore: зафиксированы все текущие изменения проекта

This commit is contained in:
AidarKC 2026-05-25 23:46:54 +03:00
parent 8c5de781ea
commit 8941582d54
53 changed files with 2303 additions and 106 deletions

2
.idea/vcs.xml generated
View File

@ -2,5 +2,7 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/shine-solana" vcs="Git" />
</component>
</project>

View File

@ -11,7 +11,9 @@
## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
- Подробные правила работы сервиса, его очередь, история, 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`.
## Solana-модуль
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
@ -19,6 +21,13 @@
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `Dev_Docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
@ -99,6 +108,9 @@
## Будущие фичи
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
- какие файлы и участки отключены;

View File

@ -1,14 +1,42 @@
# Будущие фичи
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку.
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
## Горизонты планирования
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
- `far/` - дальнее будущее: идеи без понятного срока возврата.
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
## Как использовать
1. Каждая будущая фича описывается отдельным markdown-файлом.
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
2. В файле нужно фиксировать:
- зачем нужна фича;
- что уже было сделано в коде;
- что временно отключено или закомментировано;
- к какому сроку или горизонту она относится;
- что нужно сделать;
- какие вопросы нужно уточнить перед реализацией;
- что уже было сделано в коде, если фича частично реализована;
- что временно отключено или закомментировано, если применимо;
- какие документы нужно обновить при возврате к задаче;
- с какого места продолжать разработку.
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
## Текущие планы
### Ближайшие
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
- `near/2026-05-25_1106_channels_my_create_button.md` - поправить экран каналов: создание канала показывать только в разделе `Мои каналы`.
### Среднесрочные
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
### Дальнее будущее
- Сейчас задач нет.

View File

@ -0,0 +1,5 @@
# Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.

View File

@ -3,6 +3,12 @@
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
1-2 месяца
- Решение от 2026-05-24:
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.

View File

@ -0,0 +1,62 @@
# Кошелёк и пополнение баланса сияния
- Горизонт:
`medium`
- Ориентир:
среднесрочно
- Статус:
`proposal`
## Кратко
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
## Предполагаемый сценарий
1. Пользователь регистрируется и получает/подключает нужные кошельки.
2. В интерфейсе появляется баланс сияния.
3. Пользователь открывает пополнение баланса сияния.
4. Система создаёт или принимает блокчейн-операцию пополнения.
5. После подтверждения баланса UI обновляет значение.
## Что нужно продумать
1. Что именно является единицей баланса сияния.
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
3. Какая операция отвечает за пополнение.
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
5. Как баланс восстанавливается после перезагрузки клиента.
6. Какие права нужны для пополнения и списания.
7. Нужна ли история операций баланса.
## Вопросы перед реализацией
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
2. Нужна ли конвертация из SOL/AR в сияние.
3. Кто может выпускать или начислять сияние.
4. Нужно ли поддерживать перевод сияния между пользователями.
5. Нужны ли лимиты, комиссии или статусы подтверждения.
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
## Важное ограничение
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
## Документы, которые обновить при реализации
- `Dev_Docs/Blockchain/`, если появятся или изменятся блоки баланса.
- `Dev_Docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
- `Dev_Docs/API/`, если меняется серверный API.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
## Минимальная проверка в будущем
1. Новый пользователь видит корректный начальный баланс.
2. Пополнение создаёт правильную операцию.
3. Баланс обновляется после подтверждения.
4. После перезагрузки UI баланс остаётся корректным.
5. Ошибочные или повторные операции не начисляют баланс дважды.

View File

@ -0,0 +1,67 @@
# Кнопка создания канала только в разделе «Мои каналы»
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно поправить экран каналов: в общем списке должна быть простая надпись `Каналы` и переход к своим каналам, а кнопка создания канала должна появляться только в разделе `Мои каналы`.
## Ожидаемый сценарий
### Общий список
1. Заголовок: `Каналы`.
2. Есть кнопка перехода в свои каналы: `Мои` или `Мои каналы`.
3. Кнопки создания канала в общем списке нет.
### Раздел своих каналов
1. Заголовок: `Мои каналы`.
2. Слева есть переход назад или кнопка `Ко всем каналам`.
3. Справа от заголовка или в правой части шапки есть маленькая кнопка `+` для создания канала.
4. Кнопка создания канала работает только здесь.
## Что нужно сделать
1. Найти компонент/страницу списка каналов в UI.
2. Разделить состояние общего списка и списка своих каналов.
3. Убрать кнопку создания канала из общего списка.
4. Добавить или оставить кнопку создания канала в `Мои каналы`.
5. Поправить заголовки:
- общий список: `Каналы`;
- свой список: `Мои каналы`.
6. Проверить расположение кнопок на мобильном и desktop-экране.
7. Убедиться, что существующее создание канала не ломается.
## Вопросы перед реализацией
1. Какой текст кнопки перехода лучше: `Мои`, `Мои каналы` или иконка с подписью.
2. Нужна ли отдельная стрелка назад, если уже есть кнопка `Ко всем каналам`.
3. Кнопка `+` должна быть иконкой без текста или `Создать канал`.
4. Нужно ли сохранять выбранный режим после перезагрузки страницы.
## Риски и ограничения
- Нужно проверить, не завязаны ли текущие обработчики создания канала на общий экран.
- На узких экранах заголовок, кнопка назад и `+` могут конфликтовать по ширине.
- Если есть роутинг по query/state, важно не сломать прямые переходы.
## Документы, которые обновить при реализации
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- UI-документацию, если в проекте есть отдельное описание экранов каналов.
## Минимальная проверка
1. В общем списке виден заголовок `Каналы`.
2. В общем списке нет кнопки создания канала.
3. Переход в `Мои каналы` работает.
4. В `Мои каналы` видна кнопка создания канала.
5. Создание канала из `Мои каналы` работает.
6. Возврат ко всем каналам работает.
7. На мобильном экране шапка не ломается.

View File

@ -0,0 +1,94 @@
# Telegram-агент для разрешённых игроков
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно расширить `SHiNE-agent-bot-coder`, чтобы агент мог принимать личные сообщения от заранее разрешённых пользователей, вести по каждому отдельную рабочую папку и историю, помогать им с обсуждениями/документами без изменения кода, а краткий результат публиковать в общий канал.
## Пользовательский сценарий
1. Разрешённый пользователь пишет агенту в личные сообщения текстом или голосом.
2. Голосовое сообщение распознаётся так же, как сейчас распознаются voice/audio-задачи.
3. Сервис определяет пользователя по разрешённому списку логинов.
4. Для пользователя используется отдельная папка в `Players/`.
5. Codex запускается с системным контекстом: от имени какого человека он работает, где лежит его папка, какие у него локальные инструкции.
6. Агент может читать код и документацию проекта, но писать должен только в папку этого пользователя, если нет отдельного согласования на изменение общего проекта.
7. После ответа пользователю агент отправляет в общий канал короткую сводку двумя сообщениями или двумя блоками: вопрос пользователя и полученный ответ.
8. Команда `/new` или `New` сбрасывает только сессию этого пользователя.
## Предлагаемая структура
- `Players/`
- `Ivan/`
- `AGENTS.md`
- `history/`
- `files/`
- `Sergey/`
- `AGENTS.md`
- `history/`
- `files/`
- `Milana/`
- `AGENTS.md`
- `history/`
- `files/`
Имена папок можно уточнить после получения точных Telegram-логинов.
## Что нужно сделать
1. Добавить конфигурацию разрешённых Telegram-пользователей.
2. Описать соответствие `telegram username -> имя игрока -> папка`.
3. Создавать или использовать отдельную историю диалога для каждого игрока.
4. Поддержать личные сообщения от разрешённых пользователей.
5. Запретить постановку задач от неизвестных пользователей.
6. Для групп/каналов оставить текущую логику: команды Айдара имеют приоритет.
7. При запуске Codex для игрока добавлять отдельный системный контекст:
- имя пользователя;
- путь к его папке;
- правило записи только в эту папку;
- путь к персональному `AGENTS.md`.
8. После ответа игроку отправлять краткую сводку в общий канал.
9. Поддержать `/new`/`New` как сброс только персональной сессии игрока.
10. Добавить защиту от случайного изменения общего кода в режиме игрока.
## Вопросы перед реализацией
1. Точные Telegram-логины Ивана, Сергея и Миланы.
2. Какой общий канал использовать для сводок: текущий `@shine_writing` или отдельный чат.
3. Нужно ли отправлять в общий канал полный текст вопроса/ответа или краткую выжимку.
4. Нужно ли пересылать вложения игроков в общий канал или только текстовые сводки.
5. Разрешить ли игрокам читать все документы проекта, включая технические заметки деплоя.
6. Что делать, если пользователь просит изменить код: отказать, создать предложение в своей папке или просить подтверждение Айдара.
7. Нужны ли русские имена папок (`Иван`, `Сергей`, `Милана`) или ASCII-имена (`Ivan`, `Sergey`, `Milana`).
8. Нужно ли хранить истории игроков в общей папке сервиса или внутри `Players/<name>/history/`.
## Риски и ограничения
- Нужно аккуратно разделить режим Айдара и режим игрока, чтобы игроки не могли случайно запустить изменение общего кода.
- Нужно не смешать истории разных пользователей.
- Нужно ограничить публикацию в общий канал, чтобы не утекали личные или слишком длинные ответы.
- Нужна проверка Telegram-идентификации: username может меняться, поэтому желательно хранить и `user_id`.
## Документы, которые обновить при реализации
- `SHiNE-agent-bot-coder/AGENTS.md`
- `SHiNE-agent-bot-coder/AGENT.md`
- `SHiNE-agent-bot-coder/README.md`
- `Dev_Docs/deploy/agent-bot-coder-local-systemd.md`, если появятся новые переменные окружения или настройки сервиса.
## Минимальная проверка
1. Айдар по-прежнему может ставить задачи из `@shine_writing`.
2. Неизвестный пользователь не ставит задачу в очередь.
3. Разрешённый игрок пишет личное текстовое сообщение и получает ответ.
4. Разрешённый игрок отправляет voice, оно распознаётся и обрабатывается.
5. История одного игрока не попадает в историю другого.
6. `/new` сбрасывает только историю текущего игрока.
7. Сводка вопрос/ответ появляется в общем канале.
8. В режиме игрока агент не пишет за пределы `Players/<name>/` без отдельного подтверждения.

View File

@ -0,0 +1,71 @@
# Пополнение Solana и Arweave через внешний сервис покупки
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
## Пользовательский сценарий
1. Пользователь видит адрес кошелька Solana или Arweave.
2. Нажимает `Пополнить`.
3. Открывается промежуточное окно с инструкцией:
- сейчас пользователь перейдёт на страницу покупки/пополнения;
- нужно указать или проверить адрес кошелька;
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
4. В окне есть кнопки:
- `Скопировать адрес и перейти`;
- `Перейти без копирования`.
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
## Что нужно сделать
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
3. Выбрать международный сервис покупки Solana с карты, не российский.
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
6. Добавить отдельные действия для Solana и Arweave.
7. Сделать текст инструкции коротким и понятным.
8. Проверить, что адрес копируется в буфер обмена в браузере.
9. Проверить мобильный сценарий и desktop-сценарий.
## Вопросы перед реализацией
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
7. Какой точный текст использовать для времени прихода Arweave.
## Риски и ограничения
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
- Clipboard API может требовать HTTPS и пользовательский жест.
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
## Документы, которые обновить при реализации
- Документацию UI/кошельков, если такая есть.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- `Dev_Docs/API/`, только если появится новый серверный API или логирование.
## Минимальная проверка
1. На Solana-кошельке открывается правильное окно пополнения.
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
4. Аналогичный сценарий работает для Arweave.
5. На мобильном экране текст и кнопки не перекрываются.
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.

View File

@ -0,0 +1,26 @@
# Solana: init регистрации + деплой обязательных программ
- дата: 2026-05-24 20:35 (Europe/Moscow)
- статус: `pending`
## Кратко
Добавлена dev-страница в UI для вызова `init_users_economy_config` программы `shine_users` через подключённый кошелёк Phantom.
Задеплоены и зафиксированы адреса двух обязательных программ регистрации: `shine_users` и `shine_login_guard`.
## Что проверять вручную
1. Открыть UI и перейти в `Настройки разработчика`.
2. Нажать `Solana: init регистрации`.
3. Подключить Phantom devnet-кошелёк.
4. Выполнить `init_users_economy_config`.
5. Проверить отображение статуса и хэша транзакции.
6. Повторно нажать init и убедиться, что корректно показывается "уже инициализировано".
7. Выполнить тестовую регистрацию пользователя и убедиться, что CPI-вызов `shine_login_guard` не падает.
## Ожидаемый результат
- Первая транзакция выполняется успешно (если PDA ещё не создан).
- Вторая попытка возвращает ожидаемую ошибку о повторной инициализации.
- UI не падает, статус понятный, Program ID отображается корректно.
- Регистрация пользователя проходит с подключённым `shine_login_guard`.

View File

@ -0,0 +1,26 @@
# Отчёт private-запросов агента в группу
## Что сделано
После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
- первым сообщением исходный запрос;
- вторым сообщением, reply на первое, финальный ответ Codex.
Промежуточные статусы выполнения в группу не дублируются.
## Что проверять
1. Отправить боту личный текстовый запрос.
2. Дождаться полного ответа в личном чате.
3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
4. Проверить, что итоговый ответ опубликован reply на это сообщение.
5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
## Ожидаемый результат
Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
## Статус
pending

View File

@ -0,0 +1,23 @@
# Отчёт voice/audio-запросов с исходным файлом
## Краткое описание
Публичный отчёт по приватным voice/audio-запросам агента должен отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. `file_id` не должен показываться пользователям в тексте отчёта.
## Что проверить
1. Отправить боту приватный voice-запрос от Айдара.
2. Дождаться обработки Codex.
3. Проверить группу/канал публичных отчётов.
4. Повторить сценарий для audio-файла, если он используется.
## Ожидаемый результат
- В публичном отчёте появляется исходное голосовое/audio-сообщение.
- В подписи к нему есть распознанный текст.
- В отчёте нет строки `Голосовой file_id` и самого `file_id`.
- Итоговый ответ Codex отправляется ответом на сообщение с исходным файлом.
## Статус
pending

View File

@ -0,0 +1,19 @@
# Улучшенная обработка длинных voice/audio
## Что сделано
- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
## Как проверять
- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
## Ожидаемый результат
- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
- В логах сервиса видны стадия и техническая причина сбоя.
## Статус
pending

View File

@ -51,7 +51,7 @@
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
@ -63,7 +63,7 @@
После первых 9 полей идет набор типизированных блоков:
```text
UserPdaRecordV2
UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
@ -89,7 +89,7 @@ UserPdaRecordV2
Правила:
- неизвестный `block_type` в `format_major = 2` считается ошибкой;
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;

View File

@ -0,0 +1,166 @@
# Архитектура Solana-программ SHiNE
Документ описывает рабочую архитектуру Solana-части SHiNE: три Anchor-программы, DAO, ключи управления, PDA-счета и движение денег.
Это архитектурная справка. Она не меняет код, формат PDA-записи пользователя, серверный API или формат блокчейна SHiNE.
Статус: актуализировано по коду `shine-solana/shine/programs/*` на 2026-05-25.
Связанные документы:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
## Кратко
В Solana-модуле сейчас три основные программы:
1. `shine_login_guard` — проверяет логин и возвращает класс логина: обычный, premium или trademark.
2. `shine_users` — создает и обновляет пользовательскую PDA-запись, проверяет подписи и берет оплату за регистрацию/увеличение лимита.
3. `shine_payments` — принимает входящий поток средств в `inflow_vault`, ведет очереди тикетов, позволяет DAO выдавать лимиты менеджерам и выполняет выплаты.
DAO в текущем виде не является отдельной Anchor-программой SHiNE внутри `programs/`. Это управляющая модель поверх кошельков, governance-скриптов и authority-адресов. Для проектирования ее удобно считать отдельным управляющим блоком: DAO голосует, назначает управляющие ключи, управляет казной и вызывает защищенные методы второй и третьей программ.
## Общая схема
Редактируемая Mermaid-схема находится в [schemes/architecture.mmd](schemes/architecture.mmd).
Картинки:
- [schemes/architecture.svg](schemes/architecture.svg)
- [schemes/architecture.png](schemes/architecture.png)
## Программы и функции
| Блок | Папка/имя | Текущие функции из кода | Основной смысл |
| --- | --- | --- | --- |
| 1 | `shine_login_guard` | `classify_login` | Проверка логина перед регистрацией. |
| 2 | `shine_users` | `init_users_economy_config`, `update_users_economy_config`, `create_user_pda`, `update_user_pda` | Регистрация пользователя, обновление записи, экономика лимита. |
| 3 | `shine_payments` | `init`, `update_coef_limit`, `grant_manager_limits`, `buy_ticket`, `buy_ticket_usd`, `buy_ticket_sol`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient` | Vault, билеты, очереди, выплаты, DAO-настройки, лимиты менеджеров. |
| DAO | governance/authority | Вызовы через governance и управляющие ключи | Управление правами, казной, настройками и будущими обновлениями программ. |
## Актуальные program id
Актуальные адреса заданы одновременно в `Anchor.toml`, `declare_id!` программ и `programs/common/src/deploy_config.rs`:
| Программа | Program ID |
| --- | --- |
| `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `shine_users` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `shine_payments` | `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` |
Если эти адреса меняются, нужно синхронно обновить:
1. `shine-solana/shine/Anchor.toml`
2. `declare_id!` в `programs/*/src/lib.rs`
3. `programs/common/src/deploy_config.rs`
4. UI/серверные константы, перечисленные в `Dev_Docs/Инициализация_Solana_регистрации/README.md`
## Ключи и authority
Для удобного понимания на старте можно считать, что есть четыре группы ключей:
1. `key_1` / authority программы `shine_login_guard`.
- Сейчас программа только классифицирует логин.
- На первом этапе ее можно оставить под отдельным ключом.
- В будущем право обновления можно передать DAO.
2. `key_2` / authority программы `shine_users`.
- Отвечает за деплой/upgrade второй программы.
- Защищенное обновление economy-конфига в коде уже проверяет `DAO_AUTHORITY`.
- В целевой модели upgrade-authority второй программы нужно передать DAO.
3. `key_3` / authority программы `shine_payments`.
- Отвечает за деплой/upgrade третьей программы.
- Защищенные методы `update_coef_limit` и `grant_manager_limits` проверяют `dao_wallet` из `ConfigState`.
- В целевой модели upgrade-authority третьей программы нужно передать DAO.
4. DAO-ключи.
- Это управляющие кошельки/токены/realm governance.
- DAO может добавлять и отзывать управляющие ключи по голосованию.
- DAO-казна получает деньги от покупки тикетов и DAO-часть выплат из `inflow_vault`.
Адреса program id сейчас берутся из `programs/common/src/deploy_config.rs`. Для production/devnet можно подбирать vanity-адреса с понятным началом вроде `SHi...`, но это отдельная операция генерации ключей и деплоя.
## Счета и PDA
Постоянные PDA и счета:
1. `shine_users`
- `user_pda` — пользовательская запись по seed `login=<login>`, создается для каждого логина.
- `users_economy_config_pda` — общие параметры экономики регистрации и лимита.
2. `shine_payments`
- `config_pda` — хранит `dao_wallet` и адрес `inflow_vault`.
- `coef_limit_pda` — хранит коэффициент выплат, лимит очереди и награду вызывающему `step_payout`.
- `queues_pda` — агрегаты очередей выплат.
- `inflow_vault_pda` — PDA-вольт, куда `shine_users` переводит оплату регистрации и увеличения лимита.
- `ticket_pda` — отдельная PDA-запись тикета на каждую покупку/менеджерскую выдачу.
- `manager_allowance_pda` — PDA лимитов конкретного менеджера.
3. DAO
- `dao_wallet` / treasury — казна DAO.
- governance-аккаунты DAO — realm, governance, proposal/vote records и связанные аккаунты SPL Governance, если используется эта модель.
## Правило разделения с основным сервером
Solana-модуль лежит в основном репозитории как отдельная папка `shine-solana/shine/`, но не подключается автоматически к сборке или деплою основного сервера SHiNE. Команды `deployServer` и `deployUI` не должны деплоить Anchor-программы. Solana build/deploy выполняется отдельно из папки `shine-solana/shine/` по локальным правилам модуля.
## Движение денег
Основные потоки:
1. Регистрация пользователя через `shine_users::create_user_pda`.
- Платит `signer`.
- Деньги идут в `shine_payments::inflow_vault_pda`.
- Сумма состоит из регистрационной комиссии и оплаты дополнительного лимита.
2. Увеличение лимита через `shine_users::update_user_pda`.
- Платит `signer`.
- Деньги идут в тот же `inflow_vault_pda`.
- Сумма равна оплате дополнительного лимита.
3. Покупка тикета через `shine_payments::buy_ticket*`.
- Платит покупатель.
- Деньги сразу идут в `dao_wallet`.
- Одновременно создается тикет на выплату.
4. Выплата через `shine_payments::step_payout`.
- Вызвать может любой подписант.
- Деньги берутся из `inflow_vault_pda`.
- Часть идет получателю тикета.
- Часть идет в `dao_wallet`.
- Небольшая награда идет вызвавшему шаг выплат.
- Если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Передача прав DAO
Минимальная целевая модель:
1. `shine_login_guard`
- Пока оставить на отдельном ключе `key_1`.
- Передачу DAO сделать позже, когда логика premium/trademark стабилизируется.
2. `shine_users`
- Economy-настройки уже должны обновляться DAO-authority.
- Upgrade-authority программы после проверки можно передать DAO.
3. `shine_payments`
- DAO уже управляет настройками выплат и лимитами менеджеров через `dao_wallet`.
- Upgrade-authority программы после проверки можно передать DAO.
4. DAO
- Управляет казной.
- Принимает решения голосованием.
- Добавляет/отзывает управляющие ключи.
- Вызывает защищенные методы второй и третьей программ.
- В будущем может принять управление первой программой.
## Детальные файлы
- [details/shine_login_guard.md](details/shine_login_guard.md)
- [details/shine_users.md](details/shine_users.md)
- [details/shine_payments.md](details/shine_payments.md)
- [details/shine_dao.md](details/shine_dao.md)
- [details/accounts_and_money_flow.md](details/accounts_and_money_flow.md)

View File

@ -0,0 +1,110 @@
# Счета, ключи и движение денег
## Кратко
В архитектуре есть три типа объектов:
1. Ключи программ и DAO.
2. PDA-счета состояния.
3. Денежные счета, через которые проходят SOL/lamports.
## Ключи
Минимальный набор для понимания:
1. `key_1` — deploy/upgrade authority `shine_login_guard`.
2. `key_2` — deploy/upgrade authority `shine_users`.
3. `key_3` — deploy/upgrade authority `shine_payments`.
4. `DAO_AUTHORITY` — адрес, который имеет право менять защищенные настройки.
5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO.
6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов.
7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи.
8. `user device_key` — ключ устройства пользователя.
9. `server_key` — ключ сервера пользователя, если пользователь является сервером.
Текущие адреса из `programs/common/src/deploy_config.rs`:
| Роль | Адрес |
| --- | --- |
| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `SHINE_USERS_PROGRAM_ID` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `SHINE_PAYMENTS_PROGRAM_ID` | `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` |
| `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
| `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
## Постоянные PDA
`shine_users`:
- `user_pda` — создается для каждого логина, seed `login=` + normalized login.
- `users_economy_config_pda` — один PDA с экономикой регистрации, seed `shine_users_economy_config`.
`shine_payments`:
- `config_pda` — один PDA конфига, seed `shine_payments_config`.
- `coef_limit_pda` — один PDA коэффициента/лимита/награды, seed `shine_payments_coef_limit`.
- `queues_pda` — один PDA агрегатов очередей, seed `shine_payments_queues`.
- `inflow_vault_pda` — один PDA-вольт входящих средств, seed `shine_payments_inflow_vault`.
- `ticket_pda` — много PDA, по одному на тикет, seed `shine_payments_q1_ticket` или `shine_payments_q2_ticket` + индекс.
- `manager_allowance_pda` — много PDA, по одному на менеджера, seed `shine_p_manager_allow` + адрес менеджера.
## Денежные потоки
### Регистрация
```text
user signer -> shine_users::create_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- регистрационная комиссия;
- оплата `additional_limit`.
### Увеличение лимита
```text
user signer -> shine_users::update_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- только оплата `additional_limit`.
### Покупка тикета
```text
buyer signer -> shine_payments::buy_ticket* -> dao_wallet
```
При этом создается `ticket_pda`, но деньги в `inflow_vault_pda` на этом шаге не идут.
### Выплата
```text
shine_payments::inflow_vault_pda -> ticket_recipient_wallet
shine_payments::inflow_vault_pda -> dao_wallet
shine_payments::inflow_vault_pda -> step_payout caller
```
Если очереди пустые:
```text
shine_payments::inflow_vault_pda -> dao_wallet
```
## Что нужно создать на старте
Минимально:
1. Три program id для `shine_login_guard`, `shine_users`, `shine_payments`.
2. Три upgrade-authority ключа или один временный deploy-ключ с четким планом передачи прав.
3. DAO authority/treasury.
4. `users_economy_config_pda`.
5. `shine_payments` PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
Динамически будут создаваться:
- `user_pda` на каждого пользователя;
- `ticket_pda` на каждый тикет;
- `manager_allowance_pda` на каждого менеджера.

View File

@ -0,0 +1,74 @@
# SHiNE DAO
## Кратко
DAO — управляющий слой Solana-части SHiNE. В текущем коде это не отдельная Anchor-программа в `programs/`, а модель управления через DAO-кошелек, DAO-authority, governance-скрипты и будущую передачу upgrade-authority программ.
## Что DAO должно уметь
1. Управлять казной.
- Принимать средства на `dao_wallet`.
- Выплачивать средства со счета DAO по решениям голосования.
2. Управлять настройками `shine_users`.
- Обновлять регистрационную комиссию.
- Обновлять цену шага лимита.
- Обновлять стартовый бонус лимита.
3. Управлять настройками `shine_payments`.
- Обновлять коэффициент выплат.
- Обновлять лимит очереди.
- Обновлять награду за вызов `step_payout`.
4. Управлять менеджерами.
- Выдавать менеджеру лимит на добавление тикетов.
- Отдельно учитывать лимиты Q1 и Q2.
5. Управлять правами программ.
- Принять upgrade-authority `shine_users`.
- Принять upgrade-authority `shine_payments`.
- Позже принять upgrade-authority `shine_login_guard`, если это потребуется.
6. Управлять ключами DAO.
- Добавлять управляющие ключи.
- Отзывать или сжигать управляющие ключи.
- Делать это через голосование, а не вручную одним админом.
7. Фиксировать решения.
- Делать заявления/решения через governance-механику.
- Привязывать важные изменения к proposal/vote/execute.
## Текущие адреса управления
В общем deploy-конфиге сейчас есть два важных адреса:
- `DAO_AUTHORITY` — используется `shine_users` для проверки права менять economy-конфиг.
- `DAO_TREASURY_WALLET` — используется `shine_payments` как `dao_wallet`.
Сейчас они могут совпадать. В целевой DAO-модели их лучше рассматривать как разные роли:
- authority/governance signer — кто имеет право исполнять управленческие инструкции;
- treasury wallet — счет, куда приходят деньги DAO.
## Передача прав
Рекомендуемый порядок:
1. Сначала стабилизировать и проверить `shine_users` и `shine_payments`.
2. Передать DAO право обновлять настройки, если оно еще не передано.
3. Передать DAO upgrade-authority второй и третьей программ.
4. Оставить `shine_login_guard` на отдельном ключе до стабилизации словарей и правил логинов.
5. После стабилизации решить отдельным голосованием, передавать ли первую программу DAO.
## Важное разделение
Есть два разных типа прав:
1. Право вызвать защищенную функцию программы.
- Например, `update_coef_limit` или `grant_manager_limits`.
- Проверяется внутри программы по `dao_wallet` или `DAO_AUTHORITY`.
2. Право обновить саму программу.
- Это upgrade-authority Solana ProgramData.
- Оно передается отдельной Solana-командой/DAO-транзакцией и не равно обычному PDA-счету.

View File

@ -0,0 +1,58 @@
# `shine_login_guard`
## Кратко
`shine_login_guard` — первая программа Solana-модуля SHiNE. Она проверяет логин перед регистрацией пользователя и возвращает класс логина.
Папка программы: `shine-solana/shine/programs/shine_login_guard/`.
## Текущая функция
1. `classify_login(login: String)`
- Нормализует логин.
- Проверяет длину и допустимые символы.
- Сравнивает части логина со словарями premium/trademark.
- Возвращает результат через `set_return_data`.
Классы результата:
- `0` — обычный логин, регистрацию можно продолжать.
- `1` — premium-логин.
- `2` — trademark-логин, нужна отдельная проверка/разрешение.
## Правила нормализации и классификации
Текущая логика из `programs/shine_login_guard/src/lib.rs`:
- пустой логин или логин длиннее 20 символов получает класс `premium`;
- `_` при нормализации удаляется;
- допустимы только ASCII-буквы и цифры, остальные символы дают класс `premium`;
- после удаления `_` результат приводится к нижнему регистру;
- логины длиной 7 символов или меньше считаются `premium`;
- логин разбивается максимум на 3 словарных фрагмента;
- если среди найденных фрагментов есть trademark-слово, результат `trademark`;
- если найдены только premium-слова, результат `premium`;
- если разбиение по словарям не найдено, результат `free`.
Словари собираются на этапе build из файлов:
- `programs/shine_login_guard/src/dictionaries/premium/*.txt`
- `programs/shine_login_guard/src/dictionaries/trademarks/*.txt`
## Роль в общей схеме
`shine_users::create_user_pda` вызывает `shine_login_guard` через CPI и продолжает регистрацию только если логин получил класс `0`.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_1`.
Текущая рекомендация:
- пока оставить `shine_login_guard` под отдельным ключом;
- не передавать ее DAO до стабилизации правил premium/trademark;
- позже можно передать upgrade-authority DAO, чтобы изменения словарей и правил проходили через голосование.
## Счета
Собственных постоянных PDA-счетов у программы сейчас нет. Для проверки нужен только подписант транзакции в `ClassifyLogin`.

View File

@ -0,0 +1,173 @@
# `shine_payments`
## Кратко
`shine_payments` — третья программа Solana-модуля SHiNE. Она отвечает за vault входящих средств, DAO-казну, покупку тикетов, менеджерские лимиты, очереди выплат и пошаговое исполнение выплат.
Папка программы: `shine-solana/shine/programs/shine_payments/`.
## Текущие функции
1. `init`
- Создает основные PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
- Записывает `dao_wallet` и стартовые параметры выплат.
2. `update_coef_limit`
- Обновляет коэффициент выплаты, лимит очереди и награду вызвавшему `step_payout`.
- Требует подпись DAO-кошелька из `ConfigState`.
3. `grant_manager_limits`
- DAO выдает менеджеру лимиты на создание тикетов в очередях Q1/Q2.
- Создает или обновляет `manager_allowance_pda`.
4. `buy_ticket`
- Покупка тикета с суммой в lamports, пересчетом через Pyth SOL/USD.
5. `buy_ticket_usd`
- Покупка тикета от USD-центов с защитой по максимальному платежу в lamports.
6. `buy_ticket_sol`
- Покупка тикета в lamports с проверкой минимального ожидаемого USD-эквивалента.
7. `manager_add_ticket`
- Менеджер создает тикет за счет выданного ему DAO-лимита.
8. `step_payout`
- Любой подписант может вызвать шаг выплат.
- Программа выплачивает следующий тикет, DAO-часть и награду вызывающему.
9. `change_ticket_recipient`
- Текущий получатель тикета может поменять адрес получателя, если тикет еще не следующий на выплату.
## Аргументы инструкций
`init` аргументов не принимает.
`update_coef_limit`:
- `coef_ppm: u64`
- `limit_usd_cents: u64`
- `call_reward_lamports: u64`
`grant_manager_limits`:
- `manager_wallet: Pubkey`
- `add_q1_usd_cents: u64`
- `add_q2_usd_cents: u64`
`buy_ticket`:
- `amount_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_usd`:
- `amount_usd_cents: u64`
- `max_pay_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_sol`:
- `amount_lamports: u64`
- `min_expected_usd_cents: u64`
- `recipient_wallet: Pubkey`
`manager_add_ticket`:
- `queue_id: u8` — только `1` или `2`
- `recipient_wallet: Pubkey`
- `payout_usd_cents: u64`
`change_ticket_recipient`:
- `new_recipient_wallet: Pubkey`
## Главные PDA
1. `config_pda`
- Seed: `shine_payments_config`.
- Хранит `dao_wallet` и `inflow_vault`.
- Размер PDA: `8 + 160` байт.
2. `coef_limit_pda`
- Seed: `shine_payments_coef_limit`.
- Хранит коэффициент выплат, лимит и награду `step_payout`.
- Размер PDA: `8 + 96` байт.
3. `queues_pda`
- Seed: `shine_payments_queues`.
- Хранит агрегаты очередей Q1/Q2.
- Размер PDA: `8 + 192` байт.
4. `inflow_vault_pda`
- Seed: `shine_payments_inflow_vault`.
- Принимает деньги от `shine_users`.
- Из него выполняются выплаты тикетам, DAO и вызывающему `step_payout`.
- Размер PDA: `8 + 32` байт.
5. `ticket_pda`
- Seed зависит от очереди и индекса тикета.
- Отдельная PDA-запись на каждый тикет.
- Q1 seed: `shine_payments_q1_ticket` + `ticket_index`.
- Q2 seed: `shine_payments_q2_ticket` + `ticket_index`.
- Размер PDA: `8 + 160` байт.
6. `manager_allowance_pda`
- Seed: `shine_p_manager_allow` + адрес менеджера.
- Хранит доступный лимит менеджера по Q1/Q2.
- Размер PDA: `8 + 128` байт.
## Текущие параметры
Параметры initial config из `programs/shine_payments/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_COEF_PPM` | `5_000_000` | коэффициент 5.0x в ppm-масштабе |
| `START_LIMIT_USD_CENTS` | `1_000_000` | стартовый лимит Q1: 10_000 USD |
| `START_CALL_REWARD_LAMPORTS` | `8_000_000` | награда вызвавшему `step_payout`, 0.008 SOL |
| `MAX_CALL_REWARD_LAMPORTS` | `10_000_000` | максимум награды, 0.01 SOL |
| `ORACLE_MAX_AGE_SECS` | `120` | максимальный возраст цены Pyth |
Для расчетов используется Pyth SOL/USD:
- feed id: `0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`
- price update account: `7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE`
## Деньги
Входы:
- из `shine_users` в `inflow_vault_pda` при регистрации и увеличении лимита;
- от покупателя тикета сразу в `dao_wallet` при `buy_ticket*`.
Выходы:
- из `inflow_vault_pda` получателю тикета;
- из `inflow_vault_pda` в `dao_wallet`;
- из `inflow_vault_pda` вызвавшему `step_payout`;
- если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Очереди и выплаты
Выплаты идут строго пошагово:
- если есть невыплаченные Q1-тикеты, `step_payout` берет следующий Q1;
- если Q1 пустая, берется следующий Q2;
- для Q1 DAO-часть равна сумме тикета в USD;
- для Q2 DAO-часть равна двойной сумме тикета в USD;
- перед выплатой суммы пересчитываются из USD-центов в lamports по Pyth SOL/USD;
- если в `inflow_vault_pda` не хватает средств на тикет, DAO-часть и награду вызвавшему, шаг отклоняется.
`change_ticket_recipient` запрещает менять получателя у тикета, который является следующим на выплату.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_3`.
Целевая модель:
- `update_coef_limit` вызывает DAO;
- `grant_manager_limits` вызывает DAO;
- upgrade-authority программы после проверки передается DAO;
- `step_payout` остается открытым для любого подписанта, чтобы выплаты не зависели от одного оператора.

View File

@ -0,0 +1,136 @@
# `shine_users`
## Кратко
`shine_users` — вторая программа Solana-модуля SHiNE. Она отвечает за создание и обновление пользовательской PDA-записи, проверку подписи записи, проверку логина через `shine_login_guard` и оплату регистрации/дополнительного лимита.
Папка программы: `shine-solana/shine/programs/shine_users/`.
## Текущие функции
1. `init_users_economy_config`
- Создает PDA с экономическими настройками пользователей.
- Записывает стартовую регистрационную комиссию, цену шага лимита и стартовый бонус лимита.
2. `update_users_economy_config`
- Обновляет экономические настройки.
- Требует подпись `DAO_AUTHORITY` из общего deploy-конфига.
3. `create_user_pda`
- Проверяет логин через `shine_login_guard`.
- Проверяет структуру полей пользователя.
- Проверяет подпись записи root-ключом пользователя.
- Создает `user_pda` по seed `login=<normalized_login>`.
- Переводит оплату регистрации и дополнительного лимита в `shine_payments::inflow_vault_pda`.
4. `update_user_pda`
- Проверяет неизменяемые поля пользователя.
- Проверяет `prev_hash`, новую подпись и новое состояние последнего блока.
- При необходимости расширяет PDA.
- Переводит оплату дополнительного лимита в `shine_payments::inflow_vault_pda`.
## Аргументы инструкций
`init_users_economy_config` аргументов не принимает.
`update_users_economy_config`:
- `registration_fee_lamports: u64`
- `lamports_per_limit_step: u64`
- `start_bonus_limit: u64`
`create_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`update_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `updated_at_ms: u64`
- `version: u32`
- `prev_hash: Vec<u8>`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`UserMutableFields`:
- `device_key: Pubkey`
- `blockchain_public_key: Pubkey`
- `blockchain_name: String`
- `used_bytes: u64`
- `last_block_number: u32`
- `last_block_hash: Vec<u8>` — ровно 32 байта
- `last_block_signature: Vec<u8>` — ровно 64 байта
- `arweave_tx_id: String`
- `is_server: bool`
- `server_key: Pubkey`
- `server_address: String`
- `sync_servers: Vec<String>`
- `access_servers: Vec<String>`
- `trusted_count: u8`
## Главные PDA
1. `user_pda`
- PDA записи пользователя.
- Seed: `login=<normalized_login>`.
- Создается отдельно для каждого логина.
- Стартовый размер: `768` байт.
- При обновлении может расширяться через `realloc`, но один auto-realloc ограничен `10_000` байт.
2. `users_economy_config_pda`
- PDA с настройками экономики.
- Seed: `shine_users_economy_config`.
- Хранит регистрационную комиссию, цену шага лимита и стартовый бонус.
- Размер PDA: `8 + 96` байт.
## Текущие параметры экономики
Параметры initial config из `programs/shine_users/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_REGISTRATION_FEE_LAMPORTS` | `10_000_000` | стартовая комиссия регистрации, 0.01 SOL |
| `LIMIT_STEP` | `10_000` | шаг `additional_limit` |
| `START_LAMPORTS_PER_LIMIT_STEP` | `100_000` | 0.0001 SOL за один шаг лимита |
| `START_BONUS_LIMIT` | `100_000` | стартовый бесплатный лимит при регистрации |
`additional_limit` в create/update должен быть кратен `LIMIT_STEP`.
## Связь с другими программами
`shine_users` зависит от:
- `shine_login_guard` — для проверки логина при создании пользователя;
- `shine_payments` — для вычисления и проверки `inflow_vault_pda`, куда уходят платежи.
`create_user_pda` делает CPI-вызов `shine_login_guard::classify_login` и принимает только результат `0`. Premium/trademark логины сейчас отклоняются ошибками `PremiumLogin` или `TrademarkLoginRequiresReview`.
Подпись `user_pda` и подпись состояния последнего блока проверяются через встроенную Solana Ed25519-инструкцию, которая должна идти раньше инструкции `shine_users` в той же транзакции.
## Деньги
Деньги из `shine_users` идут только в `inflow_vault_pda` программы `shine_payments`.
Потоки:
- `create_user_pda`: регистрационная комиссия + оплата `additional_limit`;
- `update_user_pda`: оплата `additional_limit`, если она больше нуля.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_2`.
Целевая модель:
- economy-настройки меняет DAO-authority;
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.

View File

@ -0,0 +1,54 @@
flowchart LR
U[Пользователь / signer]
B[Покупатель тикета]
M[Менеджер]
C[Любой caller step_payout]
LG[1. shine_login_guard<br/>classify_login]
USERS[2. shine_users<br/>create_user_pda / update_user_pda]
PAY[3. shine_payments<br/>vault / tickets / payouts]
DAO[SHiNE DAO<br/>governance / authority / treasury]
USERPDA[(user_pda<br/>по login)]
ECON[(users_economy_config_pda)]
CONFIG[(config_pda)]
COEF[(coef_limit_pda)]
QUEUES[(queues_pda)]
VAULT[(inflow_vault_pda)]
TICKET[(ticket_pda)]
ALLOW[(manager_allowance_pda)]
U -->|логин| USERS
USERS -->|CPI проверка| LG
USERS -->|создает/обновляет| USERPDA
USERS -->|читает экономику| ECON
U -->|регистрация / лимит| VAULT
DAO -->|update economy| USERS
DAO -->|update coef/limit| PAY
DAO -->|grant manager limits| PAY
DAO -->|создает/отзывает ключи| DAO
PAY --> CONFIG
PAY --> COEF
PAY --> QUEUES
PAY --> VAULT
PAY --> TICKET
PAY --> ALLOW
B -->|buy_ticket*| PAY
B -->|оплата покупки тикета| DAO
PAY -->|создает тикет| TICKET
M -->|manager_add_ticket| PAY
ALLOW -->|лимиты Q1/Q2| M
C -->|step_payout| PAY
VAULT -->|выплата тикета| U
VAULT -->|DAO-часть| DAO
VAULT -->|call reward| C
DAO -. upgrade authority после передачи .-> USERS
DAO -. upgrade authority после передачи .-> PAY
DAO -. позже возможно .-> LG

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View File

@ -0,0 +1,139 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="900" viewBox="0 0 1400 900" role="img" aria-labelledby="title desc">
<title id="title">Архитектура Solana-программ SHiNE</title>
<desc id="desc">Схема трех программ, DAO, PDA-счетов и движения денег.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#2f3a45"/>
</marker>
<marker id="moneyArrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#0a7f62"/>
</marker>
<style>
.bg { fill: #f7f8fa; }
.title { font: 700 30px Arial, sans-serif; fill: #1f2933; }
.subtitle { font: 400 16px Arial, sans-serif; fill: #52606d; }
.box { fill: #ffffff; stroke: #9aa5b1; stroke-width: 2; rx: 8; }
.program { fill: #e8f1ff; stroke: #3465a4; }
.dao { fill: #fff3d6; stroke: #b7791f; }
.pda { fill: #edf7ed; stroke: #2f855a; }
.actor { fill: #f3e8ff; stroke: #805ad5; }
.txt { font: 700 17px Arial, sans-serif; fill: #1f2933; }
.small { font: 400 13px Arial, sans-serif; fill: #3e4c59; }
.line { stroke: #2f3a45; stroke-width: 2.2; fill: none; marker-end: url(#arrow); }
.money { stroke: #0a7f62; stroke-width: 3; fill: none; marker-end: url(#moneyArrow); }
.dashed { stroke-dasharray: 8 7; }
.legend { font: 400 14px Arial, sans-serif; fill: #3e4c59; }
</style>
</defs>
<rect class="bg" x="0" y="0" width="1400" height="900"/>
<text class="title" x="52" y="54">SHiNE Solana: программы, DAO, счета и движение денег</text>
<text class="subtitle" x="52" y="82">Текущая модель: три Anchor-программы, DAO/authority как управляющий слой, inflow vault и DAO treasury.</text>
<rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text>
<text class="small" x="72" y="206">signer, root_key, device_key</text>
<rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text>
<text class="small" x="72" y="366">buy_ticket*</text>
<rect class="box actor" x="52" y="470" width="210" height="78"/>
<text class="txt" x="72" y="501">Менеджер</text>
<text class="small" x="72" y="526">manager_add_ticket</text>
<rect class="box actor" x="52" y="630" width="210" height="78"/>
<text class="txt" x="72" y="661">Любой caller</text>
<text class="small" x="72" y="686">step_payout</text>
<rect class="box program" x="360" y="126" width="270" height="96"/>
<text class="txt" x="382" y="160">1. shine_login_guard</text>
<text class="small" x="382" y="186">classify_login</text>
<text class="small" x="382" y="205">free / premium / trademark</text>
<rect class="box program" x="360" y="286" width="270" height="112"/>
<text class="txt" x="382" y="320">2. shine_users</text>
<text class="small" x="382" y="346">create_user_pda</text>
<text class="small" x="382" y="365">update_user_pda</text>
<text class="small" x="382" y="384">economy config</text>
<rect class="box program" x="360" y="518" width="270" height="122"/>
<text class="txt" x="382" y="552">3. shine_payments</text>
<text class="small" x="382" y="578">vault, tickets, queues</text>
<text class="small" x="382" y="597">grant_manager_limits</text>
<text class="small" x="382" y="616">step_payout</text>
<rect class="box dao" x="776" y="126" width="270" height="122"/>
<text class="txt" x="798" y="160">SHiNE DAO</text>
<text class="small" x="798" y="186">governance / authority</text>
<text class="small" x="798" y="205">treasury dao_wallet</text>
<text class="small" x="798" y="224">ключи через голосование</text>
<rect class="box pda" x="776" y="306" width="270" height="84"/>
<text class="txt" x="798" y="340">shine_users PDA</text>
<text class="small" x="798" y="365">user_pda, economy_config</text>
<rect class="box pda" x="776" y="500" width="270" height="150"/>
<text class="txt" x="798" y="534">shine_payments PDA</text>
<text class="small" x="798" y="560">config_pda, coef_limit_pda</text>
<text class="small" x="798" y="579">queues_pda</text>
<text class="small" x="798" y="598">inflow_vault_pda</text>
<text class="small" x="798" y="617">ticket_pda, manager_allowance</text>
<rect class="box pda" x="1134" y="500" width="214" height="88"/>
<text class="txt" x="1156" y="534">inflow_vault</text>
<text class="small" x="1156" y="560">деньги регистрации</text>
<rect class="box dao" x="1134" y="170" width="214" height="88"/>
<text class="txt" x="1156" y="204">DAO treasury</text>
<text class="small" x="1156" y="230">dao_wallet</text>
<path class="line" d="M262 189 C300 189, 318 334, 360 334"/>
<text class="small" x="270" y="286">регистрация / update</text>
<path class="line" d="M360 314 C322 250, 320 176, 360 174"/>
<text class="small" x="330" y="250">CPI login</text>
<path class="line" d="M630 342 L776 342"/>
<text class="small" x="646" y="329">создает/обновляет</text>
<path class="money" d="M262 205 C438 432, 1010 390, 1134 530"/>
<text class="small" x="430" y="430">регистрация и лимит -> inflow_vault</text>
<path class="money" d="M262 349 C540 260, 870 244, 1134 214"/>
<text class="small" x="538" y="270">покупка тикета -> DAO treasury</text>
<path class="line" d="M262 509 L360 579"/>
<text class="small" x="276" y="540">создать тикет</text>
<path class="line" d="M630 579 L776 575"/>
<text class="small" x="648" y="562">PDA состояния</text>
<path class="line" d="M1046 575 L1134 548"/>
<path class="money" d="M1134 560 C970 700, 580 728, 262 669"/>
<text class="small" x="650" y="720">call reward caller</text>
<path class="money" d="M1134 536 C860 754, 426 238, 262 194"/>
<text class="small" x="632" y="760">выплата получателю тикета</text>
<path class="money" d="M1241 500 L1241 258"/>
<text class="small" x="1254" y="380">DAO-часть выплат</text>
<path class="line" d="M776 188 L630 342"/>
<text class="small" x="642" y="250">update economy</text>
<path class="line" d="M776 216 C690 290, 666 516, 630 558"/>
<text class="small" x="654" y="438">settings / managers</text>
<path class="line dashed" d="M910 248 C850 702, 620 720, 520 640"/>
<text class="small" x="690" y="690">upgrade-authority: users/payments; login_guard позже</text>
<rect class="box" x="52" y="808" width="1296" height="54"/>
<line x1="74" y1="835" x2="132" y2="835" class="line"/>
<text class="legend" x="146" y="840">логические вызовы и управление</text>
<line x1="374" y1="835" x2="432" y2="835" class="money"/>
<text class="legend" x="446" y="840">движение SOL/lamports</text>
<line x1="682" y1="835" x2="740" y2="835" class="line dashed"/>
<text class="legend" x="754" y="840">будущая передача upgrade-authority DAO</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -0,0 +1,91 @@
# Деплой и инициализация Solana-регистрации (две обязательные программы)
## Коротко
Для рабочей регистрации пользователя нужны **обе** программы:
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`).
Если задеплоена только одна из них — регистрация неработоспособна.
## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`:
`m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR`
## Подтверждение деплоя
- Сеть: `https://api.devnet.solana.com`
- `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- TX deploy: `5iptngPYrLLjPE3Xby24zyNW3edVUnBNLBx785vjojMoq5JNLFNQvLNAm3jNYHbpf2B36qtbpTNzcvUNyRDqm1Mf`
## Порядок деплоя (devnet)
1. Убедиться, что CLI смотрит в devnet и у кошелька есть SOL.
2. Собрать и задеплоить `shine_login_guard`.
3. Собрать и задеплоить `shine_users`.
4. Проверить, что адреса совпадают между:
- `Anchor.toml`
- `declare_id!` в `programs/*/src/lib.rs`
- UI/серверными константами.
5. Выполнить `init_users_economy_config` (один раз на программу `shine_users`).
Пример команд:
```bash
cd shine-solana/shine
solana config get
solana balance
anchor build -p shine_login_guard
anchor deploy -p shine_login_guard
anchor build -p shine_users
anchor deploy -p shine_users
```
## Куда вписаны адреса в проекте
### UI
- Общие Solana-константы:
- `shine-UI/js/solana-programs.js`
- Страница инициализации:
- `shine-UI/js/pages/solana-users-init-view.js`
- Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js`
### Сервер
- Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
## Как запустить инициализацию economy PDA
1. Открыть UI.
2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`.
3. Подключить кошелёк (Phantom, devnet).
4. Нажать `Запустить init_users_economy_config`.
5. Дождаться статуса `Успешно`.
Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -1,9 +1,12 @@
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
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

View File

@ -15,19 +15,33 @@
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
@ -35,7 +49,7 @@
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`.
- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
## Правила ответа
- Пиши содержательно и коротко.

View File

@ -0,0 +1,25 @@
# 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`.
## Планы и задачи
- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`.
- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`.
- Горизонты планов:
- `near/` - ближайшие планы;
- `medium/` - среднесрочные планы;
- `far/` - дальнее будущее.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
- Не начинать реализацию задач из `Future_Features` без явной команды пользователя.
## Проверка после изменений
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.

View File

@ -0,0 +1,26 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```

View File

@ -2,6 +2,7 @@
Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`;
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно;
@ -9,8 +10,7 @@
- вызывает Codex CLI и отправляет ответ в Telegram;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
@ -20,8 +20,8 @@
- `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update;
- `data/history/*.jsonl` — активные истории;
- `data/history/archive/*.jsonl` — архив историй после `/new`.
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
## Локальный запуск
1. Скопировать пример:
@ -29,7 +29,10 @@
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_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
@ -59,4 +62,4 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.

View File

@ -14,11 +14,20 @@ import subprocess
import tempfile
import threading
import time
import traceback
import uuid
from pathlib import Path
from typing import Any
from urllib import error, request
DEFAULT_ALLOWED_PLAYERS = ",".join([
"malvviiina:Милана",
"zodiaktechnika32:Сергей",
"oidasyda:Иван",
"blackbyrd1:Ворон",
"dimasol1:Дима",
])
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat()
@ -33,6 +42,21 @@ def normalize_username(value: str | None) -> str:
return value.lower()
def parse_allowed_players(raw: str) -> dict[str, str]:
players: dict[str, str] = {}
for item in (raw or "").split(","):
part = item.strip()
if not part:
continue
username_part, sep, name_part = part.partition(":")
username = normalize_username(username_part)
if not username:
continue
player_name = (name_part if sep else username_part).strip() or username
players[username] = player_name
return players
def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
text = (text or "").strip()
if not text:
@ -55,6 +79,27 @@ def read_env_file(path: Path) -> dict[str, str]:
return result
class VoiceTranscriptionError(RuntimeError):
def __init__(
self,
user_message: str,
*,
stage: str,
retryable: bool = True,
detail: str = "",
):
super().__init__(user_message)
self.user_message = user_message
self.stage = stage
self.retryable = retryable
self.detail = detail
def log_text(self) -> str:
if self.detail and self.detail != self.user_message:
return f"{self.user_message} stage={self.stage} retryable={self.retryable} detail={self.detail}"
return f"{self.user_message} stage={self.stage} retryable={self.retryable}"
class JsonLineStore:
@staticmethod
def load(path: Path) -> list[dict[str, Any]]:
@ -116,11 +161,39 @@ class TelegramApi:
result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15)
return result.get("result", [])
def send_message(self, chat_id: int, text: str, reply_to_message_id: int | None = None) -> None:
def send_message(self, chat_id: int | str, text: str, reply_to_message_id: int | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
self.call("sendMessage", payload=payload, timeout=30)
return self.call("sendMessage", payload=payload, timeout=30)
def send_voice(
self,
chat_id: int | str,
voice: str,
caption: str = "",
reply_to_message_id: int | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"chat_id": chat_id, "voice": voice}
if caption:
payload["caption"] = caption
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
return self.call("sendVoice", payload=payload, timeout=60)
def send_audio(
self,
chat_id: int | str,
audio: str,
caption: str = "",
reply_to_message_id: int | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"chat_id": chat_id, "audio": audio}
if caption:
payload["caption"] = caption
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
return self.call("sendAudio", payload=payload, timeout=60)
def delete_webhook(self) -> None:
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
@ -134,10 +207,13 @@ class BotConfig:
self.root_dir = root_dir
self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN")
self.allowed_username = normalize_username(env.get("ALLOWED_TELEGRAM_USERNAME", "AidarKC"))
self.allowed_players = parse_allowed_players(env.get("ALLOWED_TELEGRAM_PLAYERS", DEFAULT_ALLOWED_PLAYERS))
self.allowed_channel_username = normalize_username(env.get("ALLOWED_TELEGRAM_CHANNEL_USERNAME", "shine_writing"))
self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot")
self.openai_api_key = env.get("OPENAI_API_KEY", "").strip()
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
self.telegram_file_download_timeout_seconds = int(env.get("TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS", "300"))
self.openai_transcribe_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900"))
self.codex_bin = Path(env.get(
"CODEX_BIN",
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
@ -185,6 +261,20 @@ class ShinePyBotService:
self.last_heartbeat_at: float = 0.0
self.restart_requested = False
def _is_owner(self, username: str) -> bool:
return normalize_username(username) == self.cfg.allowed_username
def _is_allowed_player(self, username: str) -> bool:
return normalize_username(username) in self.cfg.allowed_players
def _is_allowed_user(self, username: str) -> bool:
uname = normalize_username(username)
return uname == self.cfg.allowed_username or uname in self.cfg.allowed_players
def _player_name(self, username: str) -> str:
uname = normalize_username(username)
return self.cfg.allowed_players.get(uname, uname)
def run(self) -> None:
self._ensure_dirs()
self._acquire_single_instance_lock()
@ -250,9 +340,16 @@ class ShinePyBotService:
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
else:
self.state = {}
sessions = self.state.get("user_sessions")
if not isinstance(sessions, dict):
sessions = {}
self.state["user_sessions"] = sessions
if not self.state.get("current_history_file"):
history_file = self._create_new_history_file("initial")
history_file = self._create_new_history_file("initial", self.cfg.allowed_username)
self.state["current_history_file"] = str(history_file)
sessions[self.cfg.allowed_username] = {"current_history_file": str(history_file)}
elif self.cfg.allowed_username not in sessions:
sessions[self.cfg.allowed_username] = {"current_history_file": str(self.state["current_history_file"])}
if not isinstance(self.state.get("next_job_number"), int):
self.state["next_job_number"] = 1
self.state["updated_at"] = now_iso()
@ -320,26 +417,70 @@ class ShinePyBotService:
self._persist_state()
def _current_history_file(self) -> Path:
return Path(self.state["current_history_file"])
return self._current_history_file_for_user(self.cfg.allowed_username)
def _create_new_history_file(self, reason: str) -> Path:
def _history_dirs_for_user(self, username: str) -> tuple[Path, Path]:
uname = normalize_username(username) or "unknown"
history_dir = self.history_dir / uname
archive_dir = history_dir / "archive"
history_dir.mkdir(parents=True, exist_ok=True)
archive_dir.mkdir(parents=True, exist_ok=True)
return history_dir, archive_dir
def _ensure_user_session(self, username: str) -> None:
uname = normalize_username(username) or self.cfg.allowed_username
sessions = self.state.setdefault("user_sessions", {})
if not isinstance(sessions, dict):
sessions = {}
self.state["user_sessions"] = sessions
session = sessions.get(uname)
if isinstance(session, dict) and session.get("current_history_file"):
return
history_file = self._create_new_history_file("initial", uname)
sessions[uname] = {"current_history_file": str(history_file)}
if uname == self.cfg.allowed_username:
self.state["current_history_file"] = str(history_file)
self._persist_state()
def _current_history_file_for_user(self, username: str) -> Path:
uname = normalize_username(username) or self.cfg.allowed_username
self._ensure_user_session(uname)
sessions = self.state.get("user_sessions") or {}
session = sessions.get(uname) or {}
return Path(session["current_history_file"])
def _create_new_history_file(self, reason: str, username: str) -> Path:
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
path = self.history_dir / f"{ts}_{rnd}.jsonl"
JsonLineStore.append(path, {"ts": now_iso(), "type": "history_created", "reason": reason})
history_dir, _ = self._history_dirs_for_user(username)
path = history_dir / f"{ts}_{rnd}.jsonl"
JsonLineStore.append(path, {
"ts": now_iso(),
"type": "history_created",
"reason": reason,
"username": normalize_username(username),
})
return path
def _rotate_history(self, reason: str, username: str) -> Path:
current = self._current_history_file()
uname = normalize_username(username) or self.cfg.allowed_username
current = self._current_history_file_for_user(uname)
_, archive_dir = self._history_dirs_for_user(uname)
if current.exists():
archived = self.history_archive_dir / current.name
archived = archive_dir / current.name
current.replace(archived)
else:
archived = self.history_archive_dir / "(empty)"
new_file = self._create_new_history_file(reason)
self.state["current_history_file"] = str(new_file)
archived = archive_dir / "(empty)"
new_file = self._create_new_history_file(reason, uname)
sessions = self.state.setdefault("user_sessions", {})
if not isinstance(sessions, dict):
sessions = {}
self.state["user_sessions"] = sessions
sessions[uname] = {"current_history_file": str(new_file)}
if uname == self.cfg.allowed_username:
self.state["current_history_file"] = str(new_file)
self._persist_state()
self._append_history_event("history_rotated", {"reason": reason, "username": username, "archived": str(archived)})
self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname)
return archived
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
@ -347,10 +488,28 @@ class ShinePyBotService:
row.update(payload)
JsonLineStore.append(history_path, row)
def _append_history_event(self, event_type: str, payload: dict[str, Any]) -> None:
history_path = self._current_history_file()
def _append_history_event(self, event_type: str, payload: dict[str, Any], username: str | None = None) -> None:
history_path = self._current_history_file_for_user(username or self.cfg.allowed_username)
self._append_history(history_path, "system_event", {"event": event_type, **payload})
def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None:
uname = normalize_username(username)
sent = self.state.get("player_welcome_sent")
if not isinstance(sent, dict):
sent = {}
self.state["player_welcome_sent"] = sent
if sent.get(uname):
return
player_name = self._player_name(uname)
text = (
f"Привет, {player_name}.\n"
"Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
"Команда /new начинает новую сессию и архивирует текущую историю."
)
self._safe_send(chat_id, text, reply_to=message_id)
sent[uname] = now_iso()
self._persist_state()
def _resolve_chat_id(self, chat_id: int) -> int:
migrations = self.state.get("chat_id_migrations")
if not isinstance(migrations, dict):
@ -440,35 +599,39 @@ class ShinePyBotService:
)
if is_channel_post and not is_allowed_channel:
return
if chat_username and chat_username == self.cfg.allowed_channel_username:
self._remember_public_report_chat(chat_id)
# Игнорируем системные сообщения о входе/выходе и смене заголовка/фото.
if message.get("new_chat_members") or message.get("left_chat_member"):
return
if message.get("group_chat_created") or message.get("supergroup_chat_created") or message.get("channel_chat_created"):
return
text = (message.get("text") or message.get("caption") or "").strip()
history_path = self._current_history_file()
if author_username != self.cfg.allowed_username:
if is_channel_post or is_group_message:
self._append_history(history_path, "chat_context_message", {
"chatId": chat_id,
"messageId": message_id,
"updateType": update_type,
"chatType": chat_type,
"chatUsername": chat_username,
"chatTitle": chat_title,
"username": username,
"authorSignature": author_signature,
"text": text,
"hasVoice": bool(message.get("voice")),
"hasAudio": bool(message.get("audio")),
})
if is_group_message:
self._safe_send(chat_id, "Получил сообщение.", reply_to=message_id)
actor_username = normalize_username(author_username)
is_allowed = self._is_allowed_user(actor_username)
is_private = chat_type == "private"
if not is_allowed:
if is_private:
self._safe_send(chat_id, "Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.", reply_to=message_id)
return
if self._is_allowed_player(actor_username) and not is_private:
return
self._ensure_user_session(actor_username)
history_path = self._current_history_file_for_user(actor_username)
if self._is_allowed_player(actor_username):
self._send_player_welcome_once(chat_id, message_id, actor_username)
if not text:
if message.get("voice"):
self._enqueue_voice_job(
chat_id,
message_id,
author_username,
actor_username,
message["voice"].get("file_id"),
media_type="voice",
update_type=update_type,
chat_username=chat_username,
chat_title=chat_title,
@ -480,8 +643,9 @@ class ShinePyBotService:
self._enqueue_voice_job(
chat_id,
message_id,
author_username,
actor_username,
message["audio"].get("file_id"),
media_type="audio",
update_type=update_type,
chat_username=chat_username,
chat_title=chat_title,
@ -493,7 +657,7 @@ class ShinePyBotService:
return
if text.startswith("/"):
self._handle_command(chat_id, message_id, author_username, text)
self._handle_command(chat_id, message_id, actor_username, text)
return
self._append_history(history_path, "incoming_text", {
@ -503,11 +667,11 @@ class ShinePyBotService:
"chatType": chat_type,
"chatUsername": chat_username,
"chatTitle": chat_title,
"username": author_username,
"username": actor_username,
"authorSignature": author_signature,
"text": text,
})
job = self._build_job_base(chat_id, message_id, author_username, str(history_path))
job = self._build_job_base(chat_id, message_id, actor_username, str(history_path))
job["type"] = "text"
job["text"] = text
job["update_type"] = update_type
@ -515,6 +679,8 @@ class ShinePyBotService:
job["chat_username"] = chat_username
job["chat_title"] = chat_title
job["author_signature"] = author_signature
job["role"] = "owner" if self._is_owner(actor_username) else "player"
job["player_name"] = self._player_name(actor_username) if job["role"] == "player" else ""
with self.queue_lock:
self.queue.append(job)
self._persist_queue()
@ -527,6 +693,7 @@ class ShinePyBotService:
username: str,
file_id: str | None,
*,
media_type: str = "voice",
update_type: str = "message",
chat_username: str = "",
chat_title: str = "",
@ -536,7 +703,7 @@ class ShinePyBotService:
if not file_id:
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
return
history_path = self._current_history_file()
history_path = self._current_history_file_for_user(username)
self._append_history(history_path, "incoming_voice", {
"chatId": chat_id,
"messageId": message_id,
@ -547,15 +714,19 @@ class ShinePyBotService:
"username": username,
"authorSignature": author_signature,
"fileId": file_id,
"mediaType": media_type,
})
job = self._build_job_base(chat_id, message_id, username, str(history_path))
job["type"] = "voice"
job["telegram_file_id"] = file_id
job["telegram_media_type"] = media_type
job["update_type"] = update_type
job["chat_type"] = chat_type
job["chat_username"] = chat_username
job["chat_title"] = chat_title
job["author_signature"] = author_signature
job["role"] = "owner" if self._is_owner(username) else "player"
job["player_name"] = self._player_name(username) if job["role"] == "player" else ""
with self.queue_lock:
self.queue.append(job)
self._persist_queue()
@ -579,8 +750,11 @@ class ShinePyBotService:
"chat_username": "",
"chat_title": "",
"author_signature": "",
"role": "owner",
"player_name": "",
"text": "",
"telegram_file_id": "",
"telegram_media_type": "",
"history_file": history_file,
"attempts": 0,
"retry_reason": "",
@ -592,8 +766,9 @@ class ShinePyBotService:
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
lower = text.lower()
is_owner = self._is_owner(username)
if lower in ("/start", "/help"):
self._safe_send(chat_id, self._help_text(), reply_to=message_id)
self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
return
if lower == "/status":
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
@ -606,11 +781,14 @@ class ShinePyBotService:
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
return
if lower in ("/restart_service", "/restart"):
if not is_owner:
self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
return
self._append_history_event("restart_service_requested", {
"chatId": chat_id,
"messageId": message_id,
"username": username,
})
}, username=username)
self._safe_send(
chat_id,
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
@ -644,17 +822,19 @@ class ShinePyBotService:
self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id)
return
def _help_text(self) -> str:
return (
"Доступные команды:\n"
"/status — активная задача и размер очереди\n"
"/queue — список задач в очереди\n"
"/stop — остановить текущую задачу\n"
"/cancel <id|all> — удалить задачу по id (префикс) или все\n"
"/new — архивировать историю и начать новую\n"
"/restart_service — перезапустить сервис через systemd\n"
"/help — эта справка"
)
def _help_text(self, *, is_owner: bool) -> str:
lines = [
"Доступные команды:",
"/status — активная задача и размер очереди",
"/queue — список задач в очереди",
"/stop — остановить текущую задачу",
"/cancel <id|all> — удалить задачу по id (префикс) или все",
"/new — архивировать историю и начать новую",
"/help — эта справка",
]
if is_owner:
lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
return "\n".join(lines)
def _status_text(self) -> str:
with self.queue_lock:
@ -768,6 +948,7 @@ class ShinePyBotService:
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
self._mark_job_done(job_id)
self._send_private_job_public_report(job, answer)
except Exception as e:
if self.stop_current_job:
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
@ -775,6 +956,15 @@ class ShinePyBotService:
self._mark_job_removed(job_id)
self.stop_current_job = False
return
if isinstance(e, VoiceTranscriptionError):
self._append_history(history_path, "voice_transcription_failed", {
"jobId": job_id,
"jobNum": job_num,
"stage": e.stage,
"retryable": e.retryable,
"error": e.user_message,
"detail": e.detail,
})
self._handle_job_failure(job, e)
def _build_prompt(self, job: dict[str, Any]) -> str:
@ -782,6 +972,19 @@ class ShinePyBotService:
retry_reason = (job.get("retry_reason") or "").strip()
if retry_reason:
retry_block = f"\n\nПометка retry: {retry_reason}"
role = (job.get("role") or "owner").strip().lower()
player_block = ""
if role == "player":
player_name = (job.get("player_name") or "").strip()
player_dir = self.cfg.codex_workdir / "Players" / (job.get("username") or "")
player_block = (
"\n\nРежим игрока (обязательно):\n"
f"- Пользователь: {player_name} (@{job.get('username')}).\n"
f"- Рабочая папка игрока: {player_dir}\n"
"- Код проекта не изменять.\n"
"- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.\n"
"- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока."
)
return (
"Пришло сообщение в Telegram.\n"
f"Тип: {job.get('type')}\n"
@ -794,7 +997,7 @@ class ShinePyBotService:
f"{job.get('text')}\n\n"
f"История диалога (JSONL): {job.get('history_file')}\n"
f"Инструкции агента: {self.cfg.agent_instructions_file}\n"
f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{retry_block}"
f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}"
)
def _run_codex(self, prompt: str, chat_id: int, message_id: int, job_id: str, job_num: Any) -> str:
@ -921,7 +1124,11 @@ class ShinePyBotService:
chat_id = int(job["chat_id"])
message_id = int(job["message_id"])
error_text = str(err).strip() or err.__class__.__name__
print(f"[py-bot] Ошибка job={job_id[:8]}: {error_text}", flush=True)
user_error_text = self._user_error_text(err)
retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable
log_error_text = err.log_text() if isinstance(err, VoiceTranscriptionError) else error_text
print(f"[py-bot] Ошибка job={job_id[:8]}: {log_error_text}", flush=True)
print(traceback.format_exc(), flush=True)
with self.queue_lock:
target = next((j for j in self.queue if j.get("id") == job_id), None)
@ -931,7 +1138,7 @@ class ShinePyBotService:
target["attempts"] = attempts
target["last_error"] = error_text[:1000]
target["updated_at"] = now_iso()
if attempts < self.cfg.max_retries:
if retryable and attempts < self.cfg.max_retries:
target["status"] = "pending"
target["retry_reason"] = error_text[:200]
self._persist_queue()
@ -942,9 +1149,19 @@ class ShinePyBotService:
will_retry = False
if will_retry:
self._safe_send(chat_id, f"Ошибка задачи #{job_num}, повтор: {attempts}/{self.cfg.max_retries}", reply_to=message_id)
self._safe_send(
chat_id,
f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.",
reply_to=message_id,
)
else:
self._safe_send(chat_id, f"Ошибка задачи #{job_num}. Лимит попыток исчерпан.", reply_to=message_id)
self._safe_send(chat_id, f"{user_error_text}\nЗадача #{job_num} остановлена.", reply_to=message_id)
def _user_error_text(self, err: Exception) -> str:
if isinstance(err, VoiceTranscriptionError):
return f"Не удалось распознать голосовое: {err.user_message}"
error_text = str(err).strip() or err.__class__.__name__
return f"Ошибка выполнения задачи: {error_text}"
def _mark_job_done(self, job_id: str) -> None:
with self.queue_lock:
@ -956,27 +1173,145 @@ class ShinePyBotService:
self.queue = [j for j in self.queue if j.get("id") != job_id]
self._persist_queue()
def _safe_send(self, chat_id: int, text: str, reply_to: int | None = None) -> None:
text = (text or "").strip()
if not text:
def _remember_public_report_chat(self, chat_id: int) -> None:
if self.state.get("public_report_chat_id") == chat_id:
return
if len(text) > 3900:
text = text[:3900] + "\n...[обрезано]"
resolved_chat_id = self._resolve_chat_id(chat_id)
resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
self.state["public_report_chat_id"] = chat_id
self.state["updated_at"] = now_iso()
self._persist_state()
def _public_report_chat_id(self) -> int | str | None:
chat_id = self.state.get("public_report_chat_id")
if isinstance(chat_id, int):
return self._resolve_chat_id(chat_id)
if self.cfg.allowed_channel_username:
return f"@{self.cfg.allowed_channel_username}"
return None
def _send_private_job_public_report(self, job: dict[str, Any], answer: str) -> None:
if job.get("chat_type") != "private":
return
report_chat_id = self._public_report_chat_id()
if report_chat_id is None:
return
job_num = job.get("num", "?")
source_text = (job.get("text") or "").strip()
if not source_text:
source_text = "(пустой текст запроса)"
role = (job.get("role") or "owner").strip().lower()
author_label = "Айдар"
if role == "player":
player_name = (job.get("player_name") or "").strip() or job.get("username") or "Игрок"
author_label = f"{player_name} (@{job.get('username')})"
if job.get("type") == "voice":
voice_file_id = (job.get("telegram_file_id") or "").strip()
media_type = (job.get("telegram_media_type") or "voice").strip()
request_caption = self._trim_telegram_caption(
f"{author_label} сделал {media_type}-запрос, задача #{job_num}.\n\n"
f"Распознанный текст:\n{source_text}"
)
request_message_id = None
if voice_file_id:
request_message_id = self._safe_send_telegram_file(
report_chat_id,
voice_file_id,
media_type=media_type,
caption=request_caption,
)
if request_message_id is None:
request_message_id = self._safe_send(report_chat_id, request_caption)
else:
request_report = (
f"{author_label} сделал запрос, задача #{job_num}.\n\n"
f"{source_text}"
)
request_message_id = self._safe_send(report_chat_id, request_report)
if request_message_id is None:
return
answer_text = (answer or "").strip() or "(пустой ответ)"
answer_chunks = split_long_text(f"Ответ на задачу #{job_num}:\n\n{answer_text}")
for chunk in answer_chunks:
self._safe_send(report_chat_id, chunk, reply_to=request_message_id)
@staticmethod
def _trim_telegram_caption(text: str, limit: int = 1000) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[:limit].rstrip() + "\n...[обрезано]"
def _safe_send_telegram_file(
self,
chat_id: int | str,
file_id: str,
*,
media_type: str = "voice",
caption: str = "",
reply_to: int | None = None,
) -> int | None:
file_id = (file_id or "").strip()
if not file_id:
return None
caption = self._trim_telegram_caption(caption)
resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
def send(target_chat_id: int | str, target_reply_to: int | None) -> dict[str, Any]:
if media_type == "audio":
return self.telegram.send_audio(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
return self.telegram.send_voice(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
try:
self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
sent = send(resolved_chat_id, resolved_reply_to)
result = sent.get("result") or {}
message_id = result.get("message_id")
return message_id if isinstance(message_id, int) else None
except Exception as e:
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
if migrate_to_chat_id is not None:
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
if isinstance(resolved_chat_id, int):
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error")
try:
self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
return
sent = send(migrate_to_chat_id, None)
result = sent.get("result") or {}
message_id = result.get("message_id")
return message_id if isinstance(message_id, int) else None
except Exception as retry_error:
print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True)
return None
print(f"[py-bot] sendFile error: {e}", flush=True)
return None
def _safe_send(self, chat_id: int | str, text: str, reply_to: int | None = None) -> int | None:
text = (text or "").strip()
if not text:
return None
if len(text) > 3900:
text = text[:3900] + "\n...[обрезано]"
resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
try:
sent = self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
result = sent.get("result") or {}
message_id = result.get("message_id")
return message_id if isinstance(message_id, int) else None
except Exception as e:
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
if migrate_to_chat_id is not None:
if isinstance(resolved_chat_id, int):
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
try:
sent = self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
result = sent.get("result") or {}
message_id = result.get("message_id")
return message_id if isinstance(message_id, int) else None
except Exception as retry_error:
print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True)
return
return None
print(f"[py-bot] sendMessage error: {e}", flush=True)
return None
def _schedule_self_restart(self) -> None:
if self.restart_requested:
@ -992,26 +1327,94 @@ class ShinePyBotService:
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
if not self.cfg.openai_api_key:
raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice")
raise VoiceTranscriptionError(
"не настроен ключ OpenAI для распознавания.",
stage="config",
retryable=False,
)
file_id = (job.get("telegram_file_id") or "").strip()
if not file_id:
raise RuntimeError("Пустой telegram_file_id")
raise VoiceTranscriptionError(
"Telegram не передал идентификатор файла.",
stage="telegram_file_id",
retryable=False,
)
job_id = str(job.get("id") or "")[:8]
job_num = job.get("num", "?")
media_type = (job.get("telegram_media_type") or "voice").strip()
started_at = time.time()
print(f"[py-bot] transcribe start job={job_id} num={job_num} media={media_type}", flush=True)
file_bytes, filename = self._download_telegram_file(file_id)
print(
f"[py-bot] transcribe downloaded job={job_id} filename={filename} size={len(file_bytes)} bytes",
flush=True,
)
text = self._openai_transcribe(file_bytes, filename).strip()
if not text:
raise RuntimeError("Распознавание вернуло пустой текст")
raise VoiceTranscriptionError(
"сервис распознавания вернул пустой текст. Возможно, в записи нет слышимой речи или качество звука слишком низкое.",
stage="empty_text",
retryable=False,
)
elapsed = self._format_duration(int(time.time() - started_at))
print(f"[py-bot] transcribe done job={job_id} chars={len(text)} elapsed={elapsed}", flush=True)
return text
def _download_telegram_file(self, file_id: str) -> tuple[bytes, str]:
result = self.telegram.call("getFile", {"file_id": file_id}, timeout=60)
try:
result = self.telegram.call("getFile", {"file_id": file_id}, timeout=120)
except TimeoutError as e:
raise VoiceTranscriptionError(
"Telegram долго не отдавал информацию о файле.",
stage="telegram_get_file_timeout",
detail=str(e),
) from e
except Exception as e:
raise VoiceTranscriptionError(
"не удалось получить информацию о файле из Telegram.",
stage="telegram_get_file",
detail=str(e),
) from e
info = result.get("result") or {}
file_path = info.get("file_path")
if not file_path:
raise RuntimeError("Telegram getFile не вернул file_path")
raise VoiceTranscriptionError(
"Telegram не вернул путь к файлу.",
stage="telegram_file_path",
retryable=True,
detail=json.dumps(info, ensure_ascii=False)[:1000],
)
file_url = f"https://api.telegram.org/file/bot{self.cfg.telegram_bot_token}/{file_path}"
req = request.Request(file_url, method="GET")
with request.urlopen(req, timeout=120) as resp:
data = resp.read()
try:
with request.urlopen(req, timeout=self.cfg.telegram_file_download_timeout_seconds) as resp:
data = resp.read()
except TimeoutError as e:
raise VoiceTranscriptionError(
f"Telegram не успел отдать аудиофайл за {self.cfg.telegram_file_download_timeout_seconds} секунд.",
stage="telegram_download_timeout",
detail=str(e),
) from e
except error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
raise VoiceTranscriptionError(
f"Telegram вернул ошибку HTTP {e.code} при скачивании аудио.",
stage="telegram_download_http",
retryable=e.code >= 500 or e.code == 429,
detail=detail[:1000],
) from e
except error.URLError as e:
raise VoiceTranscriptionError(
"не удалось скачать аудиофайл из Telegram из-за сетевой ошибки.",
stage="telegram_download_network",
detail=str(e.reason),
) from e
if not data:
raise VoiceTranscriptionError(
"Telegram отдал пустой аудиофайл.",
stage="telegram_download_empty",
retryable=True,
)
original_name = Path(file_path).name or "audio.ogg"
lower = original_name.lower()
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
@ -1051,11 +1454,40 @@ class ShinePyBotService:
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
try:
with request.urlopen(req, timeout=240) as resp:
with request.urlopen(req, timeout=self.cfg.openai_transcribe_timeout_seconds) as resp:
return resp.read().decode("utf-8", errors="replace")
except TimeoutError as e:
raise VoiceTranscriptionError(
f"OpenAI не успел распознать аудио за {self.cfg.openai_transcribe_timeout_seconds} секунд.",
stage="openai_transcribe_timeout",
detail=str(e),
) from e
except error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenAI transcribe HTTP {e.code}: {detail}") from e
if e.code == 400:
user_message = "OpenAI не принял аудиофайл для распознавания."
elif e.code == 401:
user_message = "OpenAI отклонил ключ API для распознавания."
elif e.code == 413:
user_message = "аудиофайл слишком большой для распознавания OpenAI."
elif e.code == 429:
user_message = "OpenAI временно ограничил распознавание из-за лимита запросов."
elif e.code >= 500:
user_message = "OpenAI временно не смог обработать распознавание."
else:
user_message = f"OpenAI вернул ошибку HTTP {e.code} при распознавании."
raise VoiceTranscriptionError(
user_message,
stage="openai_transcribe_http",
retryable=e.code == 429 or e.code >= 500,
detail=detail[:1500],
) from e
except error.URLError as e:
raise VoiceTranscriptionError(
"не удалось отправить аудио в OpenAI из-за сетевой ошибки.",
stage="openai_transcribe_network",
detail=str(e.reason),
) from e
@staticmethod
def _extract_codex_user_note(line: str) -> str | None:

View File

@ -59,6 +59,7 @@ import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
@ -98,6 +99,7 @@ const routes = {
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
'solana-users-init-view': solanaUsersInitView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,

View File

@ -262,6 +262,7 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
@ -275,8 +276,10 @@ export function render({ navigate }) {
const forceUpdateBtn = card.querySelector('#settings-force-ui-update');
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({

View File

@ -0,0 +1,196 @@
import { renderHeader } from '../components/header.js';
import {
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
SOLANA_CLUSTER,
} from '../solana-programs.js';
import { state } from '../state.js';
export const pageMeta = { id: 'solana-users-init-view', title: 'Solana Init (users)' };
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) {
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
}
return solanaLibPromise;
}
function getProvider() {
const p = globalThis?.solana;
if (!p || !p.isPhantom) return null;
return p;
}
function shortAddr(value = '') {
const v = String(value || '').trim();
if (v.length < 12) return v;
return `${v.slice(0, 6)}...${v.slice(-6)}`;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Подключите кошелёк и выполните init_users_economy_config.';
const programId = SHINE_USERS_PROGRAM_ID;
const programInput = document.createElement('input');
programInput.className = 'input';
programInput.type = 'text';
programInput.readOnly = true;
programInput.value = programId;
const rpcInput = document.createElement('input');
rpcInput.className = 'input';
rpcInput.type = 'text';
rpcInput.readOnly = true;
rpcInput.value = String(state.entrySettings.solanaServer || '');
const walletLine = document.createElement('p');
walletLine.className = 'meta-muted';
walletLine.textContent = 'Кошелёк: не подключен';
const economyPdaLine = document.createElement('p');
economyPdaLine.className = 'meta-muted';
economyPdaLine.textContent = 'PDA economy config: —';
const txLine = document.createElement('p');
txLine.className = 'meta-muted';
txLine.textContent = 'TX: —';
const connectBtn = document.createElement('button');
connectBtn.className = 'text-btn';
connectBtn.type = 'button';
connectBtn.textContent = 'Подключить кошелёк';
const initBtn = document.createElement('button');
initBtn.className = 'primary-btn';
initBtn.type = 'button';
initBtn.textContent = 'Запустить init_users_economy_config';
initBtn.disabled = true;
let provider = null;
let walletPubkey = null;
let economyPda = null;
async function recomputePda() {
const solana = await loadSolanaLib();
const pid = new solana.PublicKey(programId);
const [pda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
pid,
);
economyPda = pda;
economyPdaLine.textContent = `PDA economy config: ${pda.toBase58()}`;
}
connectBtn.addEventListener('click', async () => {
status.textContent = 'Подключение кошелька...';
try {
provider = getProvider();
if (!provider) throw new Error('Phantom не найден');
const result = await provider.connect();
walletPubkey = result?.publicKey || provider.publicKey;
if (!walletPubkey) throw new Error('Кошелёк не вернул public key');
walletLine.textContent = `Кошелёк: ${walletPubkey.toBase58()} (${shortAddr(walletPubkey.toBase58())})`;
initBtn.disabled = false;
status.textContent = 'Кошелёк подключен.';
} catch (e) {
status.textContent = `Ошибка подключения: ${e?.message || 'unknown'}`;
}
});
initBtn.addEventListener('click', async () => {
if (!provider || !walletPubkey || !economyPda) return;
initBtn.disabled = true;
status.textContent = 'Отправка транзакции...';
txLine.textContent = 'TX: —';
try {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(state.entrySettings.solanaServer || ''), 'confirmed');
const programPubkey = new solana.PublicKey(programId);
const discriminator = Uint8Array.from([13, 16, 103, 175, 121, 137, 166, 222]);
const ix = new solana.TransactionInstruction({
programId: programPubkey,
keys: [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: economyPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
],
data: discriminator,
});
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
const tx = new solana.Transaction({
feePayer: walletPubkey,
blockhash,
lastValidBlockHeight,
}).add(ix);
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
txLine.textContent = `TX: ${sig}`;
status.textContent = 'Успешно: init_users_economy_config выполнен.';
} catch (e) {
const message = String(e?.message || 'unknown');
txLine.textContent = 'TX: ошибка';
if (message.toLowerCase().includes('already') || message.includes('1000')) {
status.textContent = 'PDA уже инициализирован. Повторный init не требуется.';
} else {
status.textContent = `Ошибка init: ${message}`;
}
} finally {
initBtn.disabled = false;
}
});
void recomputePda();
card.append(
(() => {
const t = document.createElement('p');
t.className = 'meta-muted';
t.textContent = `Cluster: ${SOLANA_CLUSTER}`;
return t;
})(),
(() => {
const label = document.createElement('label');
label.className = 'field-label';
label.textContent = 'Program ID (shine_users)';
return label;
})(),
programInput,
(() => {
const label = document.createElement('label');
label.className = 'field-label';
label.textContent = 'RPC endpoint';
return label;
})(),
rpcInput,
walletLine,
economyPdaLine,
txLine,
connectBtn,
initBtn,
status,
);
screen.append(
renderHeader({
title: 'Solana Init (users)',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
card,
);
return screen;
}

View File

@ -182,7 +182,8 @@ export function resolveToolbarActive(pageId) {
pageId === 'device-session-view' ||
pageId === 'language-view' ||
pageId === 'app-log-view' ||
pageId === 'pwa-diagnostics-view'
pageId === 'pwa-diagnostics-view' ||
pageId === 'solana-users-init-view'
) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';

View File

@ -1,8 +1,9 @@
import { deriveEd25519FromPassword } from './crypto-utils.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
let solanaLibPromise = null;

View File

@ -0,0 +1,8 @@
export const SOLANA_CLUSTER = 'devnet';
export const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
// Программа регистрации пользователей SHiNE (shine_users), задеплоена в devnet.
export const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
export const SHINE_USERS_ECONOMY_CONFIG_SEED = 'shine_users_economy_config';
export const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
export const SHINE_PAYMENTS_PROGRAM_ID = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';

View File

@ -1,5 +1,6 @@
import { AuthService } from './services/auth-service.js';
import { listStoredMessages, putStoredMessage } from './services/message-store.js';
import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
@ -76,7 +77,7 @@ function inferTunnelWsUrl() {
}
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl();
const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com';
const DEFAULT_SOLANA_SERVER = SOLANA_ENDPOINT_DEFAULT;
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;

View File

@ -0,0 +1,21 @@
package utils.config;
/**
* Публичные адреса Solana-программ SHiNE (жёстко зафиксированы для devnet).
* Секреты на сервере не хранятся.
*/
public final class SolanaProgramsConfig {
private SolanaProgramsConfig() {}
public static final String SOLANA_CLUSTER = "devnet";
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
// Программа регистрации пользователей (shine_users), задеплоена в devnet.
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR";
}

View File

@ -18,6 +18,7 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
@ -182,6 +183,10 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
node.setVersionsTotal(versions.size());
node.setText(versions.get(versions.size() - 1).getText());
if (row.blockBytes != null) {
node.setRawBlockB64(Base64.getEncoder().encodeToString(row.blockBytes));
}
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);

View File

@ -21,9 +21,13 @@ public class Net_GetMessageThread_Response extends Net_Response {
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
private ChannelInfo channelInfo;
private String rawBlockB64;
public ChannelInfo getChannelInfo() { return channelInfo; }
public void setChannelInfo(ChannelInfo channelInfo) { this.channelInfo = channelInfo; }
public String getRawBlockB64() { return rawBlockB64; }
public void setRawBlockB64(String rawBlockB64) { this.rawBlockB64 = rawBlockB64; }
}
public static class ChannelInfo {

View File

@ -51,7 +51,7 @@
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
@ -63,7 +63,7 @@
После первых 9 полей идет набор типизированных блоков:
```text
UserPdaRecordV2
UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
@ -89,7 +89,7 @@ UserPdaRecordV2
Правила:
- неизвестный `block_type` в `format_major = 2` считается ошибкой;
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;

View File

@ -12,7 +12,7 @@ use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use std::str::FromStr;
const MAGIC: &[u8; 5] = b"SHiNE";
const FORMAT_MAJOR: u8 = 2;
const FORMAT_MAJOR: u8 = 1;
const FORMAT_MINOR: u8 = 0;
const MAX_SYNC_SERVERS: usize = 32;
const MAX_AUTO_REALLOC_INCREASE: usize = 10_000;

View File

@ -11,7 +11,7 @@ import { expect } from "chai";
import { Shine } from "../target/types/shine";
const MAGIC = Buffer.from("SHiNE", "utf8");
const FORMAT_MAJOR = 2;
const FORMAT_MAJOR = 1;
const FORMAT_MINOR = 0;
const ZERO_HASH = Buffer.alloc(32, 0);
const LAST_BLOCK_STATE_PREFIX = Buffer.from("SHiNE_LAST_BLOCK", "utf8");