diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 0faa797..5044830 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,5 +2,7 @@
+
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index eb82179..afe8a83 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,9 @@
## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
-- Подробные правила работы сервиса, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
+- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
+- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
+- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
## Solana-модуль
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
@@ -19,6 +21,13 @@
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
+- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
+ - `Dev_Docs/Инициализация_Solana_регистрации/README.md`
+- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
+- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
+ - `Dev_Docs/Solana_Architecture/README.md`
+- Документ формата пользовательской PDA-записи `shine_users` находится в:
+ - `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
@@ -99,6 +108,9 @@
## Будущие фичи
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
+- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
+- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
+- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
- какие файлы и участки отключены;
diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md
index 6a51e70..999140e 100644
--- a/Dev_Docs/Future_Features/README.md
+++ b/Dev_Docs/Future_Features/README.md
@@ -1,14 +1,42 @@
# Будущие фичи
-Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку.
+Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
+
+## Горизонты планирования
+
+- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
+- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
+- `far/` - дальнее будущее: идеи без понятного срока возврата.
+
+Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
## Как использовать
-1. Каждая будущая фича описывается отдельным markdown-файлом.
+1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
2. В файле нужно фиксировать:
- зачем нужна фича;
- - что уже было сделано в коде;
- - что временно отключено или закомментировано;
+ - к какому сроку или горизонту она относится;
+ - что нужно сделать;
+ - какие вопросы нужно уточнить перед реализацией;
+ - что уже было сделано в коде, если фича частично реализована;
+ - что временно отключено или закомментировано, если применимо;
- какие документы нужно обновить при возврате к задаче;
- с какого места продолжать разработку.
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
+
+## Текущие планы
+
+### Ближайшие
+
+- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
+- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
+- `near/2026-05-25_1106_channels_my_create_button.md` - поправить экран каналов: создание канала показывать только в разделе `Мои каналы`.
+
+### Среднесрочные
+
+- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
+- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
+
+### Дальнее будущее
+
+- Сейчас задач нет.
diff --git a/Dev_Docs/Future_Features/far/README.md b/Dev_Docs/Future_Features/far/README.md
new file mode 100644
index 0000000..5dc5d98
--- /dev/null
+++ b/Dev_Docs/Future_Features/far/README.md
@@ -0,0 +1,5 @@
+# Дальнее будущее
+
+Сейчас в этом горизонте нет активных идей.
+
+Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
diff --git a/Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md b/Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md
similarity index 99%
rename from Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
rename to Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md
index 14238fa..641ff28 100644
--- a/Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
+++ b/Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md
@@ -3,6 +3,12 @@
- Статус:
`future`
+- Горизонт:
+ `medium`
+
+- Ориентир:
+ 1-2 месяца
+
- Решение от 2026-05-24:
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.
diff --git a/Dev_Docs/Future_Features/medium/2026-05-25_1106_shine_balance_wallet.md b/Dev_Docs/Future_Features/medium/2026-05-25_1106_shine_balance_wallet.md
new file mode 100644
index 0000000..6e35bca
--- /dev/null
+++ b/Dev_Docs/Future_Features/medium/2026-05-25_1106_shine_balance_wallet.md
@@ -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. Ошибочные или повторные операции не начисляют баланс дважды.
diff --git a/Dev_Docs/Future_Features/near/2026-05-25_1106_channels_my_create_button.md b/Dev_Docs/Future_Features/near/2026-05-25_1106_channels_my_create_button.md
new file mode 100644
index 0000000..7d88473
--- /dev/null
+++ b/Dev_Docs/Future_Features/near/2026-05-25_1106_channels_my_create_button.md
@@ -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. На мобильном экране шапка не ломается.
diff --git a/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md b/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md
new file mode 100644
index 0000000..2022dc0
--- /dev/null
+++ b/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md
@@ -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//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//` без отдельного подтверждения.
diff --git a/Dev_Docs/Future_Features/near/2026-05-25_1106_wallet_topup_solana_arweave.md b/Dev_Docs/Future_Features/near/2026-05-25_1106_wallet_topup_solana_arweave.md
new file mode 100644
index 0000000..c4c06a9
--- /dev/null
+++ b/Dev_Docs/Future_Features/near/2026-05-25_1106_wallet_topup_solana_arweave.md
@@ -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. Возврат назад в приложение не ломает состояние регистрации/кошелька.
diff --git a/Dev_Docs/Pending_Features/2026-05-24_2035_solana-init-registracii.md b/Dev_Docs/Pending_Features/2026-05-24_2035_solana-init-registracii.md
new file mode 100644
index 0000000..8c9e868
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-05-24_2035_solana-init-registracii.md
@@ -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`.
diff --git a/Dev_Docs/Pending_Features/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md b/Dev_Docs/Pending_Features/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md
new file mode 100644
index 0000000..89fff13
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md
@@ -0,0 +1,26 @@
+# Отчёт private-запросов агента в группу
+
+## Что сделано
+
+После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
+
+- первым сообщением исходный запрос;
+- вторым сообщением, reply на первое, финальный ответ Codex.
+
+Промежуточные статусы выполнения в группу не дублируются.
+
+## Что проверять
+
+1. Отправить боту личный текстовый запрос.
+2. Дождаться полного ответа в личном чате.
+3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
+4. Проверить, что итоговый ответ опубликован reply на это сообщение.
+5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
+
+## Ожидаемый результат
+
+Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
+
+## Статус
+
+pending
diff --git a/Dev_Docs/Pending_Features/2026-05-25_1556_voice_otchet_s_audio.md b/Dev_Docs/Pending_Features/2026-05-25_1556_voice_otchet_s_audio.md
new file mode 100644
index 0000000..9c7d627
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-05-25_1556_voice_otchet_s_audio.md
@@ -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
diff --git a/Dev_Docs/Pending_Features/2026-05-25_2057_voice_timeout_i_oshibki.md b/Dev_Docs/Pending_Features/2026-05-25_2057_voice_timeout_i_oshibki.md
new file mode 100644
index 0000000..6f68ec0
--- /dev/null
+++ b/Dev_Docs/Pending_Features/2026-05-25_2057_voice_timeout_i_oshibki.md
@@ -0,0 +1,19 @@
+# Улучшенная обработка длинных voice/audio
+
+## Что сделано
+- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
+- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
+- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
+
+## Как проверять
+- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
+- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
+- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
+
+## Ожидаемый результат
+- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
+- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
+- В логах сервиса видны стадия и техническая причина сбоя.
+
+## Статус
+pending
diff --git a/Dev_Docs/Solana/user_pda/README.md b/Dev_Docs/Solana/user_pda/README.md
index 299b098..036c130 100644
--- a/Dev_Docs/Solana/user_pda/README.md
+++ b/Dev_Docs/Solana/user_pda/README.md
@@ -51,7 +51,7 @@
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
-| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
+| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
@@ -63,7 +63,7 @@
После первых 9 полей идет набор типизированных блоков:
```text
-UserPdaRecordV2
+UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
@@ -89,7 +89,7 @@ UserPdaRecordV2
Правила:
-- неизвестный `block_type` в `format_major = 2` считается ошибкой;
+- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
diff --git a/Dev_Docs/Solana_Architecture/README.md b/Dev_Docs/Solana_Architecture/README.md
new file mode 100644
index 0000000..2252f7b
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/README.md
@@ -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=`, создается для каждого логина.
+ - `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)
diff --git a/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md b/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md
new file mode 100644
index 0000000..1f97c43
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md
@@ -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` на каждого менеджера.
diff --git a/Dev_Docs/Solana_Architecture/details/shine_dao.md b/Dev_Docs/Solana_Architecture/details/shine_dao.md
new file mode 100644
index 0000000..ce8ea98
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/details/shine_dao.md
@@ -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-счету.
+
diff --git a/Dev_Docs/Solana_Architecture/details/shine_login_guard.md b/Dev_Docs/Solana_Architecture/details/shine_login_guard.md
new file mode 100644
index 0000000..aa76f6a
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/details/shine_login_guard.md
@@ -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`.
diff --git a/Dev_Docs/Solana_Architecture/details/shine_payments.md b/Dev_Docs/Solana_Architecture/details/shine_payments.md
new file mode 100644
index 0000000..92d9da9
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/details/shine_payments.md
@@ -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` остается открытым для любого подписанта, чтобы выплаты не зависели от одного оператора.
diff --git a/Dev_Docs/Solana_Architecture/details/shine_users.md b/Dev_Docs/Solana_Architecture/details/shine_users.md
new file mode 100644
index 0000000..d8e7004
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/details/shine_users.md
@@ -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=`.
+ - Переводит оплату регистрации и дополнительного лимита в `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`
+
+`update_user_pda`:
+
+- `login: String`
+- `root_key: Pubkey`
+- `created_at_ms: u64`
+- `updated_at_ms: u64`
+- `version: u32`
+- `prev_hash: Vec`
+- `additional_limit: u64`
+- `fields: UserMutableFields`
+- `signature: Vec`
+
+`UserMutableFields`:
+
+- `device_key: Pubkey`
+- `blockchain_public_key: Pubkey`
+- `blockchain_name: String`
+- `used_bytes: u64`
+- `last_block_number: u32`
+- `last_block_hash: Vec` — ровно 32 байта
+- `last_block_signature: Vec` — ровно 64 байта
+- `arweave_tx_id: String`
+- `is_server: bool`
+- `server_key: Pubkey`
+- `server_address: String`
+- `sync_servers: Vec`
+- `access_servers: Vec`
+- `trusted_count: u8`
+
+## Главные PDA
+
+1. `user_pda`
+ - PDA записи пользователя.
+ - Seed: `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` остаются доступными обычным пользователям при корректных подписях и оплате.
diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.mmd b/Dev_Docs/Solana_Architecture/schemes/architecture.mmd
new file mode 100644
index 0000000..978afc1
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/schemes/architecture.mmd
@@ -0,0 +1,54 @@
+flowchart LR
+ U[Пользователь / signer]
+ B[Покупатель тикета]
+ M[Менеджер]
+ C[Любой caller step_payout]
+
+ LG[1. shine_login_guard classify_login]
+ USERS[2. shine_users create_user_pda / update_user_pda]
+ PAY[3. shine_payments vault / tickets / payouts]
+ DAO[SHiNE DAO governance / authority / treasury]
+
+ USERPDA[(user_pda по 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
+
diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.png b/Dev_Docs/Solana_Architecture/schemes/architecture.png
new file mode 100644
index 0000000..0f1a060
Binary files /dev/null and b/Dev_Docs/Solana_Architecture/schemes/architecture.png differ
diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.svg b/Dev_Docs/Solana_Architecture/schemes/architecture.svg
new file mode 100644
index 0000000..a731eb3
--- /dev/null
+++ b/Dev_Docs/Solana_Architecture/schemes/architecture.svg
@@ -0,0 +1,139 @@
+
diff --git a/Dev_Docs/Инициализация_Solana_регистрации/README.md b/Dev_Docs/Инициализация_Solana_регистрации/README.md
new file mode 100644
index 0000000..450c10c
--- /dev/null
+++ b/Dev_Docs/Инициализация_Solana_регистрации/README.md
@@ -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-классификации логина.
+ Несовпадение адреса приведёт к ошибке регистрации.
diff --git a/Players/blackbyrd1/files/.gitkeep b/Players/blackbyrd1/files/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/blackbyrd1/files/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/blackbyrd1/history/.gitkeep b/Players/blackbyrd1/history/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/blackbyrd1/history/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/dimasol1/files/.gitkeep b/Players/dimasol1/files/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/dimasol1/files/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/dimasol1/history/.gitkeep b/Players/dimasol1/history/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/dimasol1/history/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/malvviiina/files/.gitkeep b/Players/malvviiina/files/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/malvviiina/files/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/malvviiina/history/.gitkeep b/Players/malvviiina/history/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/malvviiina/history/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/oidasyda/files/.gitkeep b/Players/oidasyda/files/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/oidasyda/files/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/oidasyda/history/.gitkeep b/Players/oidasyda/history/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/oidasyda/history/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/zodiaktechnika32/files/.gitkeep b/Players/zodiaktechnika32/files/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/zodiaktechnika32/files/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/Players/zodiaktechnika32/history/.gitkeep b/Players/zodiaktechnika32/history/.gitkeep
new file mode 100644
index 0000000..4c0d52d
--- /dev/null
+++ b/Players/zodiaktechnika32/history/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/SHiNE-agent-bot-coder/.env.example b/SHiNE-agent-bot-coder/.env.example
index a9db23f..34aafdf 100644
--- a/SHiNE-agent-bot-coder/.env.example
+++ b/SHiNE-agent-bot-coder/.env.example
@@ -1,9 +1,12 @@
TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=replace_me
ALLOWED_TELEGRAM_USERNAME=AidarKC
+ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
BOT_USERNAME=aidar_su_bot
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
+TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
+OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
CODEX_TIMEOUT_SECONDS=900
diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md
index be17045..6bc7319 100644
--- a/SHiNE-agent-bot-coder/AGENT.md
+++ b/SHiNE-agent-bot-coder/AGENT.md
@@ -15,19 +15,33 @@
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
-- Агент должен выполнять то, что говорит Айдар; сообщения других пользователей считать дополнительным контекстом, а не командами к исполнению.
-- Сообщения других пользователей в разрешённом канале, группе или supergroup сохраняются в историю диалога как контекстные сообщения.
-- На сообщения других пользователей в группе или supergroup сервис должен коротко отвечать в тот же чат, что сообщение получено, но не ставить их в очередь как задачи.
-- Использовать сообщения других пользователей для действий нужно только если Айдар дал на это специальную инструкцию.
-- В Telegram-канале/группе `@shine_writing` сервис должен отвечать только на сообщения Айдара, а ответы отправлять в тот же чат.
+- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players//`.
+- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
+- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
+- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
+- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
-- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно.
+- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history//`.
+- Архив истории после `/new`: `data/history//archive/`.
+- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
+- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
+- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
+
+## Планы и отложенные фичи
+- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
+- Внутри есть три горизонта:
+ - `near/` - ближайшие планы, обычно сегодня/завтра;
+ - `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
+ - `far/` - дальнее будущее без понятного срока.
+- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
+- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
+- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
@@ -35,7 +49,7 @@
- Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
-- Команда Telegram `/restart_service` перезапускает сервис через завершение процесса; systemd поднимает его заново. Короткий алиас: `/restart`.
+- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
## Правила ответа
- Пиши содержательно и коротко.
diff --git a/SHiNE-agent-bot-coder/AGENTS.md b/SHiNE-agent-bot-coder/AGENTS.md
new file mode 100644
index 0000000..20aa593
--- /dev/null
+++ b/SHiNE-agent-bot-coder/AGENTS.md
@@ -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, когда это уместно.
+- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
diff --git a/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md b/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md
new file mode 100644
index 0000000..edeae1b
--- /dev/null
+++ b/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md
@@ -0,0 +1,26 @@
+# Промпты для режима игроков (на согласование)
+
+## 1) Базовый служебный промпт (добавка к задаче игрока)
+
+```text
+Режим игрока (обязательно):
+- Пользователь: <Имя> (@).
+- Рабочая папка игрока: /Players/
+- Код проекта не изменять.
+- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
+- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
+```
+
+## 2) Приветственное сообщение игроку (один раз)
+
+```text
+Привет, <Имя>.
+Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
+Команда /new начинает новую сессию и архивирует текущую историю.
+```
+
+## 3) Отказ неизвестному пользователю
+
+```text
+Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
+```
diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md
index 38d171c..c00349f 100644
--- a/SHiNE-agent-bot-coder/README.md
+++ b/SHiNE-agent-bot-coder/README.md
@@ -2,6 +2,7 @@
Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`;
+- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно;
@@ -9,8 +10,7 @@
- вызывает Codex CLI и отправляет ответ в Telegram;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
-- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст;
-- на сообщения других участников группы отвечает в тот же чат коротким подтверждением получения, не создавая задачу Codex;
+- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
@@ -20,8 +20,8 @@
- `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update;
-- `data/history/*.jsonl` — активные истории;
-- `data/history/archive/*.jsonl` — архив историй после `/new`.
+- `data/history//*.jsonl` — активные истории по пользователям;
+- `data/history//archive/*.jsonl` — архивы после `/new`.
## Локальный запуск
1. Скопировать пример:
@@ -29,7 +29,10 @@
2. Заполнить секреты в `.env`.
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
+ - `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
+ - `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
+ - `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py`
@@ -59,4 +62,4 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
- `/stop` — остановить текущую задачу.
- `/cancel ` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
-- `/restart_service` — перезапустить сервис; systemd должен поднять процесс заново.
+- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.
diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py
index 9159315..dee95c8 100644
--- a/SHiNE-agent-bot-coder/py_bot_service.py
+++ b/SHiNE-agent-bot-coder/py_bot_service.py
@@ -14,11 +14,20 @@ import subprocess
import tempfile
import threading
import time
+import traceback
import uuid
from pathlib import Path
from typing import Any
from urllib import error, request
+DEFAULT_ALLOWED_PLAYERS = ",".join([
+ "malvviiina:Милана",
+ "zodiaktechnika32:Сергей",
+ "oidasyda:Иван",
+ "blackbyrd1:Ворон",
+ "dimasol1:Дима",
+])
+
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat()
@@ -33,6 +42,21 @@ def normalize_username(value: str | None) -> str:
return value.lower()
+def parse_allowed_players(raw: str) -> dict[str, str]:
+ players: dict[str, str] = {}
+ for item in (raw or "").split(","):
+ part = item.strip()
+ if not part:
+ continue
+ username_part, sep, name_part = part.partition(":")
+ username = normalize_username(username_part)
+ if not username:
+ continue
+ player_name = (name_part if sep else username_part).strip() or username
+ players[username] = player_name
+ return players
+
+
def split_long_text(text: str, chunk_size: int = 3500) -> list[str]:
text = (text or "").strip()
if not text:
@@ -55,6 +79,27 @@ def read_env_file(path: Path) -> dict[str, str]:
return result
+class VoiceTranscriptionError(RuntimeError):
+ def __init__(
+ self,
+ user_message: str,
+ *,
+ stage: str,
+ retryable: bool = True,
+ detail: str = "",
+ ):
+ super().__init__(user_message)
+ self.user_message = user_message
+ self.stage = stage
+ self.retryable = retryable
+ self.detail = detail
+
+ def log_text(self) -> str:
+ if self.detail and self.detail != self.user_message:
+ return f"{self.user_message} stage={self.stage} retryable={self.retryable} detail={self.detail}"
+ return f"{self.user_message} stage={self.stage} retryable={self.retryable}"
+
+
class JsonLineStore:
@staticmethod
def load(path: Path) -> list[dict[str, Any]]:
@@ -116,11 +161,39 @@ class TelegramApi:
result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15)
return result.get("result", [])
- def send_message(self, chat_id: int, text: str, reply_to_message_id: int | None = None) -> None:
+ def send_message(self, chat_id: int | str, text: str, reply_to_message_id: int | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
if reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
- self.call("sendMessage", payload=payload, timeout=30)
+ return self.call("sendMessage", payload=payload, timeout=30)
+
+ def send_voice(
+ self,
+ chat_id: int | str,
+ voice: str,
+ caption: str = "",
+ reply_to_message_id: int | None = None,
+ ) -> dict[str, Any]:
+ payload: dict[str, Any] = {"chat_id": chat_id, "voice": voice}
+ if caption:
+ payload["caption"] = caption
+ if reply_to_message_id is not None:
+ payload["reply_to_message_id"] = reply_to_message_id
+ return self.call("sendVoice", payload=payload, timeout=60)
+
+ def send_audio(
+ self,
+ chat_id: int | str,
+ audio: str,
+ caption: str = "",
+ reply_to_message_id: int | None = None,
+ ) -> dict[str, Any]:
+ payload: dict[str, Any] = {"chat_id": chat_id, "audio": audio}
+ if caption:
+ payload["caption"] = caption
+ if reply_to_message_id is not None:
+ payload["reply_to_message_id"] = reply_to_message_id
+ return self.call("sendAudio", payload=payload, timeout=60)
def delete_webhook(self) -> None:
self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30)
@@ -134,10 +207,13 @@ class BotConfig:
self.root_dir = root_dir
self.telegram_bot_token = self._required(env, "TELEGRAM_BOT_TOKEN")
self.allowed_username = normalize_username(env.get("ALLOWED_TELEGRAM_USERNAME", "AidarKC"))
+ self.allowed_players = parse_allowed_players(env.get("ALLOWED_TELEGRAM_PLAYERS", DEFAULT_ALLOWED_PLAYERS))
self.allowed_channel_username = normalize_username(env.get("ALLOWED_TELEGRAM_CHANNEL_USERNAME", "shine_writing"))
self.bot_username = env.get("BOT_USERNAME", "aidar_su_bot")
self.openai_api_key = env.get("OPENAI_API_KEY", "").strip()
self.openai_transcribe_model = env.get("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe")
+ self.telegram_file_download_timeout_seconds = int(env.get("TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS", "300"))
+ self.openai_transcribe_timeout_seconds = int(env.get("OPENAI_TRANSCRIBE_TIMEOUT_SECONDS", "900"))
self.codex_bin = Path(env.get(
"CODEX_BIN",
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
@@ -185,6 +261,20 @@ class ShinePyBotService:
self.last_heartbeat_at: float = 0.0
self.restart_requested = False
+ def _is_owner(self, username: str) -> bool:
+ return normalize_username(username) == self.cfg.allowed_username
+
+ def _is_allowed_player(self, username: str) -> bool:
+ return normalize_username(username) in self.cfg.allowed_players
+
+ def _is_allowed_user(self, username: str) -> bool:
+ uname = normalize_username(username)
+ return uname == self.cfg.allowed_username or uname in self.cfg.allowed_players
+
+ def _player_name(self, username: str) -> str:
+ uname = normalize_username(username)
+ return self.cfg.allowed_players.get(uname, uname)
+
def run(self) -> None:
self._ensure_dirs()
self._acquire_single_instance_lock()
@@ -250,9 +340,16 @@ class ShinePyBotService:
self.state = json.loads(self.state_file.read_text(encoding="utf-8"))
else:
self.state = {}
+ sessions = self.state.get("user_sessions")
+ if not isinstance(sessions, dict):
+ sessions = {}
+ self.state["user_sessions"] = sessions
if not self.state.get("current_history_file"):
- history_file = self._create_new_history_file("initial")
+ history_file = self._create_new_history_file("initial", self.cfg.allowed_username)
self.state["current_history_file"] = str(history_file)
+ sessions[self.cfg.allowed_username] = {"current_history_file": str(history_file)}
+ elif self.cfg.allowed_username not in sessions:
+ sessions[self.cfg.allowed_username] = {"current_history_file": str(self.state["current_history_file"])}
if not isinstance(self.state.get("next_job_number"), int):
self.state["next_job_number"] = 1
self.state["updated_at"] = now_iso()
@@ -320,26 +417,70 @@ class ShinePyBotService:
self._persist_state()
def _current_history_file(self) -> Path:
- return Path(self.state["current_history_file"])
+ return self._current_history_file_for_user(self.cfg.allowed_username)
- def _create_new_history_file(self, reason: str) -> Path:
+ def _history_dirs_for_user(self, username: str) -> tuple[Path, Path]:
+ uname = normalize_username(username) or "unknown"
+ history_dir = self.history_dir / uname
+ archive_dir = history_dir / "archive"
+ history_dir.mkdir(parents=True, exist_ok=True)
+ archive_dir.mkdir(parents=True, exist_ok=True)
+ return history_dir, archive_dir
+
+ def _ensure_user_session(self, username: str) -> None:
+ uname = normalize_username(username) or self.cfg.allowed_username
+ sessions = self.state.setdefault("user_sessions", {})
+ if not isinstance(sessions, dict):
+ sessions = {}
+ self.state["user_sessions"] = sessions
+ session = sessions.get(uname)
+ if isinstance(session, dict) and session.get("current_history_file"):
+ return
+ history_file = self._create_new_history_file("initial", uname)
+ sessions[uname] = {"current_history_file": str(history_file)}
+ if uname == self.cfg.allowed_username:
+ self.state["current_history_file"] = str(history_file)
+ self._persist_state()
+
+ def _current_history_file_for_user(self, username: str) -> Path:
+ uname = normalize_username(username) or self.cfg.allowed_username
+ self._ensure_user_session(uname)
+ sessions = self.state.get("user_sessions") or {}
+ session = sessions.get(uname) or {}
+ return Path(session["current_history_file"])
+
+ def _create_new_history_file(self, reason: str, username: str) -> Path:
ts = dt.datetime.now().strftime("%Y-%m-%d_%H%M%S")
rnd = "".join(random.choices(string.hexdigits.lower(), k=8))
- path = self.history_dir / f"{ts}_{rnd}.jsonl"
- JsonLineStore.append(path, {"ts": now_iso(), "type": "history_created", "reason": reason})
+ history_dir, _ = self._history_dirs_for_user(username)
+ path = history_dir / f"{ts}_{rnd}.jsonl"
+ JsonLineStore.append(path, {
+ "ts": now_iso(),
+ "type": "history_created",
+ "reason": reason,
+ "username": normalize_username(username),
+ })
return path
def _rotate_history(self, reason: str, username: str) -> Path:
- current = self._current_history_file()
+ uname = normalize_username(username) or self.cfg.allowed_username
+ current = self._current_history_file_for_user(uname)
+ _, archive_dir = self._history_dirs_for_user(uname)
if current.exists():
- archived = self.history_archive_dir / current.name
+ archived = archive_dir / current.name
current.replace(archived)
else:
- archived = self.history_archive_dir / "(empty)"
- new_file = self._create_new_history_file(reason)
- self.state["current_history_file"] = str(new_file)
+ archived = archive_dir / "(empty)"
+ new_file = self._create_new_history_file(reason, uname)
+ sessions = self.state.setdefault("user_sessions", {})
+ if not isinstance(sessions, dict):
+ sessions = {}
+ self.state["user_sessions"] = sessions
+ sessions[uname] = {"current_history_file": str(new_file)}
+ if uname == self.cfg.allowed_username:
+ self.state["current_history_file"] = str(new_file)
self._persist_state()
- self._append_history_event("history_rotated", {"reason": reason, "username": username, "archived": str(archived)})
+ self._append_history_event("history_rotated", {"reason": reason, "username": uname, "archived": str(archived)}, username=uname)
return archived
def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None:
@@ -347,10 +488,28 @@ class ShinePyBotService:
row.update(payload)
JsonLineStore.append(history_path, row)
- def _append_history_event(self, event_type: str, payload: dict[str, Any]) -> None:
- history_path = self._current_history_file()
+ def _append_history_event(self, event_type: str, payload: dict[str, Any], username: str | None = None) -> None:
+ history_path = self._current_history_file_for_user(username or self.cfg.allowed_username)
self._append_history(history_path, "system_event", {"event": event_type, **payload})
+ def _send_player_welcome_once(self, chat_id: int, message_id: int, username: str) -> None:
+ uname = normalize_username(username)
+ sent = self.state.get("player_welcome_sent")
+ if not isinstance(sent, dict):
+ sent = {}
+ self.state["player_welcome_sent"] = sent
+ if sent.get(uname):
+ return
+ player_name = self._player_name(uname)
+ text = (
+ f"Привет, {player_name}.\n"
+ "Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.\n"
+ "Команда /new начинает новую сессию и архивирует текущую историю."
+ )
+ self._safe_send(chat_id, text, reply_to=message_id)
+ sent[uname] = now_iso()
+ self._persist_state()
+
def _resolve_chat_id(self, chat_id: int) -> int:
migrations = self.state.get("chat_id_migrations")
if not isinstance(migrations, dict):
@@ -440,35 +599,39 @@ class ShinePyBotService:
)
if is_channel_post and not is_allowed_channel:
return
+ if chat_username and chat_username == self.cfg.allowed_channel_username:
+ self._remember_public_report_chat(chat_id)
+
+ # Игнорируем системные сообщения о входе/выходе и смене заголовка/фото.
+ if message.get("new_chat_members") or message.get("left_chat_member"):
+ return
+ if message.get("group_chat_created") or message.get("supergroup_chat_created") or message.get("channel_chat_created"):
+ return
text = (message.get("text") or message.get("caption") or "").strip()
- history_path = self._current_history_file()
- if author_username != self.cfg.allowed_username:
- if is_channel_post or is_group_message:
- self._append_history(history_path, "chat_context_message", {
- "chatId": chat_id,
- "messageId": message_id,
- "updateType": update_type,
- "chatType": chat_type,
- "chatUsername": chat_username,
- "chatTitle": chat_title,
- "username": username,
- "authorSignature": author_signature,
- "text": text,
- "hasVoice": bool(message.get("voice")),
- "hasAudio": bool(message.get("audio")),
- })
- if is_group_message:
- self._safe_send(chat_id, "Получил сообщение.", reply_to=message_id)
+ actor_username = normalize_username(author_username)
+ is_allowed = self._is_allowed_user(actor_username)
+ is_private = chat_type == "private"
+ if not is_allowed:
+ if is_private:
+ self._safe_send(chat_id, "Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.", reply_to=message_id)
return
+ if self._is_allowed_player(actor_username) and not is_private:
+ return
+
+ self._ensure_user_session(actor_username)
+ history_path = self._current_history_file_for_user(actor_username)
+ if self._is_allowed_player(actor_username):
+ self._send_player_welcome_once(chat_id, message_id, actor_username)
if not text:
if message.get("voice"):
self._enqueue_voice_job(
chat_id,
message_id,
- author_username,
+ actor_username,
message["voice"].get("file_id"),
+ media_type="voice",
update_type=update_type,
chat_username=chat_username,
chat_title=chat_title,
@@ -480,8 +643,9 @@ class ShinePyBotService:
self._enqueue_voice_job(
chat_id,
message_id,
- author_username,
+ actor_username,
message["audio"].get("file_id"),
+ media_type="audio",
update_type=update_type,
chat_username=chat_username,
chat_title=chat_title,
@@ -493,7 +657,7 @@ class ShinePyBotService:
return
if text.startswith("/"):
- self._handle_command(chat_id, message_id, author_username, text)
+ self._handle_command(chat_id, message_id, actor_username, text)
return
self._append_history(history_path, "incoming_text", {
@@ -503,11 +667,11 @@ class ShinePyBotService:
"chatType": chat_type,
"chatUsername": chat_username,
"chatTitle": chat_title,
- "username": author_username,
+ "username": actor_username,
"authorSignature": author_signature,
"text": text,
})
- job = self._build_job_base(chat_id, message_id, author_username, str(history_path))
+ job = self._build_job_base(chat_id, message_id, actor_username, str(history_path))
job["type"] = "text"
job["text"] = text
job["update_type"] = update_type
@@ -515,6 +679,8 @@ class ShinePyBotService:
job["chat_username"] = chat_username
job["chat_title"] = chat_title
job["author_signature"] = author_signature
+ job["role"] = "owner" if self._is_owner(actor_username) else "player"
+ job["player_name"] = self._player_name(actor_username) if job["role"] == "player" else ""
with self.queue_lock:
self.queue.append(job)
self._persist_queue()
@@ -527,6 +693,7 @@ class ShinePyBotService:
username: str,
file_id: str | None,
*,
+ media_type: str = "voice",
update_type: str = "message",
chat_username: str = "",
chat_title: str = "",
@@ -536,7 +703,7 @@ class ShinePyBotService:
if not file_id:
self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id)
return
- history_path = self._current_history_file()
+ history_path = self._current_history_file_for_user(username)
self._append_history(history_path, "incoming_voice", {
"chatId": chat_id,
"messageId": message_id,
@@ -547,15 +714,19 @@ class ShinePyBotService:
"username": username,
"authorSignature": author_signature,
"fileId": file_id,
+ "mediaType": media_type,
})
job = self._build_job_base(chat_id, message_id, username, str(history_path))
job["type"] = "voice"
job["telegram_file_id"] = file_id
+ job["telegram_media_type"] = media_type
job["update_type"] = update_type
job["chat_type"] = chat_type
job["chat_username"] = chat_username
job["chat_title"] = chat_title
job["author_signature"] = author_signature
+ job["role"] = "owner" if self._is_owner(username) else "player"
+ job["player_name"] = self._player_name(username) if job["role"] == "player" else ""
with self.queue_lock:
self.queue.append(job)
self._persist_queue()
@@ -579,8 +750,11 @@ class ShinePyBotService:
"chat_username": "",
"chat_title": "",
"author_signature": "",
+ "role": "owner",
+ "player_name": "",
"text": "",
"telegram_file_id": "",
+ "telegram_media_type": "",
"history_file": history_file,
"attempts": 0,
"retry_reason": "",
@@ -592,8 +766,9 @@ class ShinePyBotService:
def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None:
lower = text.lower()
+ is_owner = self._is_owner(username)
if lower in ("/start", "/help"):
- self._safe_send(chat_id, self._help_text(), reply_to=message_id)
+ self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id)
return
if lower == "/status":
self._safe_send(chat_id, self._status_text(), reply_to=message_id)
@@ -606,11 +781,14 @@ class ShinePyBotService:
self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id)
return
if lower in ("/restart_service", "/restart"):
+ if not is_owner:
+ self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id)
+ return
self._append_history_event("restart_service_requested", {
"chatId": chat_id,
"messageId": message_id,
"username": username,
- })
+ }, username=username)
self._safe_send(
chat_id,
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
@@ -644,17 +822,19 @@ class ShinePyBotService:
self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id)
return
- def _help_text(self) -> str:
- return (
- "Доступные команды:\n"
- "/status — активная задача и размер очереди\n"
- "/queue — список задач в очереди\n"
- "/stop — остановить текущую задачу\n"
- "/cancel — удалить задачу по id (префикс) или все\n"
- "/new — архивировать историю и начать новую\n"
- "/restart_service — перезапустить сервис через systemd\n"
- "/help — эта справка"
- )
+ def _help_text(self, *, is_owner: bool) -> str:
+ lines = [
+ "Доступные команды:",
+ "/status — активная задача и размер очереди",
+ "/queue — список задач в очереди",
+ "/stop — остановить текущую задачу",
+ "/cancel — удалить задачу по id (префикс) или все",
+ "/new — архивировать историю и начать новую",
+ "/help — эта справка",
+ ]
+ if is_owner:
+ lines.insert(-1, "/restart_service — перезапустить сервис через systemd")
+ return "\n".join(lines)
def _status_text(self) -> str:
with self.queue_lock:
@@ -768,6 +948,7 @@ class ShinePyBotService:
self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id)
self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer})
self._mark_job_done(job_id)
+ self._send_private_job_public_report(job, answer)
except Exception as e:
if self.stop_current_job:
self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)})
@@ -775,6 +956,15 @@ class ShinePyBotService:
self._mark_job_removed(job_id)
self.stop_current_job = False
return
+ if isinstance(e, VoiceTranscriptionError):
+ self._append_history(history_path, "voice_transcription_failed", {
+ "jobId": job_id,
+ "jobNum": job_num,
+ "stage": e.stage,
+ "retryable": e.retryable,
+ "error": e.user_message,
+ "detail": e.detail,
+ })
self._handle_job_failure(job, e)
def _build_prompt(self, job: dict[str, Any]) -> str:
@@ -782,6 +972,19 @@ class ShinePyBotService:
retry_reason = (job.get("retry_reason") or "").strip()
if retry_reason:
retry_block = f"\n\nПометка retry: {retry_reason}"
+ role = (job.get("role") or "owner").strip().lower()
+ player_block = ""
+ if role == "player":
+ player_name = (job.get("player_name") or "").strip()
+ player_dir = self.cfg.codex_workdir / "Players" / (job.get("username") or "")
+ player_block = (
+ "\n\nРежим игрока (обязательно):\n"
+ f"- Пользователь: {player_name} (@{job.get('username')}).\n"
+ f"- Рабочая папка игрока: {player_dir}\n"
+ "- Код проекта не изменять.\n"
+ "- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.\n"
+ "- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока."
+ )
return (
"Пришло сообщение в Telegram.\n"
f"Тип: {job.get('type')}\n"
@@ -794,7 +997,7 @@ class ShinePyBotService:
f"{job.get('text')}\n\n"
f"История диалога (JSONL): {job.get('history_file')}\n"
f"Инструкции агента: {self.cfg.agent_instructions_file}\n"
- f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{retry_block}"
+ f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}"
)
def _run_codex(self, prompt: str, chat_id: int, message_id: int, job_id: str, job_num: Any) -> str:
@@ -921,7 +1124,11 @@ class ShinePyBotService:
chat_id = int(job["chat_id"])
message_id = int(job["message_id"])
error_text = str(err).strip() or err.__class__.__name__
- print(f"[py-bot] Ошибка job={job_id[:8]}: {error_text}", flush=True)
+ user_error_text = self._user_error_text(err)
+ retryable = not isinstance(err, VoiceTranscriptionError) or err.retryable
+ log_error_text = err.log_text() if isinstance(err, VoiceTranscriptionError) else error_text
+ print(f"[py-bot] Ошибка job={job_id[:8]}: {log_error_text}", flush=True)
+ print(traceback.format_exc(), flush=True)
with self.queue_lock:
target = next((j for j in self.queue if j.get("id") == job_id), None)
@@ -931,7 +1138,7 @@ class ShinePyBotService:
target["attempts"] = attempts
target["last_error"] = error_text[:1000]
target["updated_at"] = now_iso()
- if attempts < self.cfg.max_retries:
+ if retryable and attempts < self.cfg.max_retries:
target["status"] = "pending"
target["retry_reason"] = error_text[:200]
self._persist_queue()
@@ -942,9 +1149,19 @@ class ShinePyBotService:
will_retry = False
if will_retry:
- self._safe_send(chat_id, f"Ошибка задачи #{job_num}, повтор: {attempts}/{self.cfg.max_retries}", reply_to=message_id)
+ self._safe_send(
+ chat_id,
+ f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.",
+ reply_to=message_id,
+ )
else:
- self._safe_send(chat_id, f"Ошибка задачи #{job_num}. Лимит попыток исчерпан.", reply_to=message_id)
+ self._safe_send(chat_id, f"{user_error_text}\nЗадача #{job_num} остановлена.", reply_to=message_id)
+
+ def _user_error_text(self, err: Exception) -> str:
+ if isinstance(err, VoiceTranscriptionError):
+ return f"Не удалось распознать голосовое: {err.user_message}"
+ error_text = str(err).strip() or err.__class__.__name__
+ return f"Ошибка выполнения задачи: {error_text}"
def _mark_job_done(self, job_id: str) -> None:
with self.queue_lock:
@@ -956,27 +1173,145 @@ class ShinePyBotService:
self.queue = [j for j in self.queue if j.get("id") != job_id]
self._persist_queue()
- def _safe_send(self, chat_id: int, text: str, reply_to: int | None = None) -> None:
- text = (text or "").strip()
- if not text:
+ def _remember_public_report_chat(self, chat_id: int) -> None:
+ if self.state.get("public_report_chat_id") == chat_id:
return
- if len(text) > 3900:
- text = text[:3900] + "\n...[обрезано]"
- resolved_chat_id = self._resolve_chat_id(chat_id)
- resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
+ self.state["public_report_chat_id"] = chat_id
+ self.state["updated_at"] = now_iso()
+ self._persist_state()
+
+ def _public_report_chat_id(self) -> int | str | None:
+ chat_id = self.state.get("public_report_chat_id")
+ if isinstance(chat_id, int):
+ return self._resolve_chat_id(chat_id)
+ if self.cfg.allowed_channel_username:
+ return f"@{self.cfg.allowed_channel_username}"
+ return None
+
+ def _send_private_job_public_report(self, job: dict[str, Any], answer: str) -> None:
+ if job.get("chat_type") != "private":
+ return
+ report_chat_id = self._public_report_chat_id()
+ if report_chat_id is None:
+ return
+
+ job_num = job.get("num", "?")
+ source_text = (job.get("text") or "").strip()
+ if not source_text:
+ source_text = "(пустой текст запроса)"
+ role = (job.get("role") or "owner").strip().lower()
+ author_label = "Айдар"
+ if role == "player":
+ player_name = (job.get("player_name") or "").strip() or job.get("username") or "Игрок"
+ author_label = f"{player_name} (@{job.get('username')})"
+ if job.get("type") == "voice":
+ voice_file_id = (job.get("telegram_file_id") or "").strip()
+ media_type = (job.get("telegram_media_type") or "voice").strip()
+ request_caption = self._trim_telegram_caption(
+ f"{author_label} сделал {media_type}-запрос, задача #{job_num}.\n\n"
+ f"Распознанный текст:\n{source_text}"
+ )
+ request_message_id = None
+ if voice_file_id:
+ request_message_id = self._safe_send_telegram_file(
+ report_chat_id,
+ voice_file_id,
+ media_type=media_type,
+ caption=request_caption,
+ )
+ if request_message_id is None:
+ request_message_id = self._safe_send(report_chat_id, request_caption)
+ else:
+ request_report = (
+ f"{author_label} сделал запрос, задача #{job_num}.\n\n"
+ f"{source_text}"
+ )
+ request_message_id = self._safe_send(report_chat_id, request_report)
+ if request_message_id is None:
+ return
+
+ answer_text = (answer or "").strip() or "(пустой ответ)"
+ answer_chunks = split_long_text(f"Ответ на задачу #{job_num}:\n\n{answer_text}")
+ for chunk in answer_chunks:
+ self._safe_send(report_chat_id, chunk, reply_to=request_message_id)
+
+ @staticmethod
+ def _trim_telegram_caption(text: str, limit: int = 1000) -> str:
+ text = (text or "").strip()
+ if len(text) <= limit:
+ return text
+ return text[:limit].rstrip() + "\n...[обрезано]"
+
+ def _safe_send_telegram_file(
+ self,
+ chat_id: int | str,
+ file_id: str,
+ *,
+ media_type: str = "voice",
+ caption: str = "",
+ reply_to: int | None = None,
+ ) -> int | None:
+ file_id = (file_id or "").strip()
+ if not file_id:
+ return None
+ caption = self._trim_telegram_caption(caption)
+ resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
+ resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
+
+ def send(target_chat_id: int | str, target_reply_to: int | None) -> dict[str, Any]:
+ if media_type == "audio":
+ return self.telegram.send_audio(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
+ return self.telegram.send_voice(target_chat_id, file_id, caption=caption, reply_to_message_id=target_reply_to)
+
try:
- self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
+ sent = send(resolved_chat_id, resolved_reply_to)
+ result = sent.get("result") or {}
+ message_id = result.get("message_id")
+ return message_id if isinstance(message_id, int) else None
except Exception as e:
migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
if migrate_to_chat_id is not None:
- self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
+ if isinstance(resolved_chat_id, int):
+ self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error")
try:
- self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
- return
+ sent = send(migrate_to_chat_id, None)
+ result = sent.get("result") or {}
+ message_id = result.get("message_id")
+ return message_id if isinstance(message_id, int) else None
+ except Exception as retry_error:
+ print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True)
+ return None
+ print(f"[py-bot] sendFile error: {e}", flush=True)
+ return None
+
+ def _safe_send(self, chat_id: int | str, text: str, reply_to: int | None = None) -> int | None:
+ text = (text or "").strip()
+ if not text:
+ return None
+ if len(text) > 3900:
+ text = text[:3900] + "\n...[обрезано]"
+ resolved_chat_id: int | str = self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else chat_id
+ resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance(chat_id, str) else None
+ try:
+ sent = self.telegram.send_message(resolved_chat_id, text, reply_to_message_id=resolved_reply_to)
+ result = sent.get("result") or {}
+ message_id = result.get("message_id")
+ return message_id if isinstance(message_id, int) else None
+ except Exception as e:
+ migrate_to_chat_id = self._extract_migrate_to_chat_id(str(e))
+ if migrate_to_chat_id is not None:
+ if isinstance(resolved_chat_id, int):
+ self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_message_error")
+ try:
+ sent = self.telegram.send_message(migrate_to_chat_id, text, reply_to_message_id=None)
+ result = sent.get("result") or {}
+ message_id = result.get("message_id")
+ return message_id if isinstance(message_id, int) else None
except Exception as retry_error:
print(f"[py-bot] sendMessage retry after migration error: {retry_error}", flush=True)
- return
+ return None
print(f"[py-bot] sendMessage error: {e}", flush=True)
+ return None
def _schedule_self_restart(self) -> None:
if self.restart_requested:
@@ -992,26 +1327,94 @@ class ShinePyBotService:
def _transcribe_voice_job(self, job: dict[str, Any]) -> str:
if not self.cfg.openai_api_key:
- raise RuntimeError("Не задан OPENAI_API_KEY для распознавания voice")
+ raise VoiceTranscriptionError(
+ "не настроен ключ OpenAI для распознавания.",
+ stage="config",
+ retryable=False,
+ )
file_id = (job.get("telegram_file_id") or "").strip()
if not file_id:
- raise RuntimeError("Пустой telegram_file_id")
+ raise VoiceTranscriptionError(
+ "Telegram не передал идентификатор файла.",
+ stage="telegram_file_id",
+ retryable=False,
+ )
+ job_id = str(job.get("id") or "")[:8]
+ job_num = job.get("num", "?")
+ media_type = (job.get("telegram_media_type") or "voice").strip()
+ started_at = time.time()
+ print(f"[py-bot] transcribe start job={job_id} num={job_num} media={media_type}", flush=True)
file_bytes, filename = self._download_telegram_file(file_id)
+ print(
+ f"[py-bot] transcribe downloaded job={job_id} filename={filename} size={len(file_bytes)} bytes",
+ flush=True,
+ )
text = self._openai_transcribe(file_bytes, filename).strip()
if not text:
- raise RuntimeError("Распознавание вернуло пустой текст")
+ raise VoiceTranscriptionError(
+ "сервис распознавания вернул пустой текст. Возможно, в записи нет слышимой речи или качество звука слишком низкое.",
+ stage="empty_text",
+ retryable=False,
+ )
+ elapsed = self._format_duration(int(time.time() - started_at))
+ print(f"[py-bot] transcribe done job={job_id} chars={len(text)} elapsed={elapsed}", flush=True)
return text
def _download_telegram_file(self, file_id: str) -> tuple[bytes, str]:
- result = self.telegram.call("getFile", {"file_id": file_id}, timeout=60)
+ try:
+ result = self.telegram.call("getFile", {"file_id": file_id}, timeout=120)
+ except TimeoutError as e:
+ raise VoiceTranscriptionError(
+ "Telegram долго не отдавал информацию о файле.",
+ stage="telegram_get_file_timeout",
+ detail=str(e),
+ ) from e
+ except Exception as e:
+ raise VoiceTranscriptionError(
+ "не удалось получить информацию о файле из Telegram.",
+ stage="telegram_get_file",
+ detail=str(e),
+ ) from e
info = result.get("result") or {}
file_path = info.get("file_path")
if not file_path:
- raise RuntimeError("Telegram getFile не вернул file_path")
+ raise VoiceTranscriptionError(
+ "Telegram не вернул путь к файлу.",
+ stage="telegram_file_path",
+ retryable=True,
+ detail=json.dumps(info, ensure_ascii=False)[:1000],
+ )
file_url = f"https://api.telegram.org/file/bot{self.cfg.telegram_bot_token}/{file_path}"
req = request.Request(file_url, method="GET")
- with request.urlopen(req, timeout=120) as resp:
- data = resp.read()
+ try:
+ with request.urlopen(req, timeout=self.cfg.telegram_file_download_timeout_seconds) as resp:
+ data = resp.read()
+ except TimeoutError as e:
+ raise VoiceTranscriptionError(
+ f"Telegram не успел отдать аудиофайл за {self.cfg.telegram_file_download_timeout_seconds} секунд.",
+ stage="telegram_download_timeout",
+ detail=str(e),
+ ) from e
+ except error.HTTPError as e:
+ detail = e.read().decode("utf-8", errors="replace")
+ raise VoiceTranscriptionError(
+ f"Telegram вернул ошибку HTTP {e.code} при скачивании аудио.",
+ stage="telegram_download_http",
+ retryable=e.code >= 500 or e.code == 429,
+ detail=detail[:1000],
+ ) from e
+ except error.URLError as e:
+ raise VoiceTranscriptionError(
+ "не удалось скачать аудиофайл из Telegram из-за сетевой ошибки.",
+ stage="telegram_download_network",
+ detail=str(e.reason),
+ ) from e
+ if not data:
+ raise VoiceTranscriptionError(
+ "Telegram отдал пустой аудиофайл.",
+ stage="telegram_download_empty",
+ retryable=True,
+ )
original_name = Path(file_path).name or "audio.ogg"
lower = original_name.lower()
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
@@ -1051,11 +1454,40 @@ class ShinePyBotService:
req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
try:
- with request.urlopen(req, timeout=240) as resp:
+ with request.urlopen(req, timeout=self.cfg.openai_transcribe_timeout_seconds) as resp:
return resp.read().decode("utf-8", errors="replace")
+ except TimeoutError as e:
+ raise VoiceTranscriptionError(
+ f"OpenAI не успел распознать аудио за {self.cfg.openai_transcribe_timeout_seconds} секунд.",
+ stage="openai_transcribe_timeout",
+ detail=str(e),
+ ) from e
except error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
- raise RuntimeError(f"OpenAI transcribe HTTP {e.code}: {detail}") from e
+ if e.code == 400:
+ user_message = "OpenAI не принял аудиофайл для распознавания."
+ elif e.code == 401:
+ user_message = "OpenAI отклонил ключ API для распознавания."
+ elif e.code == 413:
+ user_message = "аудиофайл слишком большой для распознавания OpenAI."
+ elif e.code == 429:
+ user_message = "OpenAI временно ограничил распознавание из-за лимита запросов."
+ elif e.code >= 500:
+ user_message = "OpenAI временно не смог обработать распознавание."
+ else:
+ user_message = f"OpenAI вернул ошибку HTTP {e.code} при распознавании."
+ raise VoiceTranscriptionError(
+ user_message,
+ stage="openai_transcribe_http",
+ retryable=e.code == 429 or e.code >= 500,
+ detail=detail[:1500],
+ ) from e
+ except error.URLError as e:
+ raise VoiceTranscriptionError(
+ "не удалось отправить аудио в OpenAI из-за сетевой ошибки.",
+ stage="openai_transcribe_network",
+ detail=str(e.reason),
+ ) from e
@staticmethod
def _extract_codex_user_note(line: str) -> str | None:
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 1f48cba..2cb4931 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -59,6 +59,7 @@ import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
+import * as solanaUsersInitView from './pages/solana-users-init-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
@@ -98,6 +99,7 @@ const routes = {
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
+ 'solana-users-init-view': solanaUsersInitView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js
index 7ea90ef..d7b6d7b 100644
--- a/shine-UI/js/pages/developer-settings-view.js
+++ b/shine-UI/js/pages/developer-settings-view.js
@@ -262,6 +262,7 @@ export function render({ navigate }) {
+
@@ -275,8 +276,10 @@ export function render({ navigate }) {
const forceUpdateBtn = card.querySelector('#settings-force-ui-update');
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
+ const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
+ solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
diff --git a/shine-UI/js/pages/solana-users-init-view.js b/shine-UI/js/pages/solana-users-init-view.js
new file mode 100644
index 0000000..062ad15
--- /dev/null
+++ b/shine-UI/js/pages/solana-users-init-view.js
@@ -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;
+}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index b9f5d61..bb4351a 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -182,7 +182,8 @@ export function resolveToolbarActive(pageId) {
pageId === 'device-session-view' ||
pageId === 'language-view' ||
pageId === 'app-log-view' ||
- pageId === 'pwa-diagnostics-view'
+ pageId === 'pwa-diagnostics-view' ||
+ pageId === 'solana-users-init-view'
) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js
index 8b5540d..c91345d 100644
--- a/shine-UI/js/services/solana-wallet-service.js
+++ b/shine-UI/js/services/solana-wallet-service.js
@@ -1,8 +1,9 @@
import { deriveEd25519FromPassword } from './crypto-utils.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
+import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
-const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
+const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
let solanaLibPromise = null;
diff --git a/shine-UI/js/solana-programs.js b/shine-UI/js/solana-programs.js
new file mode 100644
index 0000000..8c84213
--- /dev/null
+++ b/shine-UI/js/solana-programs.js
@@ -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';
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index 9e5245e..6fcbadb 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -1,5 +1,6 @@
import { AuthService } from './services/auth-service.js';
import { listStoredMessages, putStoredMessage } from './services/message-store.js';
+import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
@@ -76,7 +77,7 @@ function inferTunnelWsUrl() {
}
const LOCAL_WS_OVERRIDE_URL = readLocalWsOverrideUrl() || inferTunnelWsUrl();
-const DEFAULT_SOLANA_SERVER = 'https://api.devnet.solana.com';
+const DEFAULT_SOLANA_SERVER = SOLANA_ENDPOINT_DEFAULT;
const DEFAULT_SHINE_SERVER = 'wss://shineup.me/ws';
const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
diff --git a/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java b/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java
new file mode 100644
index 0000000..f238da6
--- /dev/null
+++ b/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java
@@ -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";
+}
+
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java
index 066bd86..7ee1ee5 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java
@@ -18,6 +18,7 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.List;
public class Net_GetMessageThread_Handler implements JsonMessageHandler {
@@ -182,6 +183,10 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
node.setVersionsTotal(versions.size());
node.setText(versions.get(versions.size() - 1).getText());
+ if (row.blockBytes != null) {
+ node.setRawBlockB64(Base64.getEncoder().encodeToString(row.blockBytes));
+ }
+
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageThread_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageThread_Response.java
index 963aff1..7a9f37f 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageThread_Response.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageThread_Response.java
@@ -21,9 +21,13 @@ public class Net_GetMessageThread_Response extends Net_Response {
public static class MessageNode extends Net_GetChannelMessages_Response.MessageItem {
private ChannelInfo channelInfo;
+ private String rawBlockB64;
public ChannelInfo getChannelInfo() { return channelInfo; }
public void setChannelInfo(ChannelInfo channelInfo) { this.channelInfo = channelInfo; }
+
+ public String getRawBlockB64() { return rawBlockB64; }
+ public void setRawBlockB64(String rawBlockB64) { this.rawBlockB64 = rawBlockB64; }
}
public static class ChannelInfo {
diff --git a/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md b/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md
index 299b098..036c130 100644
--- a/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md
+++ b/shine-solana/shine/doc/SHiNE-user-format-v.1.0.md
@@ -51,7 +51,7 @@
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
-| 2 | `format_major` | `u8` | 1 | Для нового формата: `2`. |
+| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
@@ -63,7 +63,7 @@
После первых 9 полей идет набор типизированных блоков:
```text
-UserPdaRecordV2
+UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
@@ -89,7 +89,7 @@ UserPdaRecordV2
Правила:
-- неизвестный `block_type` в `format_major = 2` считается ошибкой;
+- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
diff --git a/shine-solana/shine/programs/shine_users/src/users.rs b/shine-solana/shine/programs/shine_users/src/users.rs
index 7055f79..7a30cfe 100644
--- a/shine-solana/shine/programs/shine_users/src/users.rs
+++ b/shine-solana/shine/programs/shine_users/src/users.rs
@@ -12,7 +12,7 @@ use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
use std::str::FromStr;
const MAGIC: &[u8; 5] = b"SHiNE";
-const FORMAT_MAJOR: u8 = 2;
+const FORMAT_MAJOR: u8 = 1;
const FORMAT_MINOR: u8 = 0;
const MAX_SYNC_SERVERS: usize = 32;
const MAX_AUTO_REALLOC_INCREASE: usize = 10_000;
diff --git a/shine-solana/shine/tests/shine.ts b/shine-solana/shine/tests/shine.ts
index 1a1b82e..6b3ea82 100644
--- a/shine-solana/shine/tests/shine.ts
+++ b/shine-solana/shine/tests/shine.ts
@@ -11,7 +11,7 @@ import { expect } from "chai";
import { Shine } from "../target/types/shine";
const MAGIC = Buffer.from("SHiNE", "utf8");
-const FORMAT_MAJOR = 2;
+const FORMAT_MAJOR = 1;
const FORMAT_MINOR = 0;
const ZERO_HASH = Buffer.alloc(32, 0);
const LAST_BLOCK_STATE_PREFIX = Buffer.from("SHiNE_LAST_BLOCK", "utf8");