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 @@ + + Архитектура Solana-программ SHiNE + Схема трех программ, DAO, PDA-счетов и движения денег. + + + + + + + + + + + + SHiNE Solana: программы, DAO, счета и движение денег + Текущая модель: три Anchor-программы, DAO/authority как управляющий слой, inflow vault и DAO treasury. + + + Пользователь + signer, root_key, device_key + + + Покупатель тикета + buy_ticket* + + + Менеджер + manager_add_ticket + + + Любой caller + step_payout + + + 1. shine_login_guard + classify_login + free / premium / trademark + + + 2. shine_users + create_user_pda + update_user_pda + economy config + + + 3. shine_payments + vault, tickets, queues + grant_manager_limits + step_payout + + + SHiNE DAO + governance / authority + treasury dao_wallet + ключи через голосование + + + shine_users PDA + user_pda, economy_config + + + shine_payments PDA + config_pda, coef_limit_pda + queues_pda + inflow_vault_pda + ticket_pda, manager_allowance + + + inflow_vault + деньги регистрации + + + DAO treasury + dao_wallet + + + регистрация / update + + + CPI login + + + создает/обновляет + + + регистрация и лимит -> inflow_vault + + + покупка тикета -> DAO treasury + + + создать тикет + + + PDA состояния + + + + + call reward caller + + + выплата получателю тикета + + + DAO-часть выплат + + + update economy + + + settings / managers + + + upgrade-authority: users/payments; login_guard позже + + + + логические вызовы и управление + + движение SOL/lamports + + будущая передача upgrade-authority DAO + 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");