chore: зафиксированы все текущие изменения проекта
This commit is contained in:
parent
8c5de781ea
commit
8941582d54
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@ -2,5 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="" vcs="Git" />
|
<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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
14
AGENTS.md
14
AGENTS.md
@ -11,7 +11,9 @@
|
|||||||
## Сервис агента-кодера
|
## Сервис агента-кодера
|
||||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из 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-модуль
|
||||||
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
|
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
|
||||||
@ -19,6 +21,13 @@
|
|||||||
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
|
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
|
||||||
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
|
- В 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 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`.
|
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
|
||||||
@ -99,6 +108,9 @@
|
|||||||
|
|
||||||
## Будущие фичи
|
## Будущие фичи
|
||||||
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
|
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
|
||||||
|
- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
|
||||||
|
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
|
||||||
|
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
|
||||||
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
|
||||||
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
|
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
|
||||||
- какие файлы и участки отключены;
|
- какие файлы и участки отключены;
|
||||||
|
|||||||
@ -1,14 +1,42 @@
|
|||||||
# Будущие фичи
|
# Будущие фичи
|
||||||
|
|
||||||
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку.
|
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
|
||||||
|
|
||||||
|
## Горизонты планирования
|
||||||
|
|
||||||
|
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
|
||||||
|
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
|
||||||
|
- `far/` - дальнее будущее: идеи без понятного срока возврата.
|
||||||
|
|
||||||
|
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
|
||||||
|
|
||||||
## Как использовать
|
## Как использовать
|
||||||
|
|
||||||
1. Каждая будущая фича описывается отдельным markdown-файлом.
|
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
|
||||||
2. В файле нужно фиксировать:
|
2. В файле нужно фиксировать:
|
||||||
- зачем нужна фича;
|
- зачем нужна фича;
|
||||||
- что уже было сделано в коде;
|
- к какому сроку или горизонту она относится;
|
||||||
- что временно отключено или закомментировано;
|
- что нужно сделать;
|
||||||
|
- какие вопросы нужно уточнить перед реализацией;
|
||||||
|
- что уже было сделано в коде, если фича частично реализована;
|
||||||
|
- что временно отключено или закомментировано, если применимо;
|
||||||
- какие документы нужно обновить при возврате к задаче;
|
- какие документы нужно обновить при возврате к задаче;
|
||||||
- с какого места продолжать разработку.
|
- с какого места продолжать разработку.
|
||||||
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
|
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` - кошелёк и пополнение баланса сияния через блокчейн.
|
||||||
|
|
||||||
|
### Дальнее будущее
|
||||||
|
|
||||||
|
- Сейчас задач нет.
|
||||||
|
|||||||
5
Dev_Docs/Future_Features/far/README.md
Normal file
5
Dev_Docs/Future_Features/far/README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Дальнее будущее
|
||||||
|
|
||||||
|
Сейчас в этом горизонте нет активных идей.
|
||||||
|
|
||||||
|
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
|
||||||
@ -3,6 +3,12 @@
|
|||||||
- Статус:
|
- Статус:
|
||||||
`future`
|
`future`
|
||||||
|
|
||||||
|
- Горизонт:
|
||||||
|
`medium`
|
||||||
|
|
||||||
|
- Ориентир:
|
||||||
|
1-2 месяца
|
||||||
|
|
||||||
- Решение от 2026-05-24:
|
- Решение от 2026-05-24:
|
||||||
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.
|
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.
|
||||||
|
|
||||||
@ -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. Ошибочные или повторные операции не начисляют баланс дважды.
|
||||||
@ -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. На мобильном экране шапка не ломается.
|
||||||
@ -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>/` без отдельного подтверждения.
|
||||||
@ -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. Возврат назад в приложение не ломает состояние регистрации/кошелька.
|
||||||
@ -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`.
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Отчёт private-запросов агента в группу
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
|
||||||
|
|
||||||
|
- первым сообщением исходный запрос;
|
||||||
|
- вторым сообщением, reply на первое, финальный ответ Codex.
|
||||||
|
|
||||||
|
Промежуточные статусы выполнения в группу не дублируются.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
1. Отправить боту личный текстовый запрос.
|
||||||
|
2. Дождаться полного ответа в личном чате.
|
||||||
|
3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
|
||||||
|
4. Проверить, что итоговый ответ опубликован reply на это сообщение.
|
||||||
|
5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
pending
|
||||||
@ -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
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
# Улучшенная обработка длинных voice/audio
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
|
||||||
|
- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
|
||||||
|
- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
|
||||||
|
|
||||||
|
## Как проверять
|
||||||
|
- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
|
||||||
|
- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
|
||||||
|
- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
|
||||||
|
- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
|
||||||
|
- В логах сервиса видны стадия и техническая причина сбоя.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
pending
|
||||||
@ -51,7 +51,7 @@
|
|||||||
| N | Поле | Тип | Размер | Правило |
|
| N | Поле | Тип | Размер | Правило |
|
||||||
|---|------|-----|--------|---------|
|
|---|------|-----|--------|---------|
|
||||||
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
|
||||||
| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
|
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
|
||||||
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
|
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
|
||||||
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
|
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
|
||||||
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
|
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
|
||||||
@ -63,7 +63,7 @@
|
|||||||
После первых 9 полей идет набор типизированных блоков:
|
После первых 9 полей идет набор типизированных блоков:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
UserPdaRecordV2
|
UserPdaRecordV1
|
||||||
- fixed_header: поля 1..9
|
- fixed_header: поля 1..9
|
||||||
- blocks_count: u8
|
- blocks_count: u8
|
||||||
- blocks: TypedBlock[blocks_count]
|
- blocks: TypedBlock[blocks_count]
|
||||||
@ -89,7 +89,7 @@ UserPdaRecordV2
|
|||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
|
||||||
- неизвестный `block_type` в `format_major = 2` считается ошибкой;
|
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
|
||||||
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
||||||
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
|
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
|
||||||
- каждый обязательный блок должен встречаться ровно один раз;
|
- каждый обязательный блок должен встречаться ровно один раз;
|
||||||
|
|||||||
166
Dev_Docs/Solana_Architecture/README.md
Normal file
166
Dev_Docs/Solana_Architecture/README.md
Normal 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)
|
||||||
110
Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md
Normal file
110
Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md
Normal 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` на каждого менеджера.
|
||||||
74
Dev_Docs/Solana_Architecture/details/shine_dao.md
Normal file
74
Dev_Docs/Solana_Architecture/details/shine_dao.md
Normal 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-счету.
|
||||||
|
|
||||||
58
Dev_Docs/Solana_Architecture/details/shine_login_guard.md
Normal file
58
Dev_Docs/Solana_Architecture/details/shine_login_guard.md
Normal 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`.
|
||||||
173
Dev_Docs/Solana_Architecture/details/shine_payments.md
Normal file
173
Dev_Docs/Solana_Architecture/details/shine_payments.md
Normal 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` остается открытым для любого подписанта, чтобы выплаты не зависели от одного оператора.
|
||||||
136
Dev_Docs/Solana_Architecture/details/shine_users.md
Normal file
136
Dev_Docs/Solana_Architecture/details/shine_users.md
Normal 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` остаются доступными обычным пользователям при корректных подписях и оплате.
|
||||||
54
Dev_Docs/Solana_Architecture/schemes/architecture.mmd
Normal file
54
Dev_Docs/Solana_Architecture/schemes/architecture.mmd
Normal 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
|
||||||
|
|
||||||
BIN
Dev_Docs/Solana_Architecture/schemes/architecture.png
Normal file
BIN
Dev_Docs/Solana_Architecture/schemes/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
139
Dev_Docs/Solana_Architecture/schemes/architecture.svg
Normal file
139
Dev_Docs/Solana_Architecture/schemes/architecture.svg
Normal 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 |
91
Dev_Docs/Инициализация_Solana_регистрации/README.md
Normal file
91
Dev_Docs/Инициализация_Solana_регистрации/README.md
Normal 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-классификации логина.
|
||||||
|
Несовпадение адреса приведёт к ошибке регистрации.
|
||||||
1
Players/blackbyrd1/files/.gitkeep
Normal file
1
Players/blackbyrd1/files/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/blackbyrd1/history/.gitkeep
Normal file
1
Players/blackbyrd1/history/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/dimasol1/files/.gitkeep
Normal file
1
Players/dimasol1/files/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/dimasol1/history/.gitkeep
Normal file
1
Players/dimasol1/history/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/malvviiina/files/.gitkeep
Normal file
1
Players/malvviiina/files/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/malvviiina/history/.gitkeep
Normal file
1
Players/malvviiina/history/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/oidasyda/files/.gitkeep
Normal file
1
Players/oidasyda/files/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/oidasyda/history/.gitkeep
Normal file
1
Players/oidasyda/history/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/zodiaktechnika32/files/.gitkeep
Normal file
1
Players/zodiaktechnika32/files/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
Players/zodiaktechnika32/history/.gitkeep
Normal file
1
Players/zodiaktechnika32/history/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -1,9 +1,12 @@
|
|||||||
TELEGRAM_BOT_TOKEN=replace_me
|
TELEGRAM_BOT_TOKEN=replace_me
|
||||||
OPENAI_API_KEY=replace_me
|
OPENAI_API_KEY=replace_me
|
||||||
ALLOWED_TELEGRAM_USERNAME=AidarKC
|
ALLOWED_TELEGRAM_USERNAME=AidarKC
|
||||||
|
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
|
||||||
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
|
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
|
||||||
BOT_USERNAME=aidar_su_bot
|
BOT_USERNAME=aidar_su_bot
|
||||||
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
|
||||||
|
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
|
||||||
|
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
|
||||||
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
|
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
|
||||||
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
|
||||||
CODEX_TIMEOUT_SECONDS=900
|
CODEX_TIMEOUT_SECONDS=900
|
||||||
|
|||||||
@ -15,19 +15,33 @@
|
|||||||
|
|
||||||
## Авторитет команд и история
|
## Авторитет команд и история
|
||||||
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
||||||
- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
|
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
|
||||||
- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
|
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
|
||||||
- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
|
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
|
||||||
- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
|
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
|
||||||
- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
|
|
||||||
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
|
||||||
|
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
|
||||||
|
|
||||||
## Очередь и состояние
|
## Очередь и состояние
|
||||||
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
|
||||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
||||||
- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно.
|
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
||||||
|
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
||||||
|
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
||||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
- Если 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
|
## Локальный запуск и systemd
|
||||||
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
|
||||||
@ -35,7 +49,7 @@
|
|||||||
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
|
||||||
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
|
||||||
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
|
||||||
- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`.
|
- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
|
||||||
|
|
||||||
## Правила ответа
|
## Правила ответа
|
||||||
- Пиши содержательно и коротко.
|
- Пиши содержательно и коротко.
|
||||||
|
|||||||
25
SHiNE-agent-bot-coder/AGENTS.md
Normal file
25
SHiNE-agent-bot-coder/AGENTS.md
Normal 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, когда это уместно.
|
||||||
|
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
|
||||||
26
SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md
Normal file
26
SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Промпты для режима игроков (на согласование)
|
||||||
|
|
||||||
|
## 1) Базовый служебный промпт (добавка к задаче игрока)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Режим игрока (обязательно):
|
||||||
|
- Пользователь: <Имя> (@<username>).
|
||||||
|
- Рабочая папка игрока: <project>/Players/<username>
|
||||||
|
- Код проекта не изменять.
|
||||||
|
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
|
||||||
|
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Приветственное сообщение игроку (один раз)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Привет, <Имя>.
|
||||||
|
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
|
||||||
|
Команда /new начинает новую сессию и архивирует текущую историю.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Отказ неизвестному пользователю
|
||||||
|
|
||||||
|
```text
|
||||||
|
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
|
||||||
|
```
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Локальный Telegram-бот-сервис для пользователя `ai`:
|
Локальный Telegram-бот-сервис для пользователя `ai`:
|
||||||
- принимает сообщения от `@AidarKC`;
|
- принимает сообщения от `@AidarKC`;
|
||||||
|
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
|
||||||
- ведёт историю диалога в `JSONL`;
|
- ведёт историю диалога в `JSONL`;
|
||||||
- ставит задачи в файловую очередь;
|
- ставит задачи в файловую очередь;
|
||||||
- обрабатывает задачи строго последовательно;
|
- обрабатывает задачи строго последовательно;
|
||||||
@ -9,8 +10,7 @@
|
|||||||
- вызывает Codex CLI и отправляет ответ в Telegram;
|
- вызывает Codex CLI и отправляет ответ в Telegram;
|
||||||
- при рестарте восстанавливает незавершённые задачи;
|
- при рестарте восстанавливает незавершённые задачи;
|
||||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||||
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
|
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
|
||||||
- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
|
|
||||||
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
|
||||||
|
|
||||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
||||||
@ -20,8 +20,8 @@
|
|||||||
- `data/py_queue.jsonl` — очередь Python-сервиса;
|
- `data/py_queue.jsonl` — очередь Python-сервиса;
|
||||||
- `data/py_state.json` — текущее состояние Python-сервиса;
|
- `data/py_state.json` — текущее состояние Python-сервиса;
|
||||||
- `data/py_processed_updates.log` — дедуп входящих update;
|
- `data/py_processed_updates.log` — дедуп входящих update;
|
||||||
- `data/history/*.jsonl` — активные истории;
|
- `data/history/<username>/*.jsonl` — активные истории по пользователям;
|
||||||
- `data/history/archive/*.jsonl` — архив историй после `/new`.
|
- `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
|
||||||
|
|
||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
1. Скопировать пример:
|
1. Скопировать пример:
|
||||||
@ -29,7 +29,10 @@
|
|||||||
2. Заполнить секреты в `.env`.
|
2. Заполнить секреты в `.env`.
|
||||||
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
|
||||||
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
|
||||||
|
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
|
||||||
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
|
||||||
|
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
|
||||||
|
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
|
||||||
3. Запуск:
|
3. Запуск:
|
||||||
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
|
- `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` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
- `/new` — архивировать текущую историю и начать новый диалог.
|
- `/new` — архивировать текущую историю и начать новый диалог.
|
||||||
- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.
|
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
|
||||||
|
|||||||
@ -14,11 +14,20 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
|
|
||||||
|
DEFAULT_ALLOWED_PLAYERS = ",".join([
|
||||||
|
"malvviiina:Милана",
|
||||||
|
"zodiaktechnika32:Сергей",
|
||||||
|
"oidasyda:Иван",
|
||||||
|
"blackbyrd1:Ворон",
|
||||||
|
"dimasol1:Дима",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
return dt.datetime.now(dt.timezone.utc).isoformat()
|
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||||
@ -33,6 +42,21 @@ def normalize_username(value: str | None) -> str:
|
|||||||
return value.lower()
|
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]:
|
def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@ -55,6 +79,27 @@ def read_env_file(path: Path) -> dict[str, str]:
|
|||||||
return result
|
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:
|
class JsonLineStore:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(path: Path) -> list[dict[str, Any]]:
|
def load(path: Path) -> list[dict[str, Any]]:
|
||||||
@ -116,11 +161,39 @@ class TelegramApi:
|
|||||||
result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15)
|
result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15)
|
||||||
return result.get("result", [])
|
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}
|
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||||
if reply_to_message_id is not None:
|
if reply_to_message_id is not None:
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
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:
|
def delete_webhook(self) -> None:
|
||||||
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
|
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
|
||||||
@ -134,10 +207,13 @@ class BotConfig:
|
|||||||
self.root_dir = root_dir
|
self.root_dir = root_dir
|
||||||
self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN")
|
self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN")
|
||||||
self.allowed_username = normalize_username(env.get("ALLOWED_TELEGRAM_USERNAME", "AidarKC"))
|
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.allowed_channel_username = normalize_username(env.get("ALLOWED_TELEGRAM_CHANNEL_USERNAME", "shine_writing"))
|
||||||
self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot")
|
self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot")
|
||||||
self.openai_api_key = env.get("OPENAI_API_KEY", "").strip()
|
self.openai_api_key = env.get("OPENAI_API_KEY", "").strip()
|
||||||
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
|
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
|
||||||
|
self.telegram_file_download_timeout_seconds = int(env.get("TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS", "300"))
|
||||||
|
self.openai_transcribe_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900"))
|
||||||
self.codex_bin = Path(env.get(
|
self.codex_bin = Path(env.get(
|
||||||
"CODEX_BIN",
|
"CODEX_BIN",
|
||||||
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
||||||
@ -185,6 +261,20 @@ class ShinePyBotService:
|
|||||||
self.last_heartbeat_at: float = 0.0
|
self.last_heartbeat_at: float = 0.0
|
||||||
self.restart_requested = False
|
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:
|
def run(self) -> None:
|
||||||
self._ensure_dirs()
|
self._ensure_dirs()
|
||||||
self._acquire_single_instance_lock()
|
self._acquire_single_instance_lock()
|
||||||
@ -250,9 +340,16 @@ class ShinePyBotService:
|
|||||||
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
|
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
self.state = {}
|
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"):
|
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)
|
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):
|
if not isinstance(self.state.get("next_job_number"), int):
|
||||||
self.state["next_job_number"] = 1
|
self.state["next_job_number"] = 1
|
||||||
self.state["updated_at"] = now_iso()
|
self.state["updated_at"] = now_iso()
|
||||||
@ -320,26 +417,70 @@ class ShinePyBotService:
|
|||||||
self._persist_state()
|
self._persist_state()
|
||||||
|
|
||||||
def _current_history_file(self) -> Path:
|
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")
|
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||||
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
|
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
|
||||||
path = self.history_dir / f"{ts}_{rnd}.jsonl"
|
history_dir, _ = self._history_dirs_for_user(username)
|
||||||
JsonLineStore.append(path, {"ts": now_iso(), "type": "history_created", "reason": reason})
|
path = history_dir / f"{ts}_{rnd}.jsonl"
|
||||||
|
JsonLineStore.append(path, {
|
||||||
|
"ts": now_iso(),
|
||||||
|
"type": "history_created",
|
||||||
|
"reason": reason,
|
||||||
|
"username": normalize_username(username),
|
||||||
|
})
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def _rotate_history(self, reason: str, username: str) -> 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():
|
if current.exists():
|
||||||
archived = self.history_archive_dir / current.name
|
archived = archive_dir / current.name
|
||||||
current.replace(archived)
|
current.replace(archived)
|
||||||
else:
|
else:
|
||||||
archived = self.history_archive_dir / "(empty)"
|
archived = archive_dir / "(empty)"
|
||||||
new_file = self._create_new_history_file(reason)
|
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.state["current_history_file"] = str(new_file)
|
||||||
self._persist_state()
|
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
|
return archived
|
||||||
|
|
||||||
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
|
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
|
||||||
@ -347,10 +488,28 @@ class ShinePyBotService:
|
|||||||
row.update(payload)
|
row.update(payload)
|
||||||
JsonLineStore.append(history_path, row)
|
JsonLineStore.append(history_path, row)
|
||||||
|
|
||||||
def _append_history_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
def _append_history_event(self, event_type: str, payload: dict[str, Any], username: str | None = None) -> None:
|
||||||
history_path = self._current_history_file()
|
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})
|
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:
|
def _resolve_chat_id(self, chat_id: int) -> int:
|
||||||
migrations = self.state.get("chat_id_migrations")
|
migrations = self.state.get("chat_id_migrations")
|
||||||
if not isinstance(migrations, dict):
|
if not isinstance(migrations, dict):
|
||||||
@ -440,35 +599,39 @@ class ShinePyBotService:
|
|||||||
)
|
)
|
||||||
if is_channel_post and not is_allowed_channel:
|
if is_channel_post and not is_allowed_channel:
|
||||||
return
|
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()
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
history_path = self._current_history_file()
|
actor_username = normalize_username(author_username)
|
||||||
if author_username != self.cfg.allowed_username:
|
is_allowed = self._is_allowed_user(actor_username)
|
||||||
if is_channel_post or is_group_message:
|
is_private = chat_type == "private"
|
||||||
self._append_history(history_path, "chat_context_message", {
|
if not is_allowed:
|
||||||
"chatId": chat_id,
|
if is_private:
|
||||||
"messageId": message_id,
|
self._safe_send(chat_id, "Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.", reply_to=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)
|
|
||||||
return
|
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 not text:
|
||||||
if message.get("voice"):
|
if message.get("voice"):
|
||||||
self._enqueue_voice_job(
|
self._enqueue_voice_job(
|
||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
author_username,
|
actor_username,
|
||||||
message["voice"].get("file_id"),
|
message["voice"].get("file_id"),
|
||||||
|
media_type="voice",
|
||||||
update_type=update_type,
|
update_type=update_type,
|
||||||
chat_username=chat_username,
|
chat_username=chat_username,
|
||||||
chat_title=chat_title,
|
chat_title=chat_title,
|
||||||
@ -480,8 +643,9 @@ class ShinePyBotService:
|
|||||||
self._enqueue_voice_job(
|
self._enqueue_voice_job(
|
||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
author_username,
|
actor_username,
|
||||||
message["audio"].get("file_id"),
|
message["audio"].get("file_id"),
|
||||||
|
media_type="audio",
|
||||||
update_type=update_type,
|
update_type=update_type,
|
||||||
chat_username=chat_username,
|
chat_username=chat_username,
|
||||||
chat_title=chat_title,
|
chat_title=chat_title,
|
||||||
@ -493,7 +657,7 @@ class ShinePyBotService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
self._handle_command(chat_id, message_id, author_username, text)
|
self._handle_command(chat_id, message_id, actor_username, text)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._append_history(history_path, "incoming_text", {
|
self._append_history(history_path, "incoming_text", {
|
||||||
@ -503,11 +667,11 @@ class ShinePyBotService:
|
|||||||
"chatType": chat_type,
|
"chatType": chat_type,
|
||||||
"chatUsername": chat_username,
|
"chatUsername": chat_username,
|
||||||
"chatTitle": chat_title,
|
"chatTitle": chat_title,
|
||||||
"username": author_username,
|
"username": actor_username,
|
||||||
"authorSignature": author_signature,
|
"authorSignature": author_signature,
|
||||||
"text": text,
|
"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["type"] = "text"
|
||||||
job["text"] = text
|
job["text"] = text
|
||||||
job["update_type"] = update_type
|
job["update_type"] = update_type
|
||||||
@ -515,6 +679,8 @@ class ShinePyBotService:
|
|||||||
job["chat_username"] = chat_username
|
job["chat_username"] = chat_username
|
||||||
job["chat_title"] = chat_title
|
job["chat_title"] = chat_title
|
||||||
job["author_signature"] = author_signature
|
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:
|
with self.queue_lock:
|
||||||
self.queue.append(job)
|
self.queue.append(job)
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
@ -527,6 +693,7 @@ class ShinePyBotService:
|
|||||||
username: str,
|
username: str,
|
||||||
file_id: str | None,
|
file_id: str | None,
|
||||||
*,
|
*,
|
||||||
|
media_type: str = "voice",
|
||||||
update_type: str = "message",
|
update_type: str = "message",
|
||||||
chat_username: str = "",
|
chat_username: str = "",
|
||||||
chat_title: str = "",
|
chat_title: str = "",
|
||||||
@ -536,7 +703,7 @@ class ShinePyBotService:
|
|||||||
if not file_id:
|
if not file_id:
|
||||||
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
|
||||||
return
|
return
|
||||||
history_path = self._current_history_file()
|
history_path = self._current_history_file_for_user(username)
|
||||||
self._append_history(history_path, "incoming_voice", {
|
self._append_history(history_path, "incoming_voice", {
|
||||||
"chatId": chat_id,
|
"chatId": chat_id,
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
@ -547,15 +714,19 @@ class ShinePyBotService:
|
|||||||
"username": username,
|
"username": username,
|
||||||
"authorSignature": author_signature,
|
"authorSignature": author_signature,
|
||||||
"fileId": file_id,
|
"fileId": file_id,
|
||||||
|
"mediaType": media_type,
|
||||||
})
|
})
|
||||||
job = self._build_job_base(chat_id, message_id, username, str(history_path))
|
job = self._build_job_base(chat_id, message_id, username, str(history_path))
|
||||||
job["type"] = "voice"
|
job["type"] = "voice"
|
||||||
job["telegram_file_id"] = file_id
|
job["telegram_file_id"] = file_id
|
||||||
|
job["telegram_media_type"] = media_type
|
||||||
job["update_type"] = update_type
|
job["update_type"] = update_type
|
||||||
job["chat_type"] = chat_type
|
job["chat_type"] = chat_type
|
||||||
job["chat_username"] = chat_username
|
job["chat_username"] = chat_username
|
||||||
job["chat_title"] = chat_title
|
job["chat_title"] = chat_title
|
||||||
job["author_signature"] = author_signature
|
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:
|
with self.queue_lock:
|
||||||
self.queue.append(job)
|
self.queue.append(job)
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
@ -579,8 +750,11 @@ class ShinePyBotService:
|
|||||||
"chat_username": "",
|
"chat_username": "",
|
||||||
"chat_title": "",
|
"chat_title": "",
|
||||||
"author_signature": "",
|
"author_signature": "",
|
||||||
|
"role": "owner",
|
||||||
|
"player_name": "",
|
||||||
"text": "",
|
"text": "",
|
||||||
"telegram_file_id": "",
|
"telegram_file_id": "",
|
||||||
|
"telegram_media_type": "",
|
||||||
"history_file": history_file,
|
"history_file": history_file,
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"retry_reason": "",
|
"retry_reason": "",
|
||||||
@ -592,8 +766,9 @@ class ShinePyBotService:
|
|||||||
|
|
||||||
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
|
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
|
is_owner = self._is_owner(username)
|
||||||
if lower in ("/start", "/help"):
|
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
|
return
|
||||||
if lower == "/status":
|
if lower == "/status":
|
||||||
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
|
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
|
||||||
@ -606,11 +781,14 @@ class ShinePyBotService:
|
|||||||
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
|
||||||
return
|
return
|
||||||
if lower in ("/restart_service", "/restart"):
|
if 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", {
|
self._append_history_event("restart_service_requested", {
|
||||||
"chatId": chat_id,
|
"chatId": chat_id,
|
||||||
"messageId": message_id,
|
"messageId": message_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
})
|
}, username=username)
|
||||||
self._safe_send(
|
self._safe_send(
|
||||||
chat_id,
|
chat_id,
|
||||||
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
|
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
|
||||||
@ -644,17 +822,19 @@ class ShinePyBotService:
|
|||||||
self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id)
|
self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
def _help_text(self) -> str:
|
def _help_text(self, *, is_owner: bool) -> str:
|
||||||
return (
|
lines = [
|
||||||
"Доступные команды:\n"
|
"Доступные команды:",
|
||||||
"/status — активная задача и размер очереди\n"
|
"/status — активная задача и размер очереди",
|
||||||
"/queue — список задач в очереди\n"
|
"/queue — список задач в очереди",
|
||||||
"/stop — остановить текущую задачу\n"
|
"/stop — остановить текущую задачу",
|
||||||
"/cancel <id|all> — удалить задачу по id (префикс) или все\n"
|
"/cancel <id|all> — удалить задачу по id (префикс) или все",
|
||||||
"/new — архивировать историю и начать новую\n"
|
"/new — архивировать историю и начать новую",
|
||||||
"/restart_service — перезапустить сервис через systemd\n"
|
"/help — эта справка",
|
||||||
"/help — эта справка"
|
]
|
||||||
)
|
if is_owner:
|
||||||
|
lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _status_text(self) -> str:
|
def _status_text(self) -> str:
|
||||||
with self.queue_lock:
|
with self.queue_lock:
|
||||||
@ -768,6 +948,7 @@ class ShinePyBotService:
|
|||||||
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
|
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
|
||||||
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
|
||||||
self._mark_job_done(job_id)
|
self._mark_job_done(job_id)
|
||||||
|
self._send_private_job_public_report(job, answer)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.stop_current_job:
|
if self.stop_current_job:
|
||||||
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
|
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
|
||||||
@ -775,6 +956,15 @@ class ShinePyBotService:
|
|||||||
self._mark_job_removed(job_id)
|
self._mark_job_removed(job_id)
|
||||||
self.stop_current_job = False
|
self.stop_current_job = False
|
||||||
return
|
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)
|
self._handle_job_failure(job, e)
|
||||||
|
|
||||||
def _build_prompt(self, job: dict[str, Any]) -> str:
|
def _build_prompt(self, job: dict[str, Any]) -> str:
|
||||||
@ -782,6 +972,19 @@ class ShinePyBotService:
|
|||||||
retry_reason = (job.get("retry_reason") or "").strip()
|
retry_reason = (job.get("retry_reason") or "").strip()
|
||||||
if retry_reason:
|
if retry_reason:
|
||||||
retry_block = f"\n\nПометка retry: {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 (
|
return (
|
||||||
"Пришло сообщение в Telegram.\n"
|
"Пришло сообщение в Telegram.\n"
|
||||||
f"Тип: {job.get('type')}\n"
|
f"Тип: {job.get('type')}\n"
|
||||||
@ -794,7 +997,7 @@ class ShinePyBotService:
|
|||||||
f"{job.get('text')}\n\n"
|
f"{job.get('text')}\n\n"
|
||||||
f"История диалога (JSONL): {job.get('history_file')}\n"
|
f"История диалога (JSONL): {job.get('history_file')}\n"
|
||||||
f"Инструкции агента: {self.cfg.agent_instructions_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:
|
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"])
|
chat_id = int(job["chat_id"])
|
||||||
message_id = int(job["message_id"])
|
message_id = int(job["message_id"])
|
||||||
error_text = str(err).strip() or err.__class__.__name__
|
error_text = str(err).strip() or err.__class__.__name__
|
||||||
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:
|
with self.queue_lock:
|
||||||
target = next((j for j in self.queue if j.get("id") == job_id), None)
|
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["attempts"] = attempts
|
||||||
target["last_error"] = error_text[:1000]
|
target["last_error"] = error_text[:1000]
|
||||||
target["updated_at"] = now_iso()
|
target["updated_at"] = now_iso()
|
||||||
if attempts < self.cfg.max_retries:
|
if retryable and attempts < self.cfg.max_retries:
|
||||||
target["status"] = "pending"
|
target["status"] = "pending"
|
||||||
target["retry_reason"] = error_text[:200]
|
target["retry_reason"] = error_text[:200]
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
@ -942,9 +1149,19 @@ class ShinePyBotService:
|
|||||||
will_retry = False
|
will_retry = False
|
||||||
|
|
||||||
if will_retry:
|
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:
|
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:
|
def _mark_job_done(self, job_id: str) -> None:
|
||||||
with self.queue_lock:
|
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.queue = [j for j in self.queue if j.get("id") != job_id]
|
||||||
self._persist_queue()
|
self._persist_queue()
|
||||||
|
|
||||||
def _safe_send(self, chat_id: int, text: str, reply_to: int | None = None) -> None:
|
def _remember_public_report_chat(self, chat_id: int) -> None:
|
||||||
text = (text or "").strip()
|
if self.state.get("public_report_chat_id") == chat_id:
|
||||||
if not text:
|
|
||||||
return
|
return
|
||||||
if len(text) > 3900:
|
self.state["public_report_chat_id"] = chat_id
|
||||||
text = text[:3900] + "\n...[обрезано]"
|
self.state["updated_at"] = now_iso()
|
||||||
resolved_chat_id = self._resolve_chat_id(chat_id)
|
self._persist_state()
|
||||||
resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
|
|
||||||
|
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:
|
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:
|
except Exception as e:
|
||||||
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
|
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
|
||||||
if migrate_to_chat_id is not None:
|
if migrate_to_chat_id is not None:
|
||||||
|
if isinstance(resolved_chat_id, int):
|
||||||
|
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error")
|
||||||
|
try:
|
||||||
|
sent = send(migrate_to_chat_id, None)
|
||||||
|
result = sent.get("result") or {}
|
||||||
|
message_id = result.get("message_id")
|
||||||
|
return message_id if isinstance(message_id, int) else None
|
||||||
|
except Exception as retry_error:
|
||||||
|
print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True)
|
||||||
|
return None
|
||||||
|
print(f"[py-bot] sendFile error: {e}", flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _safe_send(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")
|
self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
|
||||||
try:
|
try:
|
||||||
self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
|
sent = self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
|
||||||
return
|
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:
|
except Exception as retry_error:
|
||||||
print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True)
|
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)
|
print(f"[py-bot] sendMessage error: {e}", flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
def _schedule_self_restart(self) -> None:
|
def _schedule_self_restart(self) -> None:
|
||||||
if self.restart_requested:
|
if self.restart_requested:
|
||||||
@ -992,26 +1327,94 @@ class ShinePyBotService:
|
|||||||
|
|
||||||
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
|
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
|
||||||
if not self.cfg.openai_api_key:
|
if not self.cfg.openai_api_key:
|
||||||
raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice")
|
raise VoiceTranscriptionError(
|
||||||
|
"не настроен ключ OpenAI для распознавания.",
|
||||||
|
stage="config",
|
||||||
|
retryable=False,
|
||||||
|
)
|
||||||
file_id = (job.get("telegram_file_id") or "").strip()
|
file_id = (job.get("telegram_file_id") or "").strip()
|
||||||
if not file_id:
|
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)
|
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()
|
text = self._openai_transcribe(file_bytes, filename).strip()
|
||||||
if not text:
|
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
|
return text
|
||||||
|
|
||||||
def _download_telegram_file(self, file_id: str) -> tuple[bytes, str]:
|
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 {}
|
info = result.get("result") or {}
|
||||||
file_path = info.get("file_path")
|
file_path = info.get("file_path")
|
||||||
if not 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}"
|
file_url = f"https://api.telegram.org/file/bot{self.cfg.telegram_bot_token}/{file_path}"
|
||||||
req = request.Request(file_url, method="GET")
|
req = request.Request(file_url, method="GET")
|
||||||
with request.urlopen(req, timeout=120) as resp:
|
try:
|
||||||
|
with request.urlopen(req, timeout=self.cfg.telegram_file_download_timeout_seconds) as resp:
|
||||||
data = resp.read()
|
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"
|
original_name = Path(file_path).name or "audio.ogg"
|
||||||
lower = original_name.lower()
|
lower = original_name.lower()
|
||||||
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
|
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
|
||||||
@ -1051,11 +1454,40 @@ class ShinePyBotService:
|
|||||||
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
|
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
|
||||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
try:
|
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")
|
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:
|
except error.HTTPError as e:
|
||||||
detail = e.read().decode("utf-8", errors="replace")
|
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
|
@staticmethod
|
||||||
def _extract_codex_user_note(line: str) -> str | None:
|
def _extract_codex_user_note(line: str) -> str | None:
|
||||||
|
|||||||
@ -59,6 +59,7 @@ import * as deviceSessionView from './pages/device-session-view.js';
|
|||||||
import * as languageView from './pages/language-view.js';
|
import * as languageView from './pages/language-view.js';
|
||||||
import * as appLogView from './pages/app-log-view.js';
|
import * as appLogView from './pages/app-log-view.js';
|
||||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-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 messagesList from './pages/messages-list.js';
|
||||||
import * as contactSearchView from './pages/contact-search-view.js';
|
import * as contactSearchView from './pages/contact-search-view.js';
|
||||||
import * as chatView from './pages/chat-view.js';
|
import * as chatView from './pages/chat-view.js';
|
||||||
@ -98,6 +99,7 @@ const routes = {
|
|||||||
'language-view': languageView,
|
'language-view': languageView,
|
||||||
'app-log-view': appLogView,
|
'app-log-view': appLogView,
|
||||||
'pwa-diagnostics-view': pwaDiagnosticsView,
|
'pwa-diagnostics-view': pwaDiagnosticsView,
|
||||||
|
'solana-users-init-view': solanaUsersInitView,
|
||||||
'messages-list': messagesList,
|
'messages-list': messagesList,
|
||||||
'contact-search-view': contactSearchView,
|
'contact-search-view': contactSearchView,
|
||||||
'chat-view': chatView,
|
'chat-view': chatView,
|
||||||
|
|||||||
@ -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-ui-update">Принудительно обновить UI</button>
|
||||||
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</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-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-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-diagnostics">Диагностика PWA / Push</button>
|
||||||
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</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 forceUpdateBtn = card.querySelector('#settings-force-ui-update');
|
||||||
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
|
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
|
||||||
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
|
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
|
||||||
|
const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
|
||||||
|
|
||||||
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
|
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
|
||||||
|
solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
|
||||||
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
|
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
|
||||||
uploadAvatarBtn?.addEventListener('click', () => {
|
uploadAvatarBtn?.addEventListener('click', () => {
|
||||||
openDeveloperAvatarUploadModal({
|
openDeveloperAvatarUploadModal({
|
||||||
|
|||||||
196
shine-UI/js/pages/solana-users-init-view.js
Normal file
196
shine-UI/js/pages/solana-users-init-view.js
Normal 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;
|
||||||
|
}
|
||||||
@ -182,7 +182,8 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'device-session-view' ||
|
pageId === 'device-session-view' ||
|
||||||
pageId === 'language-view' ||
|
pageId === 'language-view' ||
|
||||||
pageId === 'app-log-view' ||
|
pageId === 'app-log-view' ||
|
||||||
pageId === 'pwa-diagnostics-view'
|
pageId === 'pwa-diagnostics-view' ||
|
||||||
|
pageId === 'solana-users-init-view'
|
||||||
) return 'profile-view';
|
) return 'profile-view';
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
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';
|
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { deriveEd25519FromPassword } from './crypto-utils.js';
|
import { deriveEd25519FromPassword } from './crypto-utils.js';
|
||||||
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
||||||
import { loadEncryptedUserSecrets } from './key-vault.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/';
|
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
|
|||||||
8
shine-UI/js/solana-programs.js
Normal file
8
shine-UI/js/solana-programs.js
Normal 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';
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { AuthService } from './services/auth-service.js';
|
import { AuthService } from './services/auth-service.js';
|
||||||
import { listStoredMessages, putStoredMessage } from './services/message-store.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 clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||||
@ -76,7 +77,7 @@ function inferTunnelWsUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || 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_SHINE_SERVER = 'wss://shineup.me/ws';
|
||||||
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
|
||||||
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
|
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
|
||||||
|
|||||||
@ -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";
|
||||||
|
}
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||||
@ -182,6 +183,10 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
node.setVersionsTotal(versions.size());
|
node.setVersionsTotal(versions.size());
|
||||||
node.setText(versions.get(versions.size() - 1).getText());
|
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);
|
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
|
|||||||
@ -21,9 +21,13 @@ public class Net_GetMessageThread_Response extends Net_Response {
|
|||||||
|
|
||||||
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
|
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
|
||||||
private ChannelInfo channelInfo;
|
private ChannelInfo channelInfo;
|
||||||
|
private String rawBlockB64;
|
||||||
|
|
||||||
public ChannelInfo getChannelInfo() { return channelInfo; }
|
public ChannelInfo getChannelInfo() { return channelInfo; }
|
||||||
public void setChannelInfo(ChannelInfo channelInfo) { this.channelInfo = 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 {
|
public static class ChannelInfo {
|
||||||
|
|||||||
@ -51,7 +51,7 @@
|
|||||||
| N | Поле | Тип | Размер | Правило |
|
| N | Поле | Тип | Размер | Правило |
|
||||||
|---|------|-----|--------|---------|
|
|---|------|-----|--------|---------|
|
||||||
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
|
||||||
| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
|
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
|
||||||
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
|
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
|
||||||
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
|
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
|
||||||
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
|
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
|
||||||
@ -63,7 +63,7 @@
|
|||||||
После первых 9 полей идет набор типизированных блоков:
|
После первых 9 полей идет набор типизированных блоков:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
UserPdaRecordV2
|
UserPdaRecordV1
|
||||||
- fixed_header: поля 1..9
|
- fixed_header: поля 1..9
|
||||||
- blocks_count: u8
|
- blocks_count: u8
|
||||||
- blocks: TypedBlock[blocks_count]
|
- blocks: TypedBlock[blocks_count]
|
||||||
@ -89,7 +89,7 @@ UserPdaRecordV2
|
|||||||
|
|
||||||
Правила:
|
Правила:
|
||||||
|
|
||||||
- неизвестный `block_type` в `format_major = 2` считается ошибкой;
|
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
|
||||||
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
|
||||||
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
|
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
|
||||||
- каждый обязательный блок должен встречаться ровно один раз;
|
- каждый обязательный блок должен встречаться ровно один раз;
|
||||||
|
|||||||
@ -12,7 +12,7 @@ use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
const MAGIC: &[u8; 5] = b"SHiNE";
|
const MAGIC: &[u8; 5] = b"SHiNE";
|
||||||
const FORMAT_MAJOR: u8 = 2;
|
const FORMAT_MAJOR: u8 = 1;
|
||||||
const FORMAT_MINOR: u8 = 0;
|
const FORMAT_MINOR: u8 = 0;
|
||||||
const MAX_SYNC_SERVERS: usize = 32;
|
const MAX_SYNC_SERVERS: usize = 32;
|
||||||
const MAX_AUTO_REALLOC_INCREASE: usize = 10_000;
|
const MAX_AUTO_REALLOC_INCREASE: usize = 10_000;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { expect } from "chai";
|
|||||||
import { Shine } from "../target/types/shine";
|
import { Shine } from "../target/types/shine";
|
||||||
|
|
||||||
const MAGIC = Buffer.from("SHiNE", "utf8");
|
const MAGIC = Buffer.from("SHiNE", "utf8");
|
||||||
const FORMAT_MAJOR = 2;
|
const FORMAT_MAJOR = 1;
|
||||||
const FORMAT_MINOR = 0;
|
const FORMAT_MINOR = 0;
|
||||||
const ZERO_HASH = Buffer.alloc(32, 0);
|
const ZERO_HASH = Buffer.alloc(32, 0);
|
||||||
const LAST_BLOCK_STATE_PREFIX = Buffer.from("SHiNE_LAST_BLOCK", "utf8");
|
const LAST_BLOCK_STATE_PREFIX = Buffer.from("SHiNE_LAST_BLOCK", "utf8");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user