diff --git a/.gitignore b/.gitignore index 6c2b95da..29bcddec 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ .kotlin ### IntelliJ IDEA ### +.idea/ .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml @@ -102,10 +103,12 @@ ESP32/**/*.d ESP32/**/*.a # Полные серверные бэкапы (тяжёлые архивы, не коммитим) -server-backup/archive/** -!server-backup/archive/.gitkeep +deploy/backup/archive/** +!deploy/backup/archive/.gitkeep -# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим +# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим +.agents/ +.codex/ .claude/ # Рабочие бэкапы/превью-ассеты UI — не для репозитория *.bak.png diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c2083d37..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 77cff8d8..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -shine-server-server \ No newline at end of file diff --git a/.idea/artifacts/server_jar.xml b/.idea/artifacts/server_jar.xml deleted file mode 100644 index a890428b..00000000 --- a/.idea/artifacts/server_jar.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - $PROJECT_DIR$/out/artifacts/server_jar - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 6a8fc097..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index a19bb717..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 0faa7979..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2019fbc4..286a1818 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,14 +14,15 @@ - Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`: - точка входа `shine-UI/server-ui.html`; - остальные файлы серверного UI — в `shine-UI/server-ui/`. -- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения. +- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения. - Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя. ## Сервис агента-кодера -- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`. +- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`. - Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту. -- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`. -- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`. +- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`. +- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`. +- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`. - Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`. ## ESP32 UI homeserver @@ -36,68 +37,62 @@ - Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения. - В 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 deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля со скриптами основного server/UI deploy из `deploy/scripts/`. - Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в: - - `Dev_Docs/Инициализация_Solana_регистрации/README.md` + - `docs/Инициализация_Solana_регистрации/README.md` - Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте. - Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в: - - `Dev_Docs/Solana_Architecture/README.md` + - `docs/Solana_Architecture/README.md` - Документ формата пользовательской PDA-записи `shine_users` находится в: - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` ## Документация блокчейна -- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`. +- Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`. - Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям. -- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`. -- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение. +- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `docs/Blockchain/`. +- Дополнительно обязательно вести `docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение. - Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён. - Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять). - Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`. - Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока. ## Документация личных сообщений (DM) -- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`. -- Точный байтовый формат DM находится в `Dev_Docs/Personal_Messages/Формат_DM_v1.md`. +- Актуальная документация по логике личных сообщений находится в `docs/Personal_Messages/Протокол_DM_v1.md`. +- Точный байтовый формат DM находится в `docs/Personal_Messages/Формат_DM_v1.md`. - При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа: - - `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - - `Dev_Docs/Personal_Messages/Формат_DM_v1.md` + - `docs/Personal_Messages/Протокол_DM_v1.md` + - `docs/Personal_Messages/Формат_DM_v1.md` - Логика личных сообщений в коде должна всегда соответствовать этим документам. - Документ по личным сообщениям обязан поддерживаться в актуальном состоянии. ## Документация API сервера -- Актуальная документация по публичному JSON/WebSocket API сервера находится в `Dev_Docs/API/`. -- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `Dev_Docs/API/`. +- Актуальная документация по публичному JSON/WebSocket API сервера находится в `docs/API/`. +- При любом изменении серверного API/эндпоинтов/операций `op` обязательно обновлять соответствующие документы в `docs/API/`. - Перед изменением самого серверного API обязательно явно предупредить пользователя, какие операции, поля запросов/ответов или коды ошибок будут изменены, и запросить отдельное подтверждение. - Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду. -- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет. +- Если добавляется новая операция `op`, нужно обновить общий список операций в `docs/API/09_Operations_Index.md` или создать его, если файла ещё нет. ## Документация Figma -- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`. -- Точка входа: `Dev_Docs/Figma/README.md`. -- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`. +- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `docs/Figma/`. +- Точка входа: `docs/Figma/README.md`. +- Подробный рабочий регламент: `docs/Figma/TRANSFER_UI_SCREENS.md`. - Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ. ## Версионирование -- Единый файл версий проекта: `VERSION.properties` (в корне репозитория). -- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`: -- `client.version` — версия клиентского UI. -- `server.version` — версия серверной части. -- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное. -- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется. +- Все правила по коммитам, merge в `main`, `git push` и обновлению `VERSION.properties` находятся в `COMMIT_AND_VERSION_RULES.md`. +- Этот файл считать единым источником истины по правилам версионирования и коммитов для данного репозитория. ## Deploy -- Все документы и заметки по деплою хранить в папке `Deploy/`. -- Production-хост SHiNE: `player@shineup.me` (`178.208.64.62`). -- Второй production-хост SHiNE: `player@193.8.215.70` (`server2.shineup.me`). -- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`). +- Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`. +- Подробные правила для агента по деплою находятся в `deploy/AGENTS.md`; перед любым deploy читать этот файл. +- Production-серверы SHiNE: `shineup.me` и `server2.shineup.me`. +- Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. +- В deploy-документах и скриптах использовать домены, а не IP. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. -- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`. -- Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя. -- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `server2.shineup.me`. -- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`. -- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`. -- Production server deploy: `./gradlew deployServerProduction`. -- Production UI deploy: `./gradlew deployUIProduction`. +- Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя. +- Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`. +- Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически. +- Deploy выполнять shell-скриптами из `deploy/scripts/`; Gradle deploy-задачи не использовать. - Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`). - Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя. - Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`. @@ -123,26 +118,13 @@ - `unknown_error` - В этих записях искать поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`. -## Недопроверенные фичи (обязательно) -- Папка для учёта недопроверенных фич: `Dev_Docs/Pending_Features/`. -- По каждой новой доработке, которая требует ручной проверки, добавлять отдельный markdown-файл в `Dev_Docs/Pending_Features/`. -- Рекомендуемый формат имени файла: `YYYY-MM-DD_HHMM_.md`. -- Имена новых файлов и краткие описания фич по возможности писать на русском языке. -- Внутри файла обязательно указывать: - - краткое описание фичи; - - что именно проверять; - - ожидаемый результат; - - статус (например: `pending`, `in_progress`, `done`). -- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять. -- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность. - ## Будущие фичи / TODO - Папка для задач, сознательно отложенных на будущее: `TODO/`. - Точка входа по планам: `TODO/README.md`. - Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам. - Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок. - Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя. -- Старую папку `Dev_Docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей. +- Старую папку `docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей. - Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать: - какие файлы и участки отключены; - что осталось в коде как заготовка; diff --git a/CLAUDE.md b/CLAUDE.md index 0e5b4c5e..c75cd666 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ @shine-UI/AGENTS.md ## Справка по подпроектам -- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`. +- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`. - При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`. - При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`. - При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`. diff --git a/COMMIT_AND_VERSION_RULES.md b/COMMIT_AND_VERSION_RULES.md new file mode 100644 index 00000000..31026e08 --- /dev/null +++ b/COMMIT_AND_VERSION_RULES.md @@ -0,0 +1,47 @@ +# Правила коммитов, merge и версионирования + +Этот файл является единым источником правил для: +- коммитов; +- merge в `main`; +- изменения версий в `VERSION.properties`; +- `git push` из этого репозитория. + +## Язык + +- Пояснения к коммитам, PR и merge-запросам писать на русском языке. + +## Где хранится версия + +- Единый файл версий проекта: `VERSION.properties` в корне репозитория. +- Основные поля: +- `client.version` — версия клиентского UI. +- `server.version` — версия серверной части. + +## Базовое правило для обычных коммитов + +- Перед каждым новым коммитом обязательно обновлять версии в `VERSION.properties`. +- Если менялся только UI, увеличивать только `client.version`. +- Если менялся только сервер, увеличивать только `server.version`. +- Если менялись и UI, и сервер, увеличивать обе версии. +- Для обычных коммитов вне `main` использовать стандартный patch-инкремент: `+1` к последнему числовому сегменту. +- Пример: `1.2.346` → `1.2.347`. + +## Правило для `main` + +- Ветка `main` предназначена только для стабильных версий. +- По умолчанию в `main` нужно не коммитить напрямую, а мержить готовые изменения из рабочей ветки. +- Если пользователь просит сделать прямой коммит в `main`, нужно отдельно и явно предупредить, что это обход обычного стабильного процесса, и обязательно переспросить подтверждение. + +## Версионирование при merge или прямом коммите в `main` + +- Для попадания изменений в `main` действует отдельная схема инкремента. +- Нужно увеличивать вторую цифру версии и обнулять третью. +- Пример: `1.2.346` → `1.3.0`. +- Если в наборе изменений менялся только UI, обновлять только `client.version`. +- Если в наборе изменений менялся только сервер, обновлять только `server.version`. +- Если соответствующая часть не менялась, её версию не трогать. + +## Правила для git commit и git push + +- Обычные коммиты делать стандартным `git commit`; токен для локального коммита не нужен и не используется. +- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`. diff --git a/Deploy/PRODUCTION_SERVERS.md b/Deploy/PRODUCTION_SERVERS.md deleted file mode 100644 index a3177b51..00000000 --- a/Deploy/PRODUCTION_SERVERS.md +++ /dev/null @@ -1,93 +0,0 @@ -# Production-серверы SHiNE - -## Короткий ответ - -По текущим данным репозитория у SHiNE описаны **два production-контура**: - -- `player@shineup.me` -- домен `shineup.me` -- IP `178.208.64.62` - -и - -- `player@193.8.215.70` -- домен `server2.shineup.me` -- IP `193.8.215.70` - -## 1. Основной production-хост - -- SSH: `player@shineup.me` -- домен: `shineup.me` -- IP: `178.208.64.62` -- пользователь: `player` -- базовый путь: `/home/player` - -Основные каталоги: - -- проект SHiNE: `/home/player/SHiNE` -- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar` -- UI: `/home/player/SHiNE/shine-ui` -- данные: `/home/player/SHiNE/shine-server/data/` -- логи: `/home/player/SHiNE/shine-server/logs/app.log` - -Сервисы: - -- `shine-server.service` -- `caddy.service` - -Caddy: - -- активный конфиг: `/etc/caddy/Caddyfile` -- UI root: `/home/player/SHiNE/shine-ui` -- `/ws` проксируется на `127.0.0.1:7070` - -Deploy: - -- `./gradlew deployServerProduction` -- `./gradlew deployUIProduction` - -Правило: - -- любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя. - -## 2. Второй production-сервер - -- SSH: `player@193.8.215.70` -- домен: `server2.shineup.me` -- IP: `193.8.215.70` -- пользователь: `player` -- базовый путь: `/home/player` - -Роль: - -- второй production-контур SHiNE; -- использовать как production-сервер, несмотря на исторические имена deploy-задач - `deployServerTest2` / `deployUITest2`. - -Основные каталоги: - -- проект SHiNE: `/home/player/SHiNE` -- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar` -- UI: `/home/player/SHiNE/shine-ui` -- данные: `/home/player/SHiNE/shine-server/data/` -- логи: `/home/player/SHiNE/shine-server/logs/app.log` - -## 3. Связанные публичные production-публикации на том же хосте - -На этом же production-хосте есть отдельная публикация для `shine_payments`: - -- каталог: `/home/player/sites/test-solana-tickets.shineup.me` -- домены: - - `https://test-solana-tickets.shineup.me` - - `https://test-solana-tickets.shiningpeople.ru` - -Это не второй production-хост SHiNE, а отдельный сайт на том же сервере. - -## 4. Какие серверы не считать production - -Не production: - -- `t1.shineup.me` -- `t2.shineup.me` -- `t3.shineup.me` -- `t4.shineup.me` diff --git a/Deploy/README.md b/Deploy/README.md deleted file mode 100644 index 0f730fcf..00000000 --- a/Deploy/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Deploy - -Подробности о том, где что задеплоено в SHiNE, нужно искать в папке `Deploy/`. - -Эта папка служит краткой картой окружений: - -- [TEST_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/TEST_SERVERS.md) — тестовые стенды; -- [PRODUCTION_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/PRODUCTION_SERVERS.md) — production-контур и связанные публичные публикации. - -Ниже краткая сводка. - -## Основные публичные контуры - -- Production SHiNE: - - `player@shineup.me` - - домен `shineup.me` - - IP `178.208.64.62` -- Второй production SHiNE: - - `player@193.8.215.70` - - домен `server2.shineup.me` - - IP `193.8.215.70` -## Отдельный quad-devnet стенд - -На отдельном VPS `178.208.90.249` подняты 4 независимых test/devnet-инстанса: - -- `t1.shineup.me` -- `t2.shineup.me` -- `t3.shineup.me` -- `t4.shineup.me` - -## Важно - -- Production-контура SHiNE сейчас два: `shineup.me` и `server2.shineup.me`. -- `t1..t4.shineup.me` — это отдельные тестовые/devnet-контуры, не production. -- Любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя. diff --git a/Deploy/TEST_SERVERS.md b/Deploy/TEST_SERVERS.md deleted file mode 100644 index eb214bb2..00000000 --- a/Deploy/TEST_SERVERS.md +++ /dev/null @@ -1,124 +0,0 @@ -# Тестовые серверы SHiNE - -Этот файл описывает тестовые стенды, которые сейчас фигурируют в проекте. - -## 1. Исторический `test2`, теперь второй production-сервер - -- SSH: `player@193.8.215.70` -- Домен: `server2.shineup.me` -- IP: `193.8.215.70` -- Назначение: второй production-контур SHiNE - -Структура: - -- каталог SHiNE: `/home/player/SHiNE` -- сервер: `/home/player/SHiNE/shine-server/shine-server.jar` -- UI: `/home/player/SHiNE/shine-ui` -- данные: `/home/player/SHiNE/shine-server/data/` -- логи: `/home/player/SHiNE/shine-server/logs/app.log` - -Сервисы: - -- `shine-server.service` -- `caddy.service` - -Deploy: - -- `./gradlew deployServer` -- `./gradlew deployServerTest2` -- `./gradlew deployUI` -- `./gradlew deployUITest2` - -Примечания: - -- этот хост больше не считать test-контуром; -- исторические имена deploy-задач `deployServerTest2` / `deployUITest2` сохранены, но сам хост считать production; -- задача `deployUITest2` по умолчанию выкладывает UI на `server2.shineup.me`, а не на `t2.shineup.me`; -- при описании окружений перечислять его как второй production-сервер. - -## 2. Отдельный quad-devnet стенд `t1..t4` - -- VPS: `178.208.90.249` -- пользователь: `player` -- назначение: 4 независимых SHiNE-инстанса на Solana `devnet` - -Домены и логины: - -- `server_t1` -> `https://t1.shineup.me` -- `server_t2` -> `https://t2.shineup.me` -- `server_t3` -> `https://t3.shineup.me` -- `server_t4` -> `https://t4.shineup.me` - -Каталоги: - -- `/home/player/t1/server` -- `/home/player/t1/UI` -- `/home/player/t2/server` -- `/home/player/t2/UI` -- `/home/player/t3/server` -- `/home/player/t3/UI` -- `/home/player/t4/server` -- `/home/player/t4/UI` - -Подробная памятка на самом VPS: - -- `/home/player/Agents.md` - -Порты и systemd: - -- `t1` -> `7101` -> `shine-t1.service` -- `t2` -> `7102` -> `shine-t2.service` -- `t3` -> `7103` -> `shine-t3.service` -- `t4` -> `7104` -> `shine-t4.service` - -Что важно по конфигу каждого инстанса: - -- отдельный `/home/player/tX/server/application.properties` -- `server.port=710X` -- `server.SHiNE.login=server_tX` -- `db.path=data/shine.sqlite` -- `solana.cluster=devnet` -- `solana.rpcUrl=https://api.devnet.solana.com` -- `server.ui.indexPath=/home/player/tX/UI/index.html` -- `server.info.url=https://tX.shineup.me` - -UI каждого инстанса: - -- живёт в отдельной копии `shine-UI`; -- использует свой `js/deploy-config.js`; -- по умолчанию смотрит именно на свой `tX.shineup.me`. - -Caddy на стенде: - -- конфиг: `/etc/caddy/Caddyfile` -- статика: `/home/player/tX/UI` -- `/ws` проксируется на `127.0.0.1:710X` - -Operational-нюанс: - -- при одновременных рестартах возможны `HTTP 429` от `api.devnet.solana.com`; -- поэтому сервисы `shine-t1..shine-t4` лучше перезапускать по одному, с паузой. - -## 3. Что проверять первым делом - -Для любого test-контура полезны такие быстрые проверки: - -```bash -curl -I https://server2.shineup.me -curl -I https://t1.shineup.me -curl -I https://t2.shineup.me -curl -I https://t3.shineup.me -curl -I https://t4.shineup.me -``` - -Для quad-devnet VPS: - -```bash -sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 -``` - -Для второго production-контура: - -```bash -sudo systemctl --no-pager --full status shine-server caddy -``` diff --git a/Players/blackbyrd1/history/.gitkeep b/Players/blackbyrd1/history/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/blackbyrd1/history/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/dimasol1/files/.gitkeep b/Players/dimasol1/files/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/dimasol1/files/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md b/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md deleted file mode 100644 index 238b3947..00000000 --- a/Players/dimasol1/files/2026-05-30_agent_instructions_approval_task.md +++ /dev/null @@ -1,71 +0,0 @@ -# Задание для Айдара: навести порядок в инструкциях агентов SHiNE - -## Кратко -Нужно согласовать и оформить единый порядок инструкций для Codex/Telegram-агентов в проекте SHiNE, чтобы агенты стабильно понимали структуру проекта, границы ответственности и правила работы с сервером, UI, Solana-модулем, Telegram-ботом и игроками. - -## Зачем это нужно -Сейчас проект состоит из нескольких связанных, но разных частей: - -- основной сервер `SHiNE-server/`; -- UI `shine-UI/`; -- Solana/Anchor-модуль `shine-solana/shine/`; -- Telegram-агент-кодер `SHiNE-agent-bot-coder/`; -- TURN-сервер; -- документация `Dev_Docs/`; -- отдельные рабочие папки игроков `Players/`. - -Без явных инструкций агент может путать эти зоны: например, смешать деплой Solana с деплоем сервера, изменить код от имени игрока, не обновить документацию API/DM/блокчейна или неправильно трактовать файл инструкций. - -## Что предлагается сделать -1. Утвердить корневой `AGENTS.md` как главный набор правил проекта. -2. Проверить и при необходимости уточнить локальный `AGENTS.md` внутри `shine-solana/shine/`. -3. Оставить отдельные служебные инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENT.md`. -4. Оставить автоматически читаемые инструкции Telegram-агента в `SHiNE-agent-bot-coder/AGENTS.md`. -5. Явно закрепить режим игроков: - - игроки могут задавать вопросы, просить анализ, идеи и ТЗ; - - игроки не меняют код проекта напрямую; - - материалы игроков сохраняются только в `Players//`. -6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`. -7. Добавить простой процесс согласования изменений инструкций: - - Дима или другой участник готовит предложение; - - Айдар получает уведомление/заявку; - - Айдар отвечает: одобрить, отклонить или попросить доработать; - - только после одобрения агент вносит изменения в проектные инструкции. - -## Предлагаемая логика уведомления Айдару -Минимальный вариант без сложной разработки: - -1. Агент готовит текст заявки. -2. Текст отправляется Айдару в Telegram или в общий рабочий чат. -3. В заявке явно указаны варианты ответа: - - `одобрить`; - - `отклонить`; - - `доработать: ...`. -4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка. - -Более удобный вариант на будущее: - -- добавить в Telegram-бота команду или сценарий согласования задач, например: - - `/approve `; - - `/reject причина`; - - `/revise комментарий`. - -Но для начала достаточно простого текстового согласования через Telegram. - -## Что нужно от Айдара -Подтвердить, что такой порядок подходит: - -1. Корневой `AGENTS.md` остается главным правилом проекта. -2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила. -3. Игроки не меняют код напрямую, а готовят материалы и предложения. -4. Изменения инструкций выполняются только после явного одобрения Айдара. -5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок. - -## Ожидаемый результат -После одобрения: - -- агенты будут стабильнее понимать границы проекта; -- снизится риск случайных изменений не в той части системы; -- появится понятный порядок согласования задач от игроков; -- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов. - diff --git a/Players/dimasol1/history/.gitkeep b/Players/dimasol1/history/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/dimasol1/history/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/malvviiina/files/.gitkeep b/Players/malvviiina/files/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/malvviiina/files/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/malvviiina/history/.gitkeep b/Players/malvviiina/history/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/malvviiina/history/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/malvviiina/shine-staging-screenshots/channels-list.png b/Players/malvviiina/shine-staging-screenshots/channels-list.png deleted file mode 100644 index 3f33dcf7..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/channels-list.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png b/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png deleted file mode 100644 index 698a1882..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/contact-search-view.png b/Players/malvviiina/shine-staging-screenshots/contact-search-view.png deleted file mode 100644 index afea30b0..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/contact-search-view.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/login.png b/Players/malvviiina/shine-staging-screenshots/login.png deleted file mode 100644 index 411333fb..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/login.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/messages-list.png b/Players/malvviiina/shine-staging-screenshots/messages-list.png deleted file mode 100644 index 91499d93..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/messages-list.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/network-view.png b/Players/malvviiina/shine-staging-screenshots/network-view.png deleted file mode 100644 index b0a1ff79..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/network-view.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/notifications-view.png b/Players/malvviiina/shine-staging-screenshots/notifications-view.png deleted file mode 100644 index 91499d93..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/notifications-view.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/register.png b/Players/malvviiina/shine-staging-screenshots/register.png deleted file mode 100644 index 5dcdb8ce..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/register.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/settings-view.png b/Players/malvviiina/shine-staging-screenshots/settings-view.png deleted file mode 100644 index 91499d93..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/settings-view.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/start.png b/Players/malvviiina/shine-staging-screenshots/start.png deleted file mode 100644 index ff94443c..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/start.png and /dev/null differ diff --git a/Players/malvviiina/shine-staging-screenshots/wallet-view.png b/Players/malvviiina/shine-staging-screenshots/wallet-view.png deleted file mode 100644 index 91499d93..00000000 Binary files a/Players/malvviiina/shine-staging-screenshots/wallet-view.png and /dev/null differ diff --git a/Players/oidasyda/files/.gitkeep b/Players/oidasyda/files/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/oidasyda/files/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/oidasyda/history/.gitkeep b/Players/oidasyda/history/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/oidasyda/history/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/zodiaktechnika32/files/.gitkeep b/Players/zodiaktechnika32/files/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/zodiaktechnika32/files/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Players/zodiaktechnika32/history/.gitkeep b/Players/zodiaktechnika32/history/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/zodiaktechnika32/history/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/SHiNE-agent-bot-coder/.env.example b/SHiNE-agent-bot-coder/.env.example deleted file mode 100644 index b4b48115..00000000 --- a/SHiNE-agent-bot-coder/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -TELEGRAM_BOT_TOKEN=replace_me -OPENAI_API_KEY=replace_me -ALLOWED_TELEGRAM_USERNAME=AidarKC -ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима -ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing -BOT_USERNAME=aidar_su_bot -OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe -TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 -OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 -OPENAI_TTS_MODEL=gpt-4o-mini-tts -OPENAI_TTS_VOICE=alloy -OPENAI_TTS_RESPONSE_FORMAT=opus -OPENAI_TTS_TIMEOUT_SECONDS=180 -OPENAI_TTS_CHUNK_CHARS=3500 -CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl -CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256 -CODEX_TIMEOUT_SECONDS=900 -MAX_RETRIES=3 -DATA_DIR=./data diff --git a/SHiNE-agent-bot-coder/.gitignore b/SHiNE-agent-bot-coder/.gitignore deleted file mode 100644 index 2af457e8..00000000 --- a/SHiNE-agent-bot-coder/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -data/ -logs/ -run/ -__pycache__/ diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md deleted file mode 100644 index a6394042..00000000 --- a/SHiNE-agent-bot-coder/AGENT.md +++ /dev/null @@ -1,81 +0,0 @@ -# AGENT.md для SHiNE-agent-bot-coder - -Ты запущен как обработчик входящего Telegram-сообщения от пользователя. - -## Контекст -- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с этим проектом. -- Сервис принимает входящие сообщения от пользователя Telegram, сохраняет историю, ставит задачи в очередь и последовательно запускает Codex CLI в рабочем проекте. -- Текстовые сообщения обрабатываются напрямую, voice/audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача. -- История диалога хранится в JSONL-файле, путь передаётся в промпте. -- Сообщение может быть текстом или результатом распознавания голосового. -- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение. -- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара. -- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды. -- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги. - -## Авторитет команд и история -- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`. -- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players//`. -- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке. -- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом. -- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат. -- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда. -- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет. - -## Очередь и состояние -- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте. -- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. -- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history//`. -- Архив истории после `/new`: `data/history//archive/`. -- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume. -- Для просмотра истории игрока открывать файлы в его папке истории по username. -- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. -- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. -- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать. -- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. -- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`; для новых пользователей оно включено по умолчанию. -- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает голосовую версию без длинных хэшей, путей, команд и технического шума, сохраняя смысл и порядок исходного ответа. -- Режим личных ответов настраивается персонально командами `/single_message_on`, `/single_message_off`: либо одно редактируемое сообщение по этапам, либо отдельные сообщения как раньше. -- Команда `/settings` должна сразу показывать текущее состояние всех персональных настроек пользователя и список команд для их изменения. -- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS даже для текстовых запросов. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать. -- Команда `/status` должна показывать состояние очереди и персональные настройки: voice-ответы, адаптацию текста перед озвучкой и режим одного сообщения в личке. - -## Правила голосовой версии ответа -- Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали. -- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS. -- В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух. -- Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое. -- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе. - -## Планы и отложенные фичи -- Планы проекта по отложенным фичам хранятся в `TODO/`. -- Внутри есть три горизонта: - - `near/` - ближайшие планы, обычно сегодня/завтра; - - `medium/` - среднесрочные планы, обычно недели или 1-2 месяца; - - `far/` - дальнее будущее без понятного срока. -- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам. -- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя. -- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`. - -## Центр задач и предложений -- Сервис хранит простые задачи и предложения в `data/task_center/items.json`. -- Айдар может смотреть список через `/tasks` или естественные фразы вроде «покажи мои задачи», «покажи задачи Миланы». -- Айдар может ставить задачи игрокам фразой вида «поставь задачу Милане: ...». -- Игроки могут отправлять предложения Айдару фразой вида `предложение: ...`, `идея: ...` или `заявка: ...`. -- Статусы меняются фразами с ID: `одобрить TC-0001`, `отклонить TC-0001`, `доработать TC-0001`, `закрыть TC-0001`. -- После финального ответа в личном чате сервис добавляет короткое напоминание, если у пользователя есть активные задачи или предложения. - -## Локальный запуск и systemd -- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`. -- Локальные секреты и параметры должны храниться в `.env`, этот файл не коммитится. -- Для проверки Codex без Telegram можно использовать self-test режим сервиса. -- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`. -- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service. -- Команда Telegram `/restart` (`/restart_service`) доступна только Айдару и выполняет отложенный рестарт после текущей задачи, до взятия следующей. Аварийный жёсткий рестарт доступен только Айдару командами `/restart_hard`, `/restart_now`, `/restart_force`. - -## Правила ответа -- Пиши содержательно и коротко. -- Не упоминай внутренние служебные детали, файловую систему и технические логи. -- Если запрос требует действий с кодом/проектом, выполняй их в рабочей директории. -- Если для ответа данных недостаточно, задай ровно один уточняющий вопрос. -- Если была ошибка предыдущего запуска, в промпте будет пометка retry — учти это и продолжи с учётом текущего состояния проекта. diff --git a/SHiNE-agent-bot-coder/AGENTS.md b/SHiNE-agent-bot-coder/AGENTS.md deleted file mode 100644 index b64d0f0e..00000000 --- a/SHiNE-agent-bot-coder/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# AGENTS - -## Назначение -- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`. -- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE. -- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`. - -## Связанные инструкции -- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`. -- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений. -- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`. - -## Планы и задачи -- Отложенные задачи проекта лежат в `../TODO/`. -- Точка входа по планам: `../TODO/README.md`. -- Горизонты планов: - - `near/` - ближайшие планы; - - `medium/` - среднесрочные планы; - - `far/` - дальнее будущее. -- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам. -- Не начинать реализацию задач из `TODO` без явной команды пользователя. - -## Проверка после изменений -- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно. -- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны. diff --git a/SHiNE-agent-bot-coder/CLAUDE.md b/SHiNE-agent-bot-coder/CLAUDE.md deleted file mode 100644 index 4ed90ae6..00000000 --- a/SHiNE-agent-bot-coder/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ -@AGENTS.md -@AGENT.md diff --git a/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md b/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md deleted file mode 100644 index edeae1b9..00000000 --- a/SHiNE-agent-bot-coder/Players/PROMPTS_REVIEW.md +++ /dev/null @@ -1,26 +0,0 @@ -# Промпты для режима игроков (на согласование) - -## 1) Базовый служебный промпт (добавка к задаче игрока) - -```text -Режим игрока (обязательно): -- Пользователь: <Имя> (@). -- Рабочая папка игрока: /Players/ -- Код проекта не изменять. -- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ. -- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока. -``` - -## 2) Приветственное сообщение игроку (один раз) - -```text -Привет, <Имя>. -Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ. -Команда /new начинает новую сессию и архивирует текущую историю. -``` - -## 3) Отказ неизвестному пользователю - -```text -Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару. -``` diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md deleted file mode 100644 index e1b815db..00000000 --- a/SHiNE-agent-bot-coder/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# SHiNE-agent-bot-coder - -Локальный Telegram-бот-сервис для пользователя `ai`: -- принимает сообщения от `@AidarKC`; -- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями; -- ведёт историю диалога в `JSONL`; -- ставит задачи в файловую очередь; -- обрабатывает задачи строго последовательно; -- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); -- вызывает Codex CLI и отправляет ответ в Telegram; -- в личном чате умеет работать в двух персонально переключаемых режимах: через одно редактируемое статусное сообщение или через отдельные сообщения по этапам; -- умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS; -- при рестарте восстанавливает незавершённые задачи; -- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи; -- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`; -- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`. - -Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется. - -## Структура -- `.env` — локальные секреты и параметры запуска (не коммитится); -- `data/py_queue.jsonl` — очередь Python-сервиса; -- `data/py_state.json` — текущее состояние Python-сервиса; -- `data/py_processed_updates.log` — дедуп входящих update; -- `data/history//*.jsonl` — активные истории по пользователям; -- `data/history//archive/*.jsonl` — архивы после `/new`. - -## Локальный запуск -1. Скопировать пример: - - `cp .env.example .env` -2. Заполнить секреты в `.env`. - - `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота. - - `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды. - - `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`. - - `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`. - - `TELEGRAM_API_BASE_URL` — базовый URL Bot API; по умолчанию `https://api.telegram.org`. Для очень больших voice/audio можно поднять локальный `telegram-bot-api` и направить бота туда. - - `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд. - - `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд. - - `OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES` — безопасный лимит размера одного куска для OpenAI transcription, по умолчанию `24 MiB`. - - `OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS` — максимальная длина одного куска при длинном аудио, по умолчанию `900` секунд. - - `OPENAI_TRANSCRIBE_OVERLAP_SECONDS` — перекрытие соседних кусков для более ровной склейки текста, по умолчанию `2` секунды. - - `OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS` — битрейт локального пережатия длинного аудио через `ffmpeg`, по умолчанию `24`. - - `OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS` — тайм-аут локальной обработки длинного аудио через `ffmpeg`/`ffprobe`, по умолчанию `1800`. - - `FFMPEG_BIN` и `FFPROBE_BIN` — пути к локальным бинарям `ffmpeg`/`ffprobe`, если они не лежат в `PATH`. - - `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`. - - `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`. - - `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`. - - `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд. - - `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов. -3. Запуск: - - `python3 SHiNE-agent-bot-coder/py_bot_service.py` - -## Быстрый self-test Codex (без Telegram) -```bash -python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает" -``` - -## Длинные voice/audio -- Если аудио короткое, бот отправляет его в OpenAI как раньше. -- Если аудио большое или длинное, бот локально пережимает его через `ffmpeg`, при необходимости режет на куски и распознаёт последовательно. -- Если Telegram заранее сообщает большой размер файла, бот больше не отказывается сразу: сначала явно пишет, что пробует скачать файл, затем отдельно сообщает, удалось ли скачивание, и только после успешной загрузки переходит к подготовке аудио и OpenAI. -- Для очень больших файлов упираемся не только в OpenAI, но и в лимит обычного облачного Telegram Bot API на скачивание файла ботом. Для таких случаев нужно использовать локальный `telegram-bot-api` сервер и указать его через `TELEGRAM_API_BASE_URL`. - -## Статусы в личке -- Для `private`-чата бот поддерживает персональную настройку режима ответа. -- По умолчанию он старается не засорять переписку промежуточными сообщениями: создаёт одно статусное сообщение и редактирует его по этапам. -- Если включить `/single_message_off`, бот возвращается к старому режиму и отправляет отдельные сообщения по этапам и финальный ответ отдельно. -- Если финальный текст в режиме одного сообщения не помещается целиком, бот оставляет первую часть в отредактированном статусном сообщении и отправляет максимум ещё одно дополнительное текстовое сообщение с хвостом ответа. -- Голосовой ответ, если он включён, всегда приходит отдельным новым сообщением. - -## Запуск как systemd-сервис -Файлы для установки: -- `scripts/systemd/shine-agent-bot-coder.service` -- `scripts/systemd/install-local-systemd.sh` - -Установка: -- `bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh` - -Проверка: -- `systemctl --user status shine-agent-bot-coder --no-pager` -- `journalctl --user -u shine-agent-bot-coder -f` - -Перезапуск после изменений: -- `systemctl --user restart shine-agent-bot-coder` - -## Telegram-команды -- `/status` — активная задача и размер очереди. -- `/settings` — текущие пользовательские настройки и команды для их изменения. -- `/queue` — список задач в очереди. -- `/stop` — остановить текущую задачу. -- `/cancel ` — удалить задачу по id/префиксу или очистить очередь. -- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог. -- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя. -- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. -- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой. -- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой. -- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение. -- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ. -- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара). -- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара). diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py deleted file mode 100644 index fa53d0de..00000000 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ /dev/null @@ -1,2976 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import fcntl -import json -import mimetypes -import os -import random -import re -import shutil -import string -import subprocess -import tempfile -import threading -import time -import traceback -import uuid -from pathlib import Path -from typing import Any, Callable -from urllib import error, request - -DEFAULT_ALLOWED_PLAYERS = ",".join([ - "malvviiina:Милана", - "zodiaktechnika32:Сергей", - "oidasyda:Иван", - "blackbyrd1:Ворон", - "dimasol1:Дима", -]) - -TASK_STATUS_LABELS = { - "new": "новая", - "approved": "одобрена", - "rejected": "отклонена", - "needs_work": "на доработку", - "done": "сделана", -} - - -def now_iso() -> str: - return dt.datetime.now(dt.timezone.utc).isoformat() - - -def normalize_username(value: str | None) -> str: - if not value: - return "" - value = value.strip() - if value.startswith("@"): - value = value[1:] - 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 compact_spaces(text: str) -> str: - return re.sub(r"\s+", " ", (text or "").strip()) - - -def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: - text = (text or "").strip() - if not text: - return ["(пустой ответ)"] - return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] - - -def split_final_private_text(text: str, first_chunk_size: int = 3900, second_chunk_size: int = 3900) -> list[str]: - text = (text or "").strip() - if not text: - return ["(пустой ответ)"] - if len(text) <= first_chunk_size: - return [text] - first = text[:first_chunk_size].rstrip() - rest = text[first_chunk_size:].lstrip() - if len(rest) <= second_chunk_size: - return [first, rest] - second = rest[:second_chunk_size].rstrip() - if len(second) > 40: - second = second[:-40].rstrip() - second = second.rstrip() + "\n...[ответ обрезан]" - return [first, second] - - -def split_text_for_tts(text: str, chunk_size: int) -> list[str]: - text = (text or "").strip() - if not text: - return [] - chunks: list[str] = [] - current = "" - paragraphs = re.split(r"\n\s*\n", text) - for paragraph in paragraphs: - paragraph = paragraph.strip() - if not paragraph: - continue - if len(paragraph) > chunk_size: - if current: - chunks.append(current) - current = "" - for i in range(0, len(paragraph), chunk_size): - part = paragraph[i:i + chunk_size].strip() - if part: - chunks.append(part) - continue - candidate = paragraph if not current else f"{current}\n\n{paragraph}" - if len(candidate) <= chunk_size: - current = candidate - else: - if current: - chunks.append(current) - current = paragraph - if current: - chunks.append(current) - return chunks - - -def read_env_file(path: Path) -> dict[str, str]: - result: dict[str, str] = {} - if not path.exists(): - return result - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - result[key] = value - 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 VoiceReplyError(RuntimeError): - pass - - -class JsonLineStore: - @staticmethod - def load(path: Path) -> list[dict[str, Any]]: - if not path.exists(): - return [] - items: list[dict[str, Any]] = [] - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - items.append(json.loads(line)) - return items - - @staticmethod - def save(path: Path, items: list[dict[str, Any]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as f: - for item in items: - f.write(json.dumps(item, ensure_ascii=False) + "\n") - tmp.replace(path) - - @staticmethod - def append(path: Path, item: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(item, ensure_ascii=False) + "\n") - - -class TelegramApi: - def __init__(self, token: str, base_url: str = "https://api.telegram.org"): - self.token = token - self.api_root = (base_url or "https://api.telegram.org").rstrip("/") - self.base = f"{self.api_root}/bot{token}/" - self.file_base = f"{self.api_root}/file/bot{token}/" - - def call(self, method: str, payload: dict[str, Any] | None = None, timeout: int = 60) -> dict[str, Any]: - data = None - headers = {} - if payload is not None: - data = json.dumps(payload).encode("utf-8") - headers["Content-Type"] = "application/json" - req = request.Request(self.base + method, data=data, headers=headers, method="POST") - try: - with request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode("utf-8") - except error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Telegram HTTP {e.code}: {body}") from e - except Exception as e: - raise RuntimeError(f"Telegram request failed: {e}") from e - - result = json.loads(raw) - if not result.get("ok"): - raise RuntimeError(f"Telegram API error: {result}") - return result - - def call_multipart( - self, - method: str, - fields: dict[str, Any], - files: dict[str, tuple[str, bytes, str]], - timeout: int = 120, - ) -> dict[str, Any]: - boundary = "----shine-tg-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) - body = bytearray() - for name, value in fields.items(): - if value is None: - continue - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n\r\n' - f"{value}\r\n" - ).encode("utf-8") - ) - for name, (filename, data, mime) in files.items(): - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' - f"Content-Type: {mime}\r\n\r\n" - ).encode("utf-8") - ) - body.extend(data) - body.extend(b"\r\n") - body.extend(f"--{boundary}--\r\n".encode("utf-8")) - - req = request.Request(self.base + method, data=bytes(body), method="POST") - req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") - try: - with request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode("utf-8") - except error.HTTPError as e: - body_text = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Telegram HTTP {e.code}: {body_text}") from e - except Exception as e: - raise RuntimeError(f"Telegram multipart request failed: {e}") from e - - result = json.loads(raw) - if not result.get("ok"): - raise RuntimeError(f"Telegram API error: {result}") - return result - - def get_updates(self, offset: int | None, timeout_sec: int) -> list[dict[str, Any]]: - payload: dict[str, Any] = {"timeout": timeout_sec, "allowed_updates": ["message", "channel_post"]} - if offset is not None: - payload["offset"] = offset - result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15) - return result.get("result", []) - - 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 - return self.call("sendMessage", payload=payload, timeout=30) - - def edit_message_text(self, chat_id: int | str, message_id: int, text: str) -> dict[str, Any]: - payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text} - return self.call("editMessageText", 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 send_voice_upload( - self, - chat_id: int | str, - voice_bytes: bytes, - filename: str, - caption: str = "", - reply_to_message_id: int | None = None, - ) -> dict[str, Any]: - fields: dict[str, Any] = {"chat_id": chat_id} - if caption: - fields["caption"] = caption - if reply_to_message_id is not None: - fields["reply_to_message_id"] = reply_to_message_id - return self.call_multipart( - "sendVoice", - fields=fields, - files={"voice": (filename, voice_bytes, "audio/ogg")}, - timeout=180, - ) - - def delete_webhook(self) -> None: - self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30) - - -class BotConfig: - def __init__(self, root_dir: Path): - env = dict(os.environ) - env.update(read_env_file(root_dir / ".env")) - - 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.telegram_api_base_url = env.get("TELEGRAM_API_BASE_URL", "https://api.telegram.org").strip() or "https://api.telegram.org" - 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.openai_transcribe_max_upload_bytes = max(1_000_000, int(env.get("OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES", str(24 * 1024 * 1024)))) - self.openai_transcribe_max_chunk_seconds = max(60, int(env.get("OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS", "900"))) - self.openai_transcribe_overlap_seconds = max(0, int(env.get("OPENAI_TRANSCRIBE_OVERLAP_SECONDS", "2"))) - self.openai_transcribe_reencode_bitrate_kbps = max(12, int(env.get("OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS", "24"))) - self.openai_transcribe_ffmpeg_timeout_seconds = max(30, int(env.get("OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS", "1800"))) - self.ffmpeg_bin = env.get("FFMPEG_BIN", "ffmpeg").strip() or "ffmpeg" - self.ffprobe_bin = env.get("FFPROBE_BIN", "ffprobe").strip() or "ffprobe" - self.openai_tts_model = env.get("OPENAI_TTS_MODEL", "gpt-4o-mini-tts") - self.openai_tts_voice = env.get("OPENAI_TTS_VOICE", "alloy") - self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") - self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) - self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) - self.openai_voice_rewrite_model = env.get("OPENAI_VOICE_REWRITE_MODEL", "gpt-4.1-nano") - self.openai_voice_rewrite_timeout_seconds = int(env.get("OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS", "90")) - self.openai_voice_rewrite_max_input_chars = max(1000, int(env.get("OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS", "12000"))) - self.openai_voice_rewrite_max_output_tokens = max(200, int(env.get("OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS", "900"))) - self.codex_bin = Path(env.get( - "CODEX_BIN", - "/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl" - )) - self.codex_workdir = Path(env.get("CODEX_WORKDIR", "/home/ai/work/SHiNE/SHiNE-server-sha256")) - self.codex_timeout_seconds = int(env.get("CODEX_TIMEOUT_SECONDS", "900")) - self.max_retries = max(1, int(env.get("MAX_RETRIES", "3"))) - self.data_dir = (root_dir / env.get("DATA_DIR", "./data")).resolve() - self.agent_instructions_file = (root_dir / "AGENT.md").resolve() - - @staticmethod - def _required(env: dict[str, str], key: str) -> str: - value = env.get(key, "").strip() - if not value: - raise RuntimeError(f"Не задан обязательный параметр: {key}") - return value - - -class ShinePyBotService: - def __init__(self, config: BotConfig): - self.cfg = config - self.telegram = TelegramApi(config.telegram_bot_token, config.telegram_api_base_url) - - self.queue_file = config.data_dir / "py_queue.jsonl" - self.state_file = config.data_dir / "py_state.json" - self.processed_updates_file = config.data_dir / "py_processed_updates.log" - self.lock_file = config.data_dir / "py_app.lock" - self.history_dir = config.data_dir / "history" - self.history_archive_dir = self.history_dir / "archive" - self.task_center_dir = config.data_dir / "task_center" - self.task_center_file = self.task_center_dir / "items.json" - self.max_processed_updates = 5000 - - self.queue_lock = threading.RLock() - self.task_center_lock = threading.RLock() - self.stop_event = threading.Event() - self.worker = threading.Thread(target=self._worker_loop, name="shine-py-bot-worker", daemon=True) - - self.queue: list[dict[str, Any]] = [] - self.state: dict[str, Any] = {} - self.processed_updates: list[str] = [] - self.active_job_id: str | None = None - self.active_job_started_at: float | None = None - self.active_process: subprocess.Popen[str] | None = None - self.active_process_lock = threading.Lock() - self.stop_current_job = False - self.lock_fd = None - 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 _display_name(self, username: str) -> str: - uname = normalize_username(username) - if uname == self.cfg.allowed_username: - return "Айдар" - return self._player_name(uname) - - def _known_usernames(self) -> dict[str, str]: - users = {self.cfg.allowed_username: "Айдар"} - users.update(self.cfg.allowed_players) - return users - - def _find_user_by_text(self, text: str) -> str: - source = normalize_username(text) - if source in self._known_usernames(): - return source - source_lower = (text or "").strip().lower() - aliases = { - "айдар": self.cfg.allowed_username, - "айдару": self.cfg.allowed_username, - "айдара": self.cfg.allowed_username, - "милана": "malvviiina", - "милане": "malvviiina", - "милану": "malvviiina", - "миланы": "malvviiina", - "сергей": "zodiaktechnika32", - "сергею": "zodiaktechnika32", - "сергея": "zodiaktechnika32", - "иван": "oidasyda", - "ивану": "oidasyda", - "ивана": "oidasyda", - "ворон": "blackbyrd1", - "ворону": "blackbyrd1", - "ворона": "blackbyrd1", - "дима": "dimasol1", - "диме": "dimasol1", - "диму": "dimasol1", - "димы": "dimasol1", - } - for alias, username in aliases.items(): - if re.search(rf"(^|\W){re.escape(alias)}($|\W)", source_lower, flags=re.IGNORECASE): - return username - for username, name in self._known_usernames().items(): - if username and username in source_lower: - return username - if name and name.lower() in source_lower: - return username - return "" - - def run(self) -> None: - self._ensure_dirs() - self._acquire_single_instance_lock() - self._load_state() - self._load_queue() - self._load_processed_updates() - self._recover_active_jobs_after_restart() - self.telegram.delete_webhook() - self._init_offset_if_missing() - self.worker.start() - self._append_history_event("service_started", {"allowedUsername": self.cfg.allowed_username}) - print(f"[py-bot] Запущен. allowed user: @{self.cfg.allowed_username}", flush=True) - - try: - while not self.stop_event.is_set(): - try: - offset = self.state.get("offset") - updates = self.telegram.get_updates(offset=offset, timeout_sec=25) - except Exception as e: - print(f"[py-bot] Ошибка getUpdates: {e}", flush=True) - time.sleep(2) - continue - - for update in updates: - update_id = update.get("update_id") - if isinstance(update_id, int): - self.state["offset"] = update_id + 1 - self._persist_state() - self._handle_update(update) - finally: - self.shutdown() - - def shutdown(self) -> None: - if self.stop_event.is_set(): - pass - self.stop_event.set() - self._stop_active_codex_process() - if self.worker.is_alive(): - self.worker.join(timeout=10) - if self.lock_fd is not None: - try: - fcntl.flock(self.lock_fd, fcntl.LOCK_UN) - finally: - self.lock_fd.close() - self.lock_fd = None - self._append_history_event("service_stopped", {}) - - def _ensure_dirs(self) -> None: - self.cfg.data_dir.mkdir(parents=True, exist_ok=True) - self.history_dir.mkdir(parents=True, exist_ok=True) - self.history_archive_dir.mkdir(parents=True, exist_ok=True) - self.task_center_dir.mkdir(parents=True, exist_ok=True) - - def _acquire_single_instance_lock(self) -> None: - self.lock_file.parent.mkdir(parents=True, exist_ok=True) - self.lock_fd = self.lock_file.open("a+") - try: - fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise RuntimeError(f"Уже запущен другой инстанс (lock: {self.lock_file})") - - def _load_state(self) -> None: - if self.state_file.exists(): - 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 - user_settings = self.state.get("user_settings") - if not isinstance(user_settings, dict): - user_settings = {} - self.state["user_settings"] = user_settings - if not self.state.get("current_history_file"): - 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() - self._persist_state() - - def _persist_state(self) -> None: - self.state["updated_at"] = now_iso() - tmp = self.state_file.with_suffix(".tmp") - tmp.write_text(json.dumps(self.state, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(self.state_file) - - def _load_queue(self) -> None: - self.queue = JsonLineStore.load(self.queue_file) - - def _persist_queue(self) -> None: - JsonLineStore.save(self.queue_file, self.queue) - - def _load_processed_updates(self) -> None: - if not self.processed_updates_file.exists(): - self.processed_updates = [] - return - lines = [x.strip() for x in self.processed_updates_file.read_text(encoding="utf-8").splitlines() if x.strip()] - if len(lines) > self.max_processed_updates: - lines = lines[-self.max_processed_updates:] - self.processed_updates_file.write_text("\n".join(lines) + "\n", encoding="utf-8") - self.processed_updates = lines - - def _mark_processed_update(self, update_key: str) -> bool: - if update_key in self.processed_updates: - return True - self.processed_updates.append(update_key) - if len(self.processed_updates) > self.max_processed_updates: - self.processed_updates = self.processed_updates[-self.max_processed_updates:] - self.processed_updates_file.write_text("\n".join(self.processed_updates) + "\n", encoding="utf-8") - else: - with self.processed_updates_file.open("a", encoding="utf-8") as f: - f.write(update_key + "\n") - return False - - def _recover_active_jobs_after_restart(self) -> None: - recovered_ids: list[str] = [] - for job in self.queue: - if job.get("status") == "active": - job["status"] = "pending" - job["retry_reason"] = "service_restart_recovery" - job["updated_at"] = now_iso() - recovered_ids.append(job.get("id", "")) - if recovered_ids: - self._persist_queue() - self._append_history_event("active_jobs_recovered", {"jobIds": recovered_ids}) - - def _init_offset_if_missing(self) -> None: - if self.state.get("offset") is not None: - return - try: - updates = self.telegram.get_updates(offset=None, timeout_sec=0) - if updates: - self.state["offset"] = int(updates[-1]["update_id"]) + 1 - else: - self.state["offset"] = 0 - self._persist_state() - except Exception as e: - print(f"[py-bot] Не удалось инициализировать offset: {e}", flush=True) - self.state["offset"] = 0 - self._persist_state() - - def _current_history_file(self) -> Path: - return self._current_history_file_for_user(self.cfg.allowed_username) - - 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 - self._user_settings(uname) - 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 _user_session_state(self, username: str) -> dict[str, Any]: - 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) - if not isinstance(session, dict): - session = {} - sessions[uname] = session - return session - - def _current_history_file_for_user(self, username: str) -> Path: - session = self._user_session_state(username) - return Path(session["current_history_file"]) - - def _codex_thread_id_for_user(self, username: str) -> str: - thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip() - return thread_id - - def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None: - session = self._user_session_state(username) - normalized = (thread_id or "").strip() - if normalized: - session["codex_thread_id"] = normalized - else: - session.pop("codex_thread_id", None) - self._persist_state() - - 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)) - 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: - 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 = archive_dir / current.name - current.replace(archived) - else: - 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 - previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {} - sessions[uname] = {"current_history_file": str(new_file)} - if reason != "command_new" and isinstance(previous, dict): - thread_id = (previous.get("codex_thread_id") or "").strip() - if thread_id: - sessions[uname]["codex_thread_id"] = thread_id - 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": uname, "archived": str(archived)}, username=uname) - return archived - - def _user_settings(self, username: str) -> dict[str, Any]: - uname = normalize_username(username) or self.cfg.allowed_username - settings = self.state.get("user_settings") - if not isinstance(settings, dict): - settings = {} - self.state["user_settings"] = settings - user_settings = settings.get(uname) - if not isinstance(user_settings, dict): - user_settings = {} - settings[uname] = user_settings - if not isinstance(user_settings.get("voice_replies_enabled"), bool): - user_settings["voice_replies_enabled"] = True - if not isinstance(user_settings.get("voice_rewrite_enabled"), bool): - user_settings["voice_rewrite_enabled"] = True - if not isinstance(user_settings.get("single_status_message_enabled"), bool): - user_settings["single_status_message_enabled"] = True - return user_settings - - def _voice_replies_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("voice_replies_enabled")) - - def _set_voice_replies_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["voice_replies_enabled"] = enabled - self._persist_state() - - def _voice_rewrite_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("voice_rewrite_enabled")) - - def _set_voice_rewrite_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["voice_rewrite_enabled"] = enabled - self._persist_state() - - def _single_status_message_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("single_status_message_enabled")) - - def _set_single_status_message_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["single_status_message_enabled"] = enabled - self._persist_state() - - def _remember_private_chat(self, username: str, chat_id: int) -> None: - uname = normalize_username(username) - if not uname: - return - private_chats = self.state.get("private_chat_ids") - if not isinstance(private_chats, dict): - private_chats = {} - self.state["private_chat_ids"] = private_chats - if private_chats.get(uname) == chat_id: - return - private_chats[uname] = chat_id - self._persist_state() - - def _private_chat_id_for_user(self, username: str) -> int | None: - private_chats = self.state.get("private_chat_ids") - if not isinstance(private_chats, dict): - return None - chat_id = private_chats.get(normalize_username(username)) - return self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else None - - def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: - row = {"ts": now_iso(), "type": event_type} - row.update(payload) - JsonLineStore.append(history_path, row) - - 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 _load_task_items(self) -> list[dict[str, Any]]: - with self.task_center_lock: - if not self.task_center_file.exists(): - return [] - try: - data = json.loads(self.task_center_file.read_text(encoding="utf-8")) - except Exception: - return [] - return data if isinstance(data, list) else [] - - def _save_task_items(self, items: list[dict[str, Any]]) -> None: - with self.task_center_lock: - self.task_center_file.parent.mkdir(parents=True, exist_ok=True) - tmp = self.task_center_file.with_suffix(".tmp") - tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(self.task_center_file) - - def _next_task_item_id(self, items: list[dict[str, Any]]) -> str: - max_num = 0 - for item in items: - raw = str(item.get("id") or "") - match = re.match(r"TC-(\d+)$", raw) - if match: - max_num = max(max_num, int(match.group(1))) - return f"TC-{max_num + 1:04d}" - - def _create_task_item( - self, - *, - kind: str, - title: str, - text: str, - source_username: str, - target_username: str, - source_message_id: int | None = None, - source_chat_id: int | None = None, - opinion: str = "", - ) -> dict[str, Any]: - items = self._load_task_items() - item = { - "id": self._next_task_item_id(items), - "kind": kind, - "status": "new", - "title": compact_spaces(title)[:160] or ("Предложение" if kind == "proposal" else "Задача"), - "text": (text or "").strip(), - "opinion": (opinion or "").strip(), - "source_username": normalize_username(source_username), - "source_name": self._display_name(source_username), - "target_username": normalize_username(target_username), - "target_name": self._display_name(target_username), - "source_chat_id": source_chat_id, - "source_message_id": source_message_id, - "created_at": now_iso(), - "updated_at": now_iso(), - } - items.append(item) - self._save_task_items(items) - self._append_history_event("task_center_item_created", { - "itemId": item["id"], - "kind": kind, - "sourceUsername": item["source_username"], - "targetUsername": item["target_username"], - "title": item["title"], - }, username=source_username) - return item - - def _task_items_for_user(self, username: str, *, include_done: bool = False) -> list[dict[str, Any]]: - uname = normalize_username(username) - items = self._load_task_items() - result = [ - item for item in items - if normalize_username(item.get("target_username")) == uname - and (include_done or item.get("status") != "done") - ] - return sorted(result, key=lambda x: str(x.get("created_at") or "")) - - def _task_center_counts_text(self, username: str) -> str: - counts: dict[str, int] = {} - for item in self._task_items_for_user(username): - status = str(item.get("status") or "new") - counts[status] = counts.get(status, 0) + 1 - active_total = sum(counts.values()) - if active_total <= 0: - return "" - parts = [] - for status in ("new", "approved", "needs_work", "rejected"): - count = counts.get(status, 0) - if count: - parts.append(f"{TASK_STATUS_LABELS.get(status, status)}: {count}") - return f"Напоминание по задачам: всего активных {active_total}; " + ", ".join(parts) + "." - - def _format_task_items(self, username: str, *, include_done: bool = False) -> str: - items = self._task_items_for_user(username, include_done=include_done) - if not items: - return f"Для {self._display_name(username)} активных задач и предложений нет." - lines = [f"Задачи и предложения для {self._display_name(username)}:"] - for item in items[:15]: - kind = "предложение" if item.get("kind") == "proposal" else "задача" - status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) - source = item.get("source_name") or item.get("source_username") or "неизвестно" - title = item.get("title") or "(без названия)" - lines.append(f"{item.get('id')} [{status}] {kind} от {source}: {title}") - if len(items) > 15: - lines.append(f"...и ещё {len(items) - 15}") - return "\n".join(lines) - - def _update_task_item_status(self, item_id: str, status: str) -> dict[str, Any] | None: - item_id = (item_id or "").strip().upper() - items = self._load_task_items() - updated = None - for item in items: - if str(item.get("id") or "").upper() == item_id: - item["status"] = status - item["updated_at"] = now_iso() - updated = item - break - if updated is not None: - self._save_task_items(items) - return updated - - def _find_first_task_item(self, *, source_username: str = "", target_username: str = "", kind: str = "") -> dict[str, Any] | None: - source = normalize_username(source_username) - target = normalize_username(target_username) - for item in self._load_task_items(): - if item.get("status") == "done": - continue - if source and normalize_username(item.get("source_username")) != source: - continue - if target and normalize_username(item.get("target_username")) != target: - continue - if kind and item.get("kind") != kind: - continue - return item - return None - - def _notify_user_about_task_item(self, username: str, item: dict[str, Any]) -> None: - chat_id = self._private_chat_id_for_user(username) - if chat_id is None: - return - kind = "предложение" if item.get("kind") == "proposal" else "задача" - source = item.get("source_name") or item.get("source_username") or "кто-то" - title = item.get("title") or "(без названия)" - status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) - if item.get("status") == "new": - text = f"У тебя новое {kind} от {source}: {title}\nID: {item.get('id')}" - else: - text = f"Обновление по {kind} {item.get('id')}: статус «{status}».\n{title}" - self._safe_send(chat_id, text) - - 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 начинает новую Codex-сессию и архивирует текущую историю." - ) - reminder = self._task_center_counts_text(uname) - if reminder: - text = f"{text}\n\n{reminder}\nКоманда /tasks покажет список." - 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): - return chat_id - current = chat_id - visited: set[int] = set() - while current not in visited: - visited.add(current) - next_chat_id = migrations.get(str(current)) - if not isinstance(next_chat_id, int): - break - current = next_chat_id - return current - - def _remember_chat_migration(self, old_chat_id: int, new_chat_id: int, source: str) -> None: - if old_chat_id == new_chat_id: - return - migrations = self.state.get("chat_id_migrations") - if not isinstance(migrations, dict): - migrations = {} - self.state["chat_id_migrations"] = migrations - if migrations.get(str(old_chat_id)) == new_chat_id: - return - migrations[str(old_chat_id)] = new_chat_id - self._persist_state() - with self.queue_lock: - changed = False - for job in self.queue: - if job.get("chat_id") == old_chat_id: - job["chat_id"] = new_chat_id - job["updated_at"] = now_iso() - changed = True - if changed: - self._persist_queue() - self._append_history_event("chat_migrated_to_supergroup", { - "oldChatId": old_chat_id, - "newChatId": new_chat_id, - "source": source, - }) - - @staticmethod - def _extract_migrate_to_chat_id(error_text: str) -> int | None: - match = re.search(r'"migrate_to_chat_id"\s*:\s*(-?\d+)', error_text) - if match: - return int(match.group(1)) - match = re.search(r"'migrate_to_chat_id'\s*:\s*(-?\d+)", error_text) - if match: - return int(match.group(1)) - return None - - def _handle_update(self, update: dict[str, Any]) -> None: - message = update.get("message") - update_type = "message" - if not isinstance(message, dict): - message = update.get("channel_post") - update_type = "channel_post" - if not isinstance(message, dict): - return - chat = message.get("chat") or {} - chat_id = chat.get("id") - message_id = message.get("message_id") - chat_type = str(chat.get("type") or "") - chat_username = normalize_username(chat.get("username")) - chat_title = str(chat.get("title") or "") - sender = message.get("from") or {} - username = normalize_username(sender.get("username")) - author_signature = str(message.get("author_signature") or "").strip() - author_username = username or normalize_username(author_signature) - if not isinstance(chat_id, int) or not isinstance(message_id, int): - return - - migrate_to_chat_id = message.get("migrate_to_chat_id") - if isinstance(migrate_to_chat_id, int): - self._remember_chat_migration(chat_id, migrate_to_chat_id, "telegram_message") - return - - update_key = f"{chat_id}:{message_id}" - if self._mark_processed_update(update_key): - return - - is_channel_post = update_type == "channel_post" or chat_type == "channel" - is_group_message = update_type == "message" and chat_type in ("group", "supergroup") - is_allowed_channel = ( - not is_channel_post - or not self.cfg.allowed_channel_username - or chat_username == self.cfg.allowed_channel_username - ) - 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() - 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) - if is_private: - self._remember_private_chat(actor_username, chat_id) - 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, - actor_username, - message["voice"].get("file_id"), - duration_seconds=message["voice"].get("duration"), - telegram_file_size=message["voice"].get("file_size"), - media_type="voice", - update_type=update_type, - chat_username=chat_username, - chat_title=chat_title, - author_signature=author_signature, - chat_type=chat_type, - ) - return - if message.get("audio"): - self._enqueue_voice_job( - chat_id, - message_id, - actor_username, - message["audio"].get("file_id"), - duration_seconds=message["audio"].get("duration"), - telegram_file_size=message["audio"].get("file_size"), - media_type="audio", - update_type=update_type, - chat_username=chat_username, - chat_title=chat_title, - author_signature=author_signature, - chat_type=chat_type, - ) - return - self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id) - return - - if text.startswith("/"): - self._handle_command(chat_id, message_id, actor_username, text) - return - - if self._handle_task_center_text(chat_id, message_id, actor_username, text): - return - - self._append_history(history_path, "incoming_text", { - "chatId": chat_id, - "messageId": message_id, - "updateType": update_type, - "chatType": chat_type, - "chatUsername": chat_username, - "chatTitle": chat_title, - "username": actor_username, - "authorSignature": author_signature, - "text": text, - }) - 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 - 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(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() - if chat_type == "private": - self._ensure_job_status_message( - job["id"], - chat_id, - message_id, - f"Задача #{job['num']} получена.\nСтатус: в очереди.", - ) - else: - self._safe_send(chat_id, f"Принял задачу #{job['num']}", reply_to=message_id) - - def _enqueue_voice_job( - self, - chat_id: int, - message_id: int, - username: str, - file_id: str | None, - *, - duration_seconds: int | None = None, - telegram_file_size: int | None = None, - media_type: str = "voice", - update_type: str = "message", - chat_username: str = "", - chat_title: str = "", - author_signature: str = "", - chat_type: str = "", - ) -> None: - if not file_id: - self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id) - return - history_path = self._current_history_file_for_user(username) - self._append_history(history_path, "incoming_voice", { - "chatId": chat_id, - "messageId": message_id, - "updateType": update_type, - "chatType": chat_type, - "chatUsername": chat_username, - "chatTitle": chat_title, - "username": username, - "authorSignature": author_signature, - "fileId": file_id, - "mediaType": media_type, - "durationSeconds": duration_seconds, - "fileSize": telegram_file_size, - }) - 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["telegram_duration_seconds"] = duration_seconds or 0 - job["telegram_file_size"] = telegram_file_size or 0 - 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() - if chat_type == "private": - self._ensure_job_status_message( - job["id"], - chat_id, - message_id, - f"Voice для задачи #{job['num']} получен.\nСтатус: в очереди.", - ) - else: - self._safe_send(chat_id, f"Принял voice в задачу #{job['num']}", reply_to=message_id) - - def _build_job_base(self, chat_id: int, message_id: int, username: str, history_file: str) -> dict[str, Any]: - with self.queue_lock: - num = int(self.state.get("next_job_number", 1)) - self.state["next_job_number"] = num + 1 - self._persist_state() - return { - "id": str(uuid.uuid4()), - "num": num, - "status": "pending", - "type": "text", - "chat_id": chat_id, - "message_id": message_id, - "username": username, - "update_type": "message", - "chat_type": "", - "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": "", - "last_error": "", - "created_at": now_iso(), - "updated_at": now_iso(), - "active_since": None, - "status_message_id": None, - "status_message_text": "", - } - - def _handle_task_center_text(self, chat_id: int, message_id: int, username: str, text: str) -> bool: - source_text = (text or "").strip() - lower = source_text.lower() - is_owner = self._is_owner(username) - - if re.search(r"\b(покажи|список|какие)\b.*\b(задач|задачи|предложени)", lower): - target = username - explicit_target = self._find_user_by_text(source_text) - if is_owner and explicit_target: - target = explicit_target - self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) - return True - - if is_owner: - assign_match = re.search( - r"(?:поставь|добавь|создай|запиши)\s+(?:задачу|задание)\s+(.+?)(?::|\s+-\s+|\s+—\s+)(.+)", - source_text, - flags=re.IGNORECASE | re.DOTALL, - ) - if assign_match: - target = self._find_user_by_text(assign_match.group(1)) - body = assign_match.group(2).strip() - if target and body: - item = self._create_task_item( - kind="task", - title=body, - text=body, - source_username=username, - target_username=target, - source_message_id=message_id, - source_chat_id=chat_id, - ) - self._notify_user_about_task_item(target, item) - self._safe_send( - chat_id, - f"Задача добавлена для {self._display_name(target)}: {item['id']} — {item['title']}", - reply_to=message_id, - ) - return True - - status_match = re.search(r"\b(одобрить|отклонить|доработать|закрыть|сделано|закрыта)\b(?:\s+(.+))?", lower, flags=re.IGNORECASE) - if status_match and ("задач" in lower or "предложени" in lower or re.search(r"tc-\d+", lower, flags=re.IGNORECASE)): - action = status_match.group(1) - tail = status_match.group(2) or "" - status = { - "одобрить": "approved", - "отклонить": "rejected", - "доработать": "needs_work", - "закрыть": "done", - "сделано": "done", - "закрыта": "done", - }.get(action, "new") - id_match = re.search(r"tc-\d+", source_text, flags=re.IGNORECASE) - item = None - if id_match: - item = self._update_task_item_status(id_match.group(0), status) - else: - source_user = self._find_user_by_text(tail) - item = self._find_first_task_item( - source_username=source_user, - target_username=username, - kind="proposal" if "предложени" in lower else "", - ) - if item: - item = self._update_task_item_status(str(item.get("id")), status) - if item: - label = TASK_STATUS_LABELS.get(status, status) - self._safe_send(chat_id, f"{item.get('id')} обновлена: {label}.", reply_to=message_id) - source_user = normalize_username(item.get("source_username")) - if source_user and source_user != username: - self._notify_user_about_task_item(source_user, item) - return True - - if not is_owner: - proposal_match = re.match(r"\s*(?:предложение|идея|заявка)\s*[::-]\s*(.+)", source_text, flags=re.IGNORECASE | re.DOTALL) - if proposal_match: - body = proposal_match.group(1).strip() - if body: - item = self._create_task_item( - kind="proposal", - title=body, - text=body, - opinion="Нужно решение Айдара: одобрить, отклонить или отправить на доработку.", - source_username=username, - target_username=self.cfg.allowed_username, - source_message_id=message_id, - source_chat_id=chat_id, - ) - self._notify_user_about_task_item(self.cfg.allowed_username, item) - self._safe_send( - chat_id, - f"Предложение отправлено Айдару как {item['id']}. Статус: новая.", - reply_to=message_id, - ) - return True - - return False - - def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None: - lower = text.lower() - command = lower.split(maxsplit=1)[0].split("@", 1)[0] - is_owner = self._is_owner(username) - if command in ("/start", "/help"): - self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) - return - if command == "/settings": - self._safe_send(chat_id, self._settings_text(username), reply_to=message_id) - return - if command == "/status": - self._safe_send(chat_id, self._status_text(username), reply_to=message_id) - return - if command == "/queue": - self._safe_send(chat_id, self._queue_text(), reply_to=message_id) - return - if command in ("/tasks", "/my_tasks"): - parts = text.split(maxsplit=1) - target = username - if is_owner and len(parts) > 1: - parsed = self._find_user_by_text(parts[1]) - if parsed: - target = parsed - self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) - return - if command == "/voice_on": - self._set_voice_replies_enabled(username, True) - self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Озвучивание финальных ответов включено для вашего пользователя.", reply_to=message_id) - return - if command == "/voice_off": - self._set_voice_replies_enabled(username, False) - self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id) - return - if command in ("/voice_status", "/voice_rewrite_status"): - self._safe_send(chat_id, self._status_text(username), reply_to=message_id) - return - if command == "/voice_rewrite_on": - self._set_voice_rewrite_enabled(username, True) - self._append_history_event("voice_rewrite_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Адаптация текста перед озвучкой включена для вашего пользователя.", reply_to=message_id) - return - if command == "/voice_rewrite_off": - self._set_voice_rewrite_enabled(username, False) - self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id) - return - if command == "/single_message_on": - self._set_single_status_message_enabled(username, True) - self._append_history_event("single_status_message_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке включён для вашего пользователя.", reply_to=message_id) - return - if command == "/single_message_off": - self._set_single_status_message_enabled(username, False) - self._append_history_event("single_status_message_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке выключен. Бот будет отправлять отдельные сообщения по этапам.", reply_to=message_id) - return - if command == "/new": - archived = self._rotate_history("command_new", username) - self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) - return - if command in ("/restart_service", "/restart"): - if not is_owner: - self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) - return - self._append_history_event("restart_service_deferred_requested", { - "chatId": chat_id, - "messageId": message_id, - "username": username, - }, username=username) - self._safe_send( - chat_id, - "Отложенный рестарт принят. Если задача сейчас выполняется, сервис перезапустится после её завершения и до следующей задачи.", - reply_to=message_id, - ) - self._request_deferred_restart() - return - if command in ("/restart_hard", "/restart_now", "/restart_force"): - if not is_owner: - self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) - return - self._append_history_event("restart_service_hard_requested", { - "chatId": chat_id, - "messageId": message_id, - "username": username, - }, username=username) - self._safe_send( - chat_id, - "Выполняю жёсткий рестарт сервиса прямо сейчас. Активная задача, если есть, будет прервана и после старта вернётся в очередь.", - reply_to=message_id, - ) - self._schedule_self_restart("hard_restart_requested", force=True) - return - if command == "/stop": - stopped = self._cancel_active_job("stopped_by_user") - if stopped: - self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id) - else: - self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id) - return - if command == "/cancel": - parts = text.split(maxsplit=1) - if len(parts) < 2: - self._safe_send(chat_id, "Использование: /cancel ", reply_to=message_id) - return - arg = parts[1].strip() - if arg.lower() == "all": - with self.queue_lock: - self.stop_current_job = True - self._stop_active_codex_process() - count = len(self.queue) - self.queue = [] - self._persist_queue() - self._safe_send(chat_id, f"Удалено задач из очереди: {count}", reply_to=message_id) - return - cancelled = self._cancel_by_id_prefix(arg) - self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id) - return - - def _help_text(self, *, is_owner: bool) -> str: - lines = [ - "Доступные команды:", - "/status — активная задача и размер очереди", - "/settings — текущие настройки и команды для их изменения", - "/queue — список задач в очереди", - "/tasks — список ваших задач и предложений", - "/stop — остановить текущую задачу", - "/cancel — удалить задачу по id (префикс) или все", - "/new — архивировать историю и начать новую Codex-сессию", - "/help — эта справка", - ] - if is_owner: - lines.insert(-1, "/tasks <пользователь> — список задач игрока") - lines.insert(-1, "/restart — отложенный рестарт после текущей задачи") - lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас") - return "\n".join(lines) - - def _settings_text(self, username: str) -> str: - voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" - rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" - lines = [ - "Текущие настройки:", - f"Озвучивание финальных ответов: {voice_status}", - f"Адаптация текста перед озвучкой: {rewrite_status}", - f"Режим одного редактируемого сообщения в личке: {single_message_status}", - "", - "Команды настроек:", - "/voice_on — включить озвучивание", - "/voice_off — выключить озвучивание", - "/voice_rewrite_on — адаптировать текст перед озвучкой", - "/voice_rewrite_off — озвучивать обычный текст без адаптации", - "/single_message_on — один редактируемый ответ в личке", - "/single_message_off — отдельные сообщения по этапам и финалу", - ] - return "\n".join(lines) - - def _status_text(self, username: str) -> str: - with self.queue_lock: - active = next((j for j in self.queue if j.get("status") == "active"), None) - pending = sum(1 for j in self.queue if j.get("status") == "pending") - voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" - rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" - settings_text = ( - f"Голосовые ответы: {voice_status}\n" - f"Адаптация текста перед озвучкой: {rewrite_status}\n" - f"Режим одного сообщения в личке: {single_message_status}" - ) - restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else "" - if not active: - return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}{restart_text}" - elapsed = int(time.time() - (self.active_job_started_at or time.time())) - return ( - f"Статус: активная задача #{active.get('num', '?')}\n" - f"Тип: {active.get('type', 'text')}\n" - f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" - f"Выполняется: {elapsed}с\n" - f"Pending: {pending}\n" - f"{settings_text}{restart_text}" - ) - - def _queue_text(self) -> str: - with self.queue_lock: - items = list(self.queue) - if not items: - return "Очередь пуста." - lines = [f"Очередь: {len(items)}"] - for i, job in enumerate(items[:10], start=1): - lines.append( - f"{i}) #{job.get('num', '?')} [{job.get('status')}] {job.get('type')} attempts={job.get('attempts', 0)}" - ) - if len(items) > 10: - lines.append(f"...и ещё {len(items) - 10} задач") - return "\n".join(lines) - - def _cancel_active_job(self, reason: str) -> bool: - with self.queue_lock: - active = next((j for j in self.queue if j.get("status") == "active"), None) - if not active: - return False - self.stop_current_job = True - self._stop_active_codex_process() - self.queue = [j for j in self.queue if j.get("id") != active.get("id")] - self._persist_queue() - self._append_history_event("job_stopped_by_user", {"jobId": active.get("id"), "reason": reason}) - return True - - def _cancel_by_id_prefix(self, prefix: str) -> bool: - prefix = prefix.strip().lower() - normalized_num = prefix.lstrip("#") - with self.queue_lock: - target = next( - ( - j for j in self.queue - if str(j.get("id", "")).lower().startswith(prefix) - or str(j.get("num", "")).lower() == normalized_num - ), - None - ) - if not target: - return False - if target.get("status") == "active": - self.stop_current_job = True - self._stop_active_codex_process() - self.queue = [j for j in self.queue if j.get("id") != target.get("id")] - self._persist_queue() - return True - - def _worker_loop(self) -> None: - while not self.stop_event.is_set(): - if self.restart_requested: - self._exit_for_restart("deferred_restart_before_next_job") - return - job = None - with self.queue_lock: - for item in self.queue: - if item.get("status") == "pending": - item["status"] = "active" - item["active_since"] = now_iso() - item["updated_at"] = now_iso() - self.active_job_id = item.get("id") - self.active_job_started_at = time.time() - job = dict(item) - self._persist_queue() - break - if not job: - time.sleep(0.5) - continue - - self.stop_current_job = False - self._process_job(job) - self.active_job_id = None - self.active_job_started_at = None - if self.restart_requested: - self._exit_for_restart("deferred_restart_after_job") - return - - def _process_job(self, job: dict[str, Any]) -> None: - job_id = job["id"] - job_num = job.get("num", "?") - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - history_path = Path(job["history_file"]) - private_single_message = ( - (job.get("chat_type") or "") == "private" - and self._single_status_message_enabled(job.get("username") or "") - ) - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняется.") - try: - if job.get("type") == "voice": - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: распознаю voice.") - recognized = self._transcribe_voice_job( - job, - status_cb=lambda note: self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: {note}", - ), - ) - job["text"] = recognized - self._append_history(history_path, "voice_transcription", {"jobId": job_id, "jobNum": job_num, "text": recognized}) - preview = recognized.strip() - if len(preview) > 800: - preview = preview[:800].rstrip() + " ...[обрезано]" - self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: voice распознан, отправляю в Codex.\n\nТекст:\n{preview}", - ) - - prompt = self._build_prompt(job) - self._append_history(history_path, "codex_request", {"jobId": job_id, "prompt": prompt}) - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняю через Codex.") - answer = self._run_codex(prompt, job) - if private_single_message: - parts = split_final_private_text(answer) - self._set_job_status_text(job, parts[0]) - if len(parts) > 1: - self._safe_send(chat_id, parts[1], reply_to=message_id) - else: - for chunk in split_long_text(answer): - self._safe_send(chat_id, chunk, reply_to=message_id) - self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) - self._send_private_job_public_report(job, answer) - self._send_task_center_reminder(job) - if self._voice_replies_enabled(job.get("username") or ""): - self._send_voice_reply_for_answer(job, answer, history_path, job_id) - self._mark_job_done(job_id) - except Exception as e: - if self.stop_current_job: - self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)}) - self._set_job_status_text(job, f"Задача #{job_num} остановлена.") - 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: - retry_block = "" - 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" - f"Источник Telegram: {job.get('update_type', 'message')}\n" - f"Тип чата: {job.get('chat_type') or ''}\n" - f"Канал/чат: @{job.get('chat_username') or ''} {job.get('chat_title') or ''}\n" - f"Username отправителя: @{job.get('username')}\n" - f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n" - "Текст для обработки:\n" - f"{job.get('text')}\n\n" - f"История диалога (JSONL): {job.get('history_file')}\n" - f"Инструкции агента: {self.cfg.agent_instructions_file}\n" - f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{player_block}{retry_block}" - ) - - def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: - username = job.get("username") or self.cfg.allowed_username - thread_id = self._codex_thread_id_for_user(username) - try: - return self._run_codex_once(prompt, job, thread_id=thread_id) - except RuntimeError as e: - if not thread_id or not self._is_missing_codex_session_error(str(e)): - raise - self._set_codex_thread_id_for_user(username, "") - self._append_history( - Path(job["history_file"]), - "system_event", - { - "event": "codex_thread_reset", - "reason": "missing_session", - "username": normalize_username(username), - "oldThreadId": thread_id, - }, - ) - return self._run_codex_once(prompt, job, thread_id="") - - def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str: - output_lines: list[str] = [] - job_id = str(job["id"]) - job_num = job.get("num", "?") - username = job.get("username") or self.cfg.allowed_username - with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: - output_file = Path(tmp.name) - - cmd = [ - str(self.cfg.codex_bin), - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--json", - "-C", str(self.cfg.codex_workdir), - "-o", str(output_file), - ] - if thread_id: - cmd.extend(["resume", thread_id]) - cmd.append(prompt) - mode = f"resume {thread_id}" if thread_id else "new" - print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True) - process = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, - ) - with self.active_process_lock: - self.active_process = process - - self.last_heartbeat_at = 0.0 - last_user_note = "" - last_user_note_at = 0.0 - codex_started_at = time.time() - last_job_message_at = codex_started_at - seen_thread_id = "" - - def on_line(line: str) -> None: - nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id - output_lines.append(line) - current_thread_id = self._extract_codex_thread_id(line) - if current_thread_id: - seen_thread_id = current_thread_id - note = self._extract_codex_user_note(line) - now = time.time() - if note and note != last_user_note and now - last_user_note_at > 8: - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: {note}") - last_user_note = note - last_user_note_at = now - last_job_message_at = now - - reader_done = threading.Event() - - def reader() -> None: - if not process.stdout: - reader_done.set() - return - for line in process.stdout: - on_line(line.rstrip("\n")) - reader_done.set() - - t = threading.Thread(target=reader, name=f"codex-reader-{job_id[:8]}", daemon=True) - t.start() - - try: - deadline = time.time() + self.cfg.codex_timeout_seconds - return_code = None - while return_code is None: - return_code = process.poll() - now = time.time() - if return_code is not None: - break - if now >= deadline: - process.kill() - t.join(timeout=2) - raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") - if now - codex_started_at >= 120 and now - last_job_message_at >= 120: - elapsed = self._format_duration(int(now - codex_started_at)) - self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: выполняется уже {elapsed}, от Codex давно нет новых сообщений.", - ) - last_job_message_at = now - self.last_heartbeat_at = now - time.sleep(1) - finally: - with self.active_process_lock: - self.active_process = None - - reader_done.wait(timeout=2) - - if return_code != 0: - tail = "\n".join(output_lines[-40:]) - raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}") - - if seen_thread_id and seen_thread_id != thread_id: - self._set_codex_thread_id_for_user(username, seen_thread_id) - - if output_file.exists(): - answer = output_file.read_text(encoding="utf-8").strip() - try: - output_file.unlink(missing_ok=True) - except Exception: - pass - if answer: - return answer - - fallback = self._extract_fallback_message(output_lines) - if not fallback: - raise RuntimeError("Codex returned empty response") - return fallback - - def _stop_active_codex_process(self) -> bool: - with self.active_process_lock: - process = self.active_process - if process is None: - return False - if process.poll() is not None: - return False - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - return True - - def _handle_job_failure(self, job: dict[str, Any], err: Exception) -> None: - job_id = job["id"] - job_num = job.get("num", "?") - error_text = str(err).strip() or err.__class__.__name__ - 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) - if not target: - return - attempts = int(target.get("attempts", 0)) + 1 - target["attempts"] = attempts - target["last_error"] = error_text[:1000] - target["updated_at"] = now_iso() - if retryable and attempts < self.cfg.max_retries: - target["status"] = "pending" - target["retry_reason"] = error_text[:200] - self._persist_queue() - will_retry = True - else: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - will_retry = False - - if will_retry: - self._set_job_status_text( - job, - f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.", - ) - else: - self._set_job_status_text(job, f"{user_error_text}\nЗадача #{job_num} остановлена.") - - 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: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - - def _mark_job_removed(self, job_id: str) -> None: - with self.queue_lock: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - - def _send_task_center_reminder(self, job: dict[str, Any]) -> None: - if job.get("chat_type") != "private": - return - username = job.get("username") or "" - reminder = self._task_center_counts_text(username) - if not reminder: - return - self._safe_send(int(job["chat_id"]), reminder, reply_to=int(job["message_id"])) - - def _remember_public_report_chat(self, chat_id: int) -> None: - if self.state.get("public_report_chat_id") == chat_id: - return - 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: - 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: - if isinstance(resolved_chat_id, int): - self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error") - try: - sent = send(migrate_to_chat_id, None) - result = sent.get("result") or {} - message_id = result.get("message_id") - return message_id if isinstance(message_id, int) else None - except Exception as retry_error: - print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True) - return None - print(f"[py-bot] sendFile error: {e}", flush=True) - return None - - def _safe_send_voice_upload( - self, - chat_id: int | str, - voice_bytes: bytes, - filename: str, - *, - caption: str = "", - reply_to: int | None = None, - ) -> int | None: - if not voice_bytes: - 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]: - return self.telegram.send_voice_upload( - target_chat_id, - voice_bytes, - filename, - caption=caption, - reply_to_message_id=target_reply_to, - ) - - try: - 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: - if isinstance(resolved_chat_id, int): - self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_voice_upload_error") - try: - sent = send(migrate_to_chat_id, None) - result = sent.get("result") or {} - message_id = result.get("message_id") - return message_id if isinstance(message_id, int) else None - except Exception as retry_error: - print(f"[py-bot] sendVoiceUpload retry after migration error: {retry_error}", flush=True) - return None - print(f"[py-bot] sendVoiceUpload 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 None - print(f"[py-bot] sendMessage error: {e}", flush=True) - return None - - def _safe_edit(self, chat_id: int | str, message_id: int | None, text: str) -> bool: - text = (text or "").strip() - if not text or not message_id: - return False - 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 - try: - self.telegram.edit_message_text(resolved_chat_id, message_id, text) - return True - except Exception as e: - error_text = str(e) - if "message is not modified" in error_text: - return True - migrate_to_chat_id = self._extract_migrate_to_chat_id(error_text) - 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, "edit_message_error") - try: - self.telegram.edit_message_text(migrate_to_chat_id, message_id, text) - return True - except Exception as retry_error: - print(f"[py-bot] editMessageText retry after migration error: {retry_error}", flush=True) - return False - print(f"[py-bot] editMessageText error: {e}", flush=True) - return False - - def _ensure_job_status_message(self, job_id: str, chat_id: int, reply_to_message_id: int, text: str) -> int | None: - text = (text or "").strip() - if not text: - return None - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - existing_id = target.get("status_message_id") - existing_text = (target.get("status_message_text") or "").strip() - else: - existing_id = None - existing_text = "" - if existing_id and existing_text == text and self._safe_edit(chat_id, int(existing_id), text): - return int(existing_id) - if existing_id and self._safe_edit(chat_id, int(existing_id), text): - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - target["status_message_text"] = text - target["updated_at"] = now_iso() - self._persist_queue() - return int(existing_id) - message_id = self._safe_send(chat_id, text, reply_to=reply_to_message_id) - if message_id is not None: - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - target["status_message_id"] = message_id - target["status_message_text"] = text - target["updated_at"] = now_iso() - self._persist_queue() - return message_id - - def _set_job_status_text(self, job: dict[str, Any], text: str) -> None: - if (job.get("chat_type") or "") != "private": - self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) - return - if not self._single_status_message_enabled(job.get("username") or ""): - self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) - return - message_id = self._ensure_job_status_message( - job["id"], - int(job["chat_id"]), - int(job["message_id"]), - text, - ) - if message_id is not None: - job["status_message_id"] = message_id - job["status_message_text"] = text - - def _request_deferred_restart(self) -> None: - if self.restart_requested: - return - self.restart_requested = True - self._append_history_event("restart_service_deferred_scheduled", {}) - with self.queue_lock: - has_active = any(j.get("status") == "active" for j in self.queue) - if not has_active: - threading.Thread( - target=lambda: self._exit_for_restart("deferred_restart_no_active_job"), - name="shine-py-bot-deferred-restart", - daemon=True, - ).start() - - def _exit_for_restart(self, reason: str) -> None: - print(f"[py-bot] restart now: {reason}", flush=True) - self._append_history_event("restart_service_executing", {"reason": reason}) - time.sleep(0.5) - os._exit(0) - - def _schedule_self_restart(self, reason: str = "restart_requested", *, force: bool = False) -> None: - if self.restart_requested and not force: - return - self.restart_requested = True - - def restart() -> None: - time.sleep(1.5) - print(f"[py-bot] restart requested by Telegram command: {reason}", flush=True) - if force: - self._stop_active_codex_process() - os._exit(0) - - threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() - - def _voice_reply_targets(self, job: dict[str, Any]) -> list[tuple[int | str, int | None, str]]: - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - targets: list[tuple[int | str, int | None, str]] = [(chat_id, message_id, "source")] - username = job.get("username") or "" - - private_chat_id = self._private_chat_id_for_user(username) - if private_chat_id is not None and private_chat_id != self._resolve_chat_id(chat_id): - targets.append((private_chat_id, None, "private")) - - report_chat_id = self._public_report_chat_id() - if report_chat_id is not None: - resolved_report_chat_id = self._resolve_chat_id(report_chat_id) if isinstance(report_chat_id, int) else report_chat_id - resolved_current_chat_id: int | str = self._resolve_chat_id(chat_id) - if resolved_report_chat_id != resolved_current_chat_id: - targets.append((report_chat_id, None, "public")) - - deduped: list[tuple[int | str, int | None, str]] = [] - seen: set[str] = set() - for target_chat_id, target_reply_to, label in targets: - resolved: int | str = self._resolve_chat_id(target_chat_id) if isinstance(target_chat_id, int) else target_chat_id - key = str(resolved) - if key in seen: - continue - seen.add(key) - deduped.append((target_chat_id, target_reply_to, label)) - return deduped - - def _send_voice_reply_for_answer( - self, - job: dict[str, Any], - answer: str, - history_path: Path, - job_id: str, - ) -> None: - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - job_num = job.get("num", "?") - username = job.get("username") or "" - if not self.cfg.openai_api_key: - note = "не настроен ключ OpenAI для озвучивания." - self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) - self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) - return - - voice_text = answer - rewrite_enabled = self._voice_rewrite_enabled(username) - if rewrite_enabled: - try: - voice_text = self._openai_rewrite_text_for_voice(answer) - self._append_history(history_path, "voice_rewrite_done", { - "jobId": job_id, - "jobNum": job_num, - "model": self.cfg.openai_voice_rewrite_model, - "sourceChars": len(answer or ""), - "resultChars": len(voice_text or ""), - }) - except VoiceReplyError as e: - self._append_history(history_path, "voice_rewrite_failed", { - "jobId": job_id, - "jobNum": job_num, - "model": self.cfg.openai_voice_rewrite_model, - "error": str(e), - }) - self._safe_send( - chat_id, - f"Не удалось адаптировать ответ #{job_num} для озвучки, озвучиваю обычный текст: {e}", - reply_to=message_id, - ) - voice_text = answer - - chunks = split_text_for_tts(voice_text, self.cfg.openai_tts_chunk_chars) - if not chunks: - return - - sent_count = 0 - total = len(chunks) - targets = self._voice_reply_targets(job) - print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total} targets={len(targets)} rewrite={rewrite_enabled}", flush=True) - for index, chunk in enumerate(chunks, start=1): - try: - audio = self._openai_tts(chunk) - except VoiceReplyError as e: - self._append_history(history_path, "voice_reply_failed", { - "jobId": job_id, - "jobNum": job_num, - "part": index, - "parts": total, - "error": str(e), - }) - self._safe_send(chat_id, f"Не удалось озвучить ответ #{job_num}: {e}", reply_to=message_id) - return - caption = f"Озвучка ответа #{job_num}" - if total > 1: - caption += f", часть {index}/{total}" - for target_chat_id, target_reply_to, target_label in targets: - message_sent = self._safe_send_voice_upload( - target_chat_id, - audio, - f"shine-answer-{job_num}-{index}.ogg", - caption=caption, - reply_to=target_reply_to, - ) - if message_sent is None: - self._append_history(history_path, "voice_reply_failed", { - "jobId": job_id, - "jobNum": job_num, - "part": index, - "parts": total, - "target": target_label, - "error": "Telegram не принял voice-файл озвучки.", - }) - if target_label == "source": - self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) - continue - sent_count += 1 - self._append_history(history_path, "voice_reply_sent", { - "jobId": job_id, - "jobNum": job_num, - "parts": total, - "messages": sent_count, - "targets": len(targets), - "rewriteEnabled": rewrite_enabled, - }) - print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) - - def _openai_rewrite_text_for_voice(self, text: str) -> str: - source = (text or "").strip() - if not source: - return "" - if len(source) > self.cfg.openai_voice_rewrite_max_input_chars: - source = source[:self.cfg.openai_voice_rewrite_max_input_chars].rstrip() + "\n\n...[текстовый ответ был длиннее и обрезан для голосовой версии]" - payload = { - "model": self.cfg.openai_voice_rewrite_model, - "messages": [ - { - "role": "system", - "content": ( - "Ты готовишь русскую версию финального ответа технического агента для озвучивания. " - "Не пересказывай заново и не меняй смысл: сохрани порядок мыслей, итог, предупреждения, статусы и важные действия. " - "Мягко убери только то, что плохо воспринимается на слух: длинные пути, хэши, ID, команды, JSON, " - "длинные списки файлов, точные размеры и счётчики символов. Если деталь важна, замени её коротким описанием. " - "Не добавляй новых фактов. Пиши естественно, без markdown, близко к исходному тексту." - ), - }, - { - "role": "user", - "content": f"Переделай этот финальный текст в вариант для озвучки:\n\n{source}", - }, - ], - "temperature": 0.2, - "max_tokens": self.cfg.openai_voice_rewrite_max_output_tokens, - } - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request("https://api.openai.com/v1/chat/completions", method="POST", data=data) - req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") - req.add_header("Content-Type", "application/json") - try: - with request.urlopen(req, timeout=self.cfg.openai_voice_rewrite_timeout_seconds) as resp: - raw = resp.read().decode("utf-8", errors="replace") - except TimeoutError as e: - raise VoiceReplyError( - f"OpenAI не успел адаптировать текст за {self.cfg.openai_voice_rewrite_timeout_seconds} секунд." - ) from e - except error.HTTPError as e: - detail = e.read().decode("utf-8", errors="replace") - if e.code == 401: - message = "OpenAI отклонил ключ API для адаптации текста." - elif e.code == 429: - message = "OpenAI временно ограничил адаптацию текста из-за лимита запросов." - elif e.code >= 500: - message = "OpenAI временно не смог адаптировать текст." - else: - message = f"OpenAI вернул ошибку HTTP {e.code} при адаптации текста." - if detail: - message = f"{message} Детали: {detail[:500]}" - raise VoiceReplyError(message) from e - except error.URLError as e: - raise VoiceReplyError(f"не удалось отправить текст в OpenAI для адаптации из-за сетевой ошибки: {e.reason}") from e - - try: - body = json.loads(raw) - content = (((body.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() - except Exception as e: - raise VoiceReplyError("OpenAI вернул неразборчивый ответ при адаптации текста.") from e - if not content: - raise VoiceReplyError("OpenAI вернул пустой текст адаптации.") - return content - - def _openai_tts(self, text: str) -> bytes: - payload = { - "model": self.cfg.openai_tts_model, - "voice": self.cfg.openai_tts_voice, - "input": text, - "response_format": self.cfg.openai_tts_response_format, - } - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request("https://api.openai.com/v1/audio/speech", method="POST", data=data) - req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") - req.add_header("Content-Type", "application/json") - try: - with request.urlopen(req, timeout=self.cfg.openai_tts_timeout_seconds) as resp: - audio = resp.read() - except TimeoutError as e: - raise VoiceReplyError(f"OpenAI не успел сгенерировать речь за {self.cfg.openai_tts_timeout_seconds} секунд.") from e - except error.HTTPError as e: - detail = e.read().decode("utf-8", errors="replace") - if e.code == 401: - message = "OpenAI отклонил ключ API для озвучивания." - elif e.code == 429: - message = "OpenAI временно ограничил озвучивание из-за лимита запросов." - elif e.code >= 500: - message = "OpenAI временно не смог сгенерировать речь." - else: - message = f"OpenAI вернул ошибку HTTP {e.code} при озвучивании." - if detail: - message = f"{message} Детали: {detail[:500]}" - raise VoiceReplyError(message) from e - except error.URLError as e: - raise VoiceReplyError(f"не удалось отправить текст в OpenAI TTS из-за сетевой ошибки: {e.reason}") from e - if not audio: - raise VoiceReplyError("OpenAI вернул пустой аудиофайл.") - return audio - - def _transcribe_voice_job( - self, - job: dict[str, Any], - *, - status_cb: Callable[[str], Any] | None = None, - ) -> str: - if not self.cfg.openai_api_key: - raise VoiceTranscriptionError( - "не настроен ключ OpenAI для распознавания.", - stage="config", - retryable=False, - ) - file_id = (job.get("telegram_file_id") or "").strip() - if not 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() - duration_seconds = int(job.get("telegram_duration_seconds") or 0) - telegram_file_size = int(job.get("telegram_file_size") or 0) - file_looks_big_for_cloud = self._telegram_cloud_download_is_likely_too_big(telegram_file_size) - if file_looks_big_for_cloud and status_cb is not None: - status_cb( - "файл большой, всё равно пробую скачать его из Telegram. " - f"Предварительный размер около {self._bytes_to_mb(telegram_file_size)} MB." - ) - 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, - ) - if file_looks_big_for_cloud and status_cb is not None: - status_cb( - "скачивание из Telegram прошло успешно. " - f"Фактический размер около {self._bytes_to_mb(len(file_bytes))} MB, дальше готовлю аудио и отправляю в OpenAI." - ) - prepared_parts = self._prepare_audio_parts_for_transcription( - file_bytes, - filename, - duration_seconds=duration_seconds, - job_id=job_id, - job_num=job_num, - ) - print( - f"[py-bot] transcribe prepared job={job_id} parts={len(prepared_parts)} duration={duration_seconds}s", - flush=True, - ) - parts_text: list[str] = [] - prompt_tail = "" - for index, (part_bytes, part_name) in enumerate(prepared_parts, start=1): - print( - f"[py-bot] transcribe part job={job_id} index={index}/{len(prepared_parts)} filename={part_name} size={len(part_bytes)} bytes", - flush=True, - ) - part_text = self._openai_transcribe(part_bytes, part_name, prompt=prompt_tail).strip() - if part_text: - parts_text.append(part_text) - prompt_tail = self._transcription_prompt_tail("\n".join(parts_text)) - text = "\n".join(parts_text).strip() - if not text: - 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]: - 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: - detail = str(e) - if "file is too big" in detail.lower(): - raise VoiceTranscriptionError( - "Файл большой: я попробовал скачать его через текущий Telegram Bot API, " - "но Telegram не дал это сделать. Для такого аудио нужен локальный `telegram-bot-api` " - "сервер или другой способ передать файл боту.", - stage="telegram_get_file_too_big", - retryable=False, - detail=detail, - ) from e - raise VoiceTranscriptionError( - "не удалось получить информацию о файле из Telegram.", - stage="telegram_get_file", - detail=detail, - ) from e - info = result.get("result") or {} - file_path = info.get("file_path") - if not file_path: - raise VoiceTranscriptionError( - "Telegram не вернул путь к файлу.", - stage="telegram_file_path", - retryable=True, - detail=json.dumps(info, ensure_ascii=False)[:1000], - ) - file_url = self.telegram.file_base + file_path.lstrip("/") - req = request.Request(file_url, method="GET") - 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. - if lower.endswith(".oga"): - base = original_name[:-4] if len(original_name) > 4 else "audio" - normalized = f"{base}.ogg" - else: - normalized = original_name - return data, normalized - - def _prepare_audio_parts_for_transcription( - self, - file_bytes: bytes, - filename: str, - *, - duration_seconds: int, - job_id: str, - job_num: Any, - ) -> list[tuple[bytes, str]]: - needs_duration_chunking = duration_seconds > self.cfg.openai_transcribe_max_chunk_seconds - if len(file_bytes) <= self.cfg.openai_transcribe_max_upload_bytes and not needs_duration_chunking: - return [(file_bytes, filename)] - ffmpeg_path = shutil.which(self.cfg.ffmpeg_bin) - ffprobe_path = shutil.which(self.cfg.ffprobe_bin) - if not ffmpeg_path or not ffprobe_path: - raise VoiceTranscriptionError( - "для длинного аудио нужен локальный `ffmpeg`/`ffprobe`, но они не найдены в системе.", - stage="audio_prepare_tools_missing", - retryable=False, - ) - with tempfile.TemporaryDirectory(prefix="shine-audio-") as tmpdir: - tmp = Path(tmpdir) - input_suffix = Path(filename).suffix or ".ogg" - input_path = tmp / f"source{input_suffix}" - input_path.write_bytes(file_bytes) - prepared_path = tmp / "prepared.ogg" - self._ffmpeg_reencode_audio(input_path, prepared_path) - prepared_bytes = prepared_path.read_bytes() - prepared_duration = self._ffprobe_duration_seconds(prepared_path) - if ( - len(prepared_bytes) <= self.cfg.openai_transcribe_max_upload_bytes - and prepared_duration <= self.cfg.openai_transcribe_max_chunk_seconds - ): - return [(prepared_bytes, prepared_path.name)] - chunk_length = self._choose_transcription_chunk_seconds(prepared_duration, len(prepared_bytes)) - print( - f"[py-bot] audio chunking job={job_id} num={job_num} duration={prepared_duration:.1f}s total_bytes={len(prepared_bytes)} chunk_seconds={chunk_length}", - flush=True, - ) - chunks: list[tuple[bytes, str]] = [] - offset = 0 - index = 1 - total_duration = max(1, int(prepared_duration + 0.999)) - while offset < total_duration: - chunk_path = tmp / f"chunk_{index:03d}.ogg" - self._ffmpeg_extract_audio_chunk(prepared_path, chunk_path, offset, chunk_length) - chunk_bytes = chunk_path.read_bytes() - if not chunk_bytes: - break - if len(chunk_bytes) > self.cfg.openai_transcribe_max_upload_bytes: - raise VoiceTranscriptionError( - "локальная нарезка аудио дала слишком большой кусок для OpenAI; нужно уменьшить размер чанка.", - stage="audio_chunk_too_large", - retryable=False, - ) - chunks.append((chunk_bytes, chunk_path.name)) - step = max(1, chunk_length - self.cfg.openai_transcribe_overlap_seconds) - offset += step - index += 1 - if not chunks: - raise VoiceTranscriptionError( - "не удалось подготовить куски аудио для распознавания.", - stage="audio_chunk_empty", - retryable=False, - ) - return chunks - - def _ffmpeg_reencode_audio(self, input_path: Path, output_path: Path) -> None: - cmd = [ - self.cfg.ffmpeg_bin, - "-y", - "-i", - str(input_path), - "-vn", - "-ac", - "1", - "-ar", - "16000", - "-c:a", - "libopus", - "-b:a", - f"{self.cfg.openai_transcribe_reencode_bitrate_kbps}k", - str(output_path), - ] - self._run_subprocess_checked(cmd, "audio_reencode_ffmpeg") - - def _ffmpeg_extract_audio_chunk(self, input_path: Path, output_path: Path, offset_seconds: int, chunk_seconds: int) -> None: - cmd = [ - self.cfg.ffmpeg_bin, - "-y", - "-ss", - str(offset_seconds), - "-t", - str(chunk_seconds), - "-i", - str(input_path), - "-vn", - "-acodec", - "copy", - str(output_path), - ] - self._run_subprocess_checked(cmd, "audio_chunk_ffmpeg") - - def _ffprobe_duration_seconds(self, audio_path: Path) -> float: - cmd = [ - self.cfg.ffprobe_bin, - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - str(audio_path), - ] - try: - result = subprocess.run( - cmd, - check=True, - capture_output=True, - text=True, - timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, - ) - except subprocess.TimeoutExpired as e: - raise VoiceTranscriptionError( - f"`ffprobe` не успел определить длительность аудио за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", - stage="audio_probe_timeout", - retryable=False, - ) from e - except subprocess.CalledProcessError as e: - detail = (e.stderr or e.stdout or "").strip() - raise VoiceTranscriptionError( - "не удалось определить длительность аудио через `ffprobe`.", - stage="audio_probe_failed", - retryable=False, - detail=detail[:1500], - ) from e - raw = (result.stdout or "").strip() - try: - return max(0.0, float(raw)) - except ValueError as e: - raise VoiceTranscriptionError( - "`ffprobe` вернул некорректную длительность аудио.", - stage="audio_probe_invalid", - retryable=False, - detail=raw[:300], - ) from e - - def _run_subprocess_checked(self, cmd: list[str], stage: str) -> None: - try: - subprocess.run( - cmd, - check=True, - capture_output=True, - text=True, - timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, - ) - except subprocess.TimeoutExpired as e: - raise VoiceTranscriptionError( - f"локальная обработка аудио не успела завершиться за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", - stage=f"{stage}_timeout", - retryable=False, - ) from e - except subprocess.CalledProcessError as e: - detail = (e.stderr or e.stdout or "").strip() - raise VoiceTranscriptionError( - "локальная обработка аудио через `ffmpeg` завершилась с ошибкой.", - stage=f"{stage}_failed", - retryable=False, - detail=detail[:1500], - ) from e - - def _choose_transcription_chunk_seconds(self, duration_seconds: float, total_bytes: int) -> int: - max_chunk = self.cfg.openai_transcribe_max_chunk_seconds - safe_seconds = max(60, max_chunk - self.cfg.openai_transcribe_overlap_seconds) - if duration_seconds <= 0 or total_bytes <= 0: - return safe_seconds - bytes_per_second = total_bytes / max(duration_seconds, 1.0) - if bytes_per_second <= 0: - return safe_seconds - size_limited = int((self.cfg.openai_transcribe_max_upload_bytes * 0.9) / bytes_per_second) - return max(60, min(safe_seconds, size_limited if size_limited > 0 else safe_seconds)) - - @staticmethod - def _transcription_prompt_tail(text: str, limit: int = 1000) -> str: - source = compact_spaces(text) - if len(source) <= limit: - return source - return source[-limit:] - - def _telegram_cloud_download_is_likely_too_big(self, file_size: int) -> bool: - if file_size <= 0: - return False - using_cloud_api = self.cfg.telegram_api_base_url.rstrip("/") == "https://api.telegram.org" - return using_cloud_api and file_size > 20 * 1024 * 1024 - - @staticmethod - def _bytes_to_mb(value: int) -> str: - return f"{value / (1024 * 1024):.1f}" - - def _openai_transcribe(self, file_bytes: bytes, filename: str, prompt: str = "") -> str: - boundary = "----shine-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) - mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" - - def text_part(name: str, value: str) -> bytes: - return ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n\r\n' - f"{value}\r\n" - ).encode("utf-8") - - body = bytearray() - body.extend(text_part("model", self.cfg.openai_transcribe_model)) - body.extend(text_part("response_format", "text")) - prompt = compact_spaces(prompt) - if prompt: - body.extend(text_part("prompt", prompt[:1000])) - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' - f"Content-Type: {mime}\r\n\r\n" - ).encode("utf-8") - ) - body.extend(file_bytes) - body.extend(b"\r\n") - body.extend(f"--{boundary}--\r\n".encode("utf-8")) - - req = request.Request("https://api.openai.com/v1/audio/transcriptions", method="POST", data=bytes(body)) - 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=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") - 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: - s = (line or "").strip() - if not s.startswith("{"): - return None - try: - obj = json.loads(s) - except Exception: - return None - if obj.get("type") != "item.completed": - return None - item = obj.get("item") or {} - if item.get("type") != "agent_message": - return None - text = (item.get("text") or "").strip() - if not text: - return None - if len(text) > 220: - return text[:220] + "..." - return text - - @staticmethod - def _extract_fallback_message(lines: list[str]) -> str: - for line in reversed(lines): - line = line.strip() - if not line: - continue - if line.startswith("{") and '"type":' in line: - continue - if line.startswith("mcp:") or line.startswith("OpenAI Codex"): - continue - return line - return "" - - @staticmethod - def _extract_codex_thread_id(line: str) -> str: - s = (line or "").strip() - if not s.startswith("{"): - return "" - try: - obj = json.loads(s) - except Exception: - return "" - if obj.get("type") != "thread.started": - return "" - thread_id = (obj.get("thread_id") or "").strip() - return thread_id - - @staticmethod - def _is_missing_codex_session_error(text: str) -> bool: - lowered = (text or "").lower() - markers = [ - "session not found", - "conversation not found", - "thread not found", - "no session found", - "invalid session", - "unknown session", - "no conversation found", - "unknown thread", - ] - return any(marker in lowered for marker in markers) - - @staticmethod - def _format_duration(seconds: int) -> str: - seconds = max(0, seconds) - minutes, sec = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours}ч {minutes}м {sec}с" - if minutes: - return f"{minutes}м {sec}с" - return f"{sec}с" - - -def run_selftest(config: BotConfig, prompt: str) -> int: - cmd = [ - str(config.codex_bin), - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--json", - "-C", str(config.codex_workdir), - prompt, - ] - proc = subprocess.run( - cmd, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - print(proc.stdout) - if proc.stderr: - print(proc.stderr) - return proc.returncode - - -def main() -> int: - parser = argparse.ArgumentParser(description="SHiNE Python Telegram bot wrapper for Codex CLI") - parser.add_argument("--selftest-codex", default="", help="Выполнить только codex exec с этим prompt и выйти") - args = parser.parse_args() - - root = Path(__file__).resolve().parent - cfg = BotConfig(root) - if args.selftest_codex: - return run_selftest(cfg, args.selftest_codex) - - service = ShinePyBotService(cfg) - try: - service.run() - except KeyboardInterrupt: - service.shutdown() - return 0 - except Exception as e: - print(f"[py-bot] FATAL: {e}", flush=True) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh b/SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh deleted file mode 100755 index eb34b47b..00000000 --- a/SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="/home/ai/work/SHiNE/SHiNE-server-sha256" -SERVICE_DIR="${ROOT_DIR}/SHiNE-agent-bot-coder" -UNIT_SRC="${SERVICE_DIR}/scripts/systemd/shine-agent-bot-coder.service" -UNIT_DST="${HOME}/.config/systemd/user/shine-agent-bot-coder.service" - -echo "[1/6] Проверка python3..." -command -v python3 >/dev/null 2>&1 || { echo "python3 не найден"; exit 1; } - -echo "[2/6] Подготовка папки логов..." -mkdir -p "${SERVICE_DIR}/logs" - -echo "[3/6] Копирование user systemd unit..." -mkdir -p "$(dirname "${UNIT_DST}")" -cp "${UNIT_SRC}" "${UNIT_DST}" - -echo "[4/6] daemon-reload..." -systemctl --user daemon-reload - -echo "[5/6] enable + start..." -systemctl --user enable --now shine-agent-bot-coder - -echo "[6/6] Статус:" -systemctl --user status shine-agent-bot-coder --no-pager - -echo "Готово. Логи: journalctl --user -u shine-agent-bot-coder -f" diff --git a/SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service b/SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service deleted file mode 100644 index 231e5557..00000000 --- a/SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder -EnvironmentFile=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/.env -ExecStart=/usr/bin/python3 /home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/py_bot_service.py -Restart=always -RestartSec=5 -TimeoutStopSec=20 -SuccessExitStatus=143 0 -StandardOutput=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log -StandardError=append:/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-agent-bot-coder/logs/service.log - -[Install] -WantedBy=default.target diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index 93981473..573f3bea 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -45,30 +45,20 @@ shine-UI/server-ui.html - `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` - `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` -Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md` +Подробнее: `docs/Инициализация_Solana_регистрации/README.md` ## Синхронизация с партнёрскими серверами Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`. -Детали: `Dev_Docs/Blockchain/sync-between-servers.md` +Детали: `docs/Blockchain/sync-between-servers.md` ## Деплой -``` -./gradlew deployServer -./gradlew deployUI -``` - -Default deploy по умолчанию идёт на `server2.shineup.me` (`player@193.8.215.70`). - -Production deploy: - -``` -./gradlew deployServerProduction -./gradlew deployUIProduction -``` - -Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя. +- Основные инструкции по деплою находятся в `../deploy/AGENTS.md`. +- Deploy выполнять shell-скриптами из `../deploy/scripts/`. +- Gradle deploy-задачи не использовать: Gradle остаётся для сборки и локального запуска. +- Любые изменения на production (`shineup.me`, `server2.shineup.me`) делать только после отдельного явного подтверждения пользователя. +- Перед production deploy обязательно обновить/проверить backup в `deploy/backup/archive/`. Логи на проде: - `/home/player/SHiNE/shine-server/logs/app.log` diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 81eeb4b0..d99c4706 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -641,6 +641,7 @@ public final class DatabaseInitializer { origin_session_id TEXT, receipt_ref_base_key TEXT, receipt_ref_type INTEGER, + read_at_ms INTEGER, FOREIGN KEY (from_login) REFERENCES solana_users(login), FOREIGN KEY (to_login) REFERENCES solana_users(login) ); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 6f39a7ba..c4cb4ab7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 11; + private static final int LATEST_SCHEMA_VERSION = 12; private final String jdbcUrl; @@ -94,6 +94,7 @@ public final class SqliteDbController { case 9 -> migrateToV9(); case 10 -> migrateToV10(); case 11 -> migrateToV11(); + case 12 -> migrateToV12(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -329,6 +330,26 @@ public final class SqliteDbController { } } + private void migrateToV12() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSignedMessagesReadAtColumn(c, st); + backfillSignedMessagesReadAt(st); + setSchemaVersion(c, 12); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v12 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v12 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -463,6 +484,33 @@ public final class SqliteDbController { } } + private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + if (!columnExists(c, "signed_messages_v2", "read_at_ms")) { + st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER"); + } + } + + private static void backfillSignedMessagesReadAt(Statement st) throws SQLException { + st.executeUpdate(""" + UPDATE signed_messages_v2 AS content + SET read_at_ms = ( + SELECT MIN(receipt.time_ms) + FROM signed_messages_v2 AS receipt + WHERE receipt.message_type IN (3, 4) + AND receipt.receipt_ref_base_key = content.base_key + ) + WHERE content.message_type IN (1, 2) + AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0) + AND EXISTS ( + SELECT 1 + FROM signed_messages_v2 AS receipt + WHERE receipt.message_type IN (3, 4) + AND receipt.receipt_ref_base_key = content.base_key + ); + """); + } + /** * Временная одноразовая миграция на переходе к SHiNE_DM v1: * старые строки signed_messages_v2 больше не гарантированно совместимы diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index 530f4b4b..85af6e05 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -7,10 +7,14 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; import java.util.ArrayList; import java.util.List; public final class SignedMessagesV2DAO { + private static final int SQLITE_BUSY_MAX_RETRIES = 6; + private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L; + public enum ApplyStatus { APPLIED, DUPLICATE_OR_OLDER, @@ -37,174 +41,195 @@ public final class SignedMessagesV2DAO { } public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception { - try (Connection c = db.getConnection()) { - if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) { - return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) { + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + String sql = """ + INSERT OR IGNORE INTO signed_messages_v2 ( + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type, read_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER; + if (status.applied()) { + markMessageReadByReceipt(c, e); + } + return status; + } } - String sql = """ - INSERT OR IGNORE INTO signed_messages_v2 ( - message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, - raw_block, created_at_ms, source_api, origin_session_id, - receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - bindSignedMessage(ps, e); - return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER; - } - } + }); } public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception { - try (Connection c = db.getConnection()) { - boolean prevAutoCommit = c.getAutoCommit(); - c.setAutoCommit(false); - try { - int insertedFirst = insertStrict(c, first); - int insertedSecond = insertStrict(c, second); - if (insertedFirst == 1 && insertedSecond == 1) { - c.commit(); - return true; - } - c.rollback(); - return false; - } catch (SQLException sqlEx) { - try { c.rollback(); } catch (Exception ignored) {} - if (isConstraintViolation(sqlEx)) { + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + int insertedFirst = insertStrict(c, first); + int insertedSecond = insertStrict(c, second); + if (insertedFirst == 1 && insertedSecond == 1) { + markMessageReadByReceipt(c, first); + markMessageReadByReceipt(c, second); + c.commit(); + return true; + } + c.rollback(); return false; + } catch (SQLException sqlEx) { + try { c.rollback(); } catch (Exception ignored) {} + if (isConstraintViolation(sqlEx)) { + return false; + } + throw sqlEx; + } finally { + c.setAutoCommit(prevAutoCommit); } - throw sqlEx; - } finally { - c.setAutoCommit(prevAutoCommit); } - } + }); } public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { - try (Connection c = db.getConnection()) { - boolean prevAutoCommit = c.getAutoCommit(); - c.setAutoCommit(false); - try { - if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { - c.rollback(); - return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; - } - if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { - c.rollback(); - return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; - } + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; + } - RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey()); - RevisionMarker nextMarker = RevisionMarker.of(incoming); - if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { - c.rollback(); - return ApplyStatus.DUPLICATE_OR_OLDER; + RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } + + upsertMessage(c, incoming); + upsertMessage(c, outgoing); + markMessageReadByReceipt(c, incoming); + markMessageReadByReceipt(c, outgoing); + resetDeliveryRows(c, incoming.getMessageKey()); + resetDeliveryRows(c, outgoing.getMessageKey()); + + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); } - - upsertMessage(c, incoming); - upsertMessage(c, outgoing); - resetDeliveryRows(c, incoming.getMessageKey()); - resetDeliveryRows(c, outgoing.getMessageKey()); - - c.commit(); - return ApplyStatus.APPLIED; - } catch (Exception ex) { - try { c.rollback(); } catch (Exception ignored) {} - throw ex; - } finally { - c.setAutoCommit(prevAutoCommit); } - } + }); } public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception { - try (Connection c = db.getConnection()) { - boolean prevAutoCommit = c.getAutoCommit(); - c.setAutoCommit(false); - try { - if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { - c.rollback(); - return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; - } - if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { - c.rollback(); - return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; - } + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; + } - RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey()); - RevisionMarker nextMarker = RevisionMarker.of(incoming); - if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { - c.rollback(); - return ApplyStatus.DUPLICATE_OR_OLDER; - } + RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } - upsertMessage(c, incoming); - resetDeliveryRows(c, incoming.getMessageKey()); - c.commit(); - return ApplyStatus.APPLIED; - } catch (Exception ex) { - try { c.rollback(); } catch (Exception ignored) {} - throw ex; - } finally { - c.setAutoCommit(prevAutoCommit); + upsertMessage(c, incoming); + markMessageReadByReceipt(c, incoming); + resetDeliveryRows(c, incoming.getMessageKey()); + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } } - } + }); } public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception { - try (Connection c = db.getConnection()) { - boolean prevAutoCommit = c.getAutoCommit(); - c.setAutoCommit(false); - try { - if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) { - c.rollback(); - return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; - } - if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) { - c.rollback(); - return ApplyStatus.DUPLICATE_OR_OLDER; - } + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } - deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); - upsertMessage(c, tombstone); - resetDeliveryRows(c, tombstone.getMessageKey()); + deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); - c.commit(); - return ApplyStatus.APPLIED; - } catch (Exception ex) { - try { c.rollback(); } catch (Exception ignored) {} - throw ex; - } finally { - c.setAutoCommit(prevAutoCommit); + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } } - } + }); } public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { - try (Connection c = db.getConnection()) { - boolean prevAutoCommit = c.getAutoCommit(); - c.setAutoCommit(false); - try { - Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin()); - if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) { - c.rollback(); - return ApplyStatus.DUPLICATE_OR_OLDER; + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin()); + if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } + + deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); + + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); } - - deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); - upsertMessage(c, tombstone); - resetDeliveryRows(c, tombstone.getMessageKey()); - - c.commit(); - return ApplyStatus.APPLIED; - } catch (Exception ex) { - try { c.rollback(); } catch (Exception ignored) {} - throw ex; - } finally { - c.setAutoCommit(prevAutoCommit); } - } + }); } public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { @@ -214,7 +239,7 @@ public final class SignedMessagesV2DAO { message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, - receipt_ref_base_key, receipt_ref_type + receipt_ref_base_key, receipt_ref_type, read_at_ms FROM signed_messages_v2 WHERE message_key = ? """; @@ -235,90 +260,153 @@ public final class SignedMessagesV2DAO { } public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception { - try (Connection c = db.getConnection()) { - String sql = """ - INSERT OR IGNORE INTO signed_message_session_delivery ( - message_key, session_id, delivered, delivered_at_ms, created_at_ms - ) VALUES (?, ?, 0, NULL, ?) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, messageKey); - ps.setString(2, sessionId); - ps.setLong(3, nowMs); - ps.executeUpdate(); + ensureDeliveryRows(messageKey, List.of(sessionId), nowMs); + } + + public void ensureDeliveryRows(String messageKey, List sessionIds, long nowMs) throws Exception { + if (sessionIds == null || sessionIds.isEmpty()) return; + withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT OR IGNORE INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) VALUES (?, ?, 0, NULL, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (String sessionId : sessionIds) { + if (sessionId == null || sessionId.isBlank()) continue; + ps.setString(1, messageKey); + ps.setString(2, sessionId); + ps.setLong(3, nowMs); + ps.addBatch(); + } + ps.executeBatch(); + } + return null; } - } + }); } public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception { - try (Connection c = db.getConnection()) { - String insertSql = """ - INSERT OR IGNORE INTO signed_message_session_delivery ( - message_key, session_id, delivered, delivered_at_ms, created_at_ms - ) VALUES (?, ?, 0, NULL, ?) - """; - try (PreparedStatement ps = c.prepareStatement(insertSql)) { - ps.setString(1, messageKey); - ps.setString(2, sessionId); - ps.setLong(3, deliveredAtMs); - ps.executeUpdate(); + withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + String sql = """ + INSERT INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) VALUES (?, ?, 1, ?, ?) + ON CONFLICT(message_key, session_id) DO UPDATE SET + delivered = 1, + delivered_at_ms = CASE + WHEN signed_message_session_delivery.delivered_at_ms IS NULL THEN excluded.delivered_at_ms + WHEN signed_message_session_delivery.delivered_at_ms > excluded.delivered_at_ms THEN excluded.delivered_at_ms + ELSE signed_message_session_delivery.delivered_at_ms + END + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + ps.setString(2, sessionId); + ps.setLong(3, deliveredAtMs); + ps.setLong(4, deliveredAtMs); + ps.executeUpdate(); + } + return null; } - - String updateSql = """ - UPDATE signed_message_session_delivery - SET delivered = 1, delivered_at_ms = ? - WHERE message_key = ? AND session_id = ? - """; - try (PreparedStatement ps = c.prepareStatement(updateSql)) { - ps.setLong(1, deliveredAtMs); - ps.setString(2, messageKey); - ps.setString(3, sessionId); - ps.executeUpdate(); - } - } + }); } public List listPendingForSession(String login, String sessionId) throws Exception { - try (Connection c = db.getConnection()) { - String fillSql = """ - INSERT OR IGNORE INTO signed_message_session_delivery ( - message_key, session_id, delivered, delivered_at_ms, created_at_ms - ) - SELECT m.message_key, ?, 0, NULL, ? - FROM signed_messages_v2 m - WHERE ( - (m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE) - OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE) - OR (m.message_type IN (5, 6, 7, 8) - AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE)) - ) - """; - long now = System.currentTimeMillis(); - try (PreparedStatement ps = c.prepareStatement(fillSql)) { - ps.setString(1, sessionId); - ps.setLong(2, now); - ps.setString(3, login); - ps.setString(4, login); - ps.setString(5, login); - ps.setString(6, login); - ps.executeUpdate(); - } + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + String fillSql = """ + INSERT OR IGNORE INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) + SELECT m.message_key, ?, 0, NULL, ? + FROM signed_messages_v2 m + WHERE ( + (m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE) + OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE) + OR (m.message_type IN (5, 6, 7, 8) + AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE)) + ) + """; + long now = System.currentTimeMillis(); + try (PreparedStatement ps = c.prepareStatement(fillSql)) { + ps.setString(1, sessionId); + ps.setLong(2, now); + ps.setString(3, login); + ps.setString(4, login); + ps.setString(5, login); + ps.setString(6, login); + ps.executeUpdate(); + } + String sql = """ + SELECT + m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, + m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms, + m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, + m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms + FROM signed_messages_v2 m + JOIN signed_message_session_delivery d + ON d.message_key = m.message_key + WHERE d.session_id = ? AND d.delivered = 0 + ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC + """; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, sessionId); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) out.add(mapRow(rs)); + } + } + return out; + } + }); + } + + public List listConversationPage( + String login, + String peerLogin, + long beforeTimeMs, + String beforeMessageKey, + int limit + ) throws Exception { + try (Connection c = db.getConnection()) { String sql = """ SELECT - m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, - m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms, - m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, - m.receipt_ref_base_key, m.receipt_ref_type - FROM signed_messages_v2 m - JOIN signed_message_session_delivery d - ON d.message_key = m.message_key - WHERE d.session_id = ? AND d.delivered = 0 - ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type, read_at_ms + FROM signed_messages_v2 + WHERE target_login = ? COLLATE NOCASE + AND message_type IN (1, 2) + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + AND ( + ? <= 0 + OR time_ms < ? + OR (time_ms = ? AND (? = '' OR message_key < ?)) + ) + ORDER BY time_ms DESC, message_key DESC + LIMIT ? """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, sessionId); + ps.setString(1, login); + ps.setString(2, login); + ps.setString(3, peerLogin); + ps.setString(4, peerLogin); + ps.setString(5, login); + ps.setLong(6, beforeTimeMs); + ps.setLong(7, beforeTimeMs); + ps.setLong(8, beforeTimeMs); + ps.setString(9, beforeMessageKey == null ? "" : beforeMessageKey); + ps.setString(10, beforeMessageKey == null ? "" : beforeMessageKey); + ps.setInt(11, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); } @@ -333,8 +421,8 @@ public final class SignedMessagesV2DAO { message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, - receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + receipt_ref_base_key, receipt_ref_type, read_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_key) DO UPDATE SET base_key = excluded.base_key, target_login = excluded.target_login, @@ -350,7 +438,8 @@ public final class SignedMessagesV2DAO { source_api = excluded.source_api, origin_session_id = excluded.origin_session_id, receipt_ref_base_key = excluded.receipt_ref_base_key, - receipt_ref_type = excluded.receipt_ref_type + receipt_ref_type = excluded.receipt_ref_type, + read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -358,6 +447,32 @@ public final class SignedMessagesV2DAO { } } + private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException { + if (entry == null) return; + int messageType = entry.getMessageType(); + if (messageType != 3 && messageType != 4) return; + String receiptRefBaseKey = String.valueOf(entry.getReceiptRefBaseKey() == null ? "" : entry.getReceiptRefBaseKey()).trim(); + if (receiptRefBaseKey.isEmpty()) return; + long readAtMs = entry.getTimeMs(); + if (readAtMs <= 0) return; + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE signed_messages_v2 + SET read_at_ms = CASE + WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ? + WHEN read_at_ms > ? THEN ? + ELSE read_at_ms + END + WHERE base_key = ? + AND message_type IN (1, 2) + """)) { + ps.setLong(1, readAtMs); + ps.setLong(2, readAtMs); + ps.setLong(3, readAtMs); + ps.setString(4, receiptRefBaseKey); + ps.executeUpdate(); + } + } + private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException { String sql = """ SELECT revision_time_ms, reencrypted_at_ms @@ -536,8 +651,8 @@ public final class SignedMessagesV2DAO { message_key, base_key, target_login, from_login, to_login, time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, raw_block, created_at_ms, source_api, origin_session_id, - receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + receipt_ref_base_key, receipt_ref_type, read_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -563,6 +678,8 @@ public final class SignedMessagesV2DAO { ps.setString(15, e.getReceiptRefBaseKey()); if (e.getReceiptRefType() == null) ps.setObject(16, null); else ps.setInt(16, e.getReceiptRefType()); + if (e.getReadAtMs() == null) ps.setObject(17, null); + else ps.setLong(17, e.getReadAtMs()); } private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException { @@ -586,6 +703,45 @@ public final class SignedMessagesV2DAO { return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); } + private boolean isBusyLock(SQLException ex) { + Throwable current = ex; + while (current != null) { + String msg = String.valueOf(current.getMessage()).toLowerCase(); + if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) { + return true; + } + current = current.getCause(); + } + return false; + } + + private void sleepBeforeBusyRetry(int attempt) throws SQLException { + long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4)); + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie); + throw sqlEx; + } + } + + private T withBusyRetry(SqlWork work) throws Exception { + SQLException lastBusy = null; + for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) { + try { + return work.run(); + } catch (SQLException ex) { + if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) { + throw ex; + } + lastBusy = ex; + sleepBeforeBusyRetry(attempt); + } + } + throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy; + } + private int compareMarkers(RevisionMarker left, RevisionMarker right) { int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs); if (revisionCompare != 0) return revisionCompare; @@ -611,6 +767,8 @@ public final class SignedMessagesV2DAO { e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key")); int maybeRefType = rs.getInt("receipt_ref_type"); e.setReceiptRefType(rs.wasNull() ? null : maybeRefType); + long maybeReadAt = rs.getLong("read_at_ms"); + e.setReadAtMs(rs.wasNull() ? null : maybeReadAt); return e; } @@ -619,4 +777,9 @@ public final class SignedMessagesV2DAO { return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); } } + + @FunctionalInterface + private interface SqlWork { + T run() throws Exception; + } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java index 84e4bdfa..d19f2d63 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -17,6 +17,7 @@ public class SignedMessageV2Entry { private String originSessionId; private String receiptRefBaseKey; private Integer receiptRefType; + private Long readAtMs; public String getMessageKey() { return messageKey; } public void setMessageKey(String messageKey) { this.messageKey = messageKey; } @@ -50,4 +51,6 @@ public class SignedMessageV2Entry { public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; } public Integer getReceiptRefType() { return receiptRefType; } public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; } + public Long getReadAtMs() { return readAtMs; } + public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 24157745..9d2121aa 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -91,6 +91,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler; import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; @@ -102,6 +103,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; @@ -200,6 +202,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), + Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), @@ -280,6 +283,7 @@ public final class JsonHandlerRegistry { Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), + Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 2e963c79..cebbfbde 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; @@ -51,8 +50,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); private static final SecureRandom RANDOM = new SecureRandom(); private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L; - private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L; - public static final long ALLOWED_SKEW_MS = 30_000L; @Override @@ -424,7 +421,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); - SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS); // --- формируем ответ --- Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index bc4685e7..6a4f51a1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; @@ -44,8 +43,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); private static final long ALLOWED_SKEW_MS = 30_000L; - private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L; - @Override public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; @@ -302,7 +299,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); - SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS); // ответ Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 02df74ac..298dc245 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -268,7 +268,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } // Репосты временно отключены до будущей реализации. - // Точка возврата: Dev_Docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md + // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md if ((block.type & 0xFFFF) == 1 && (block.subType & 0xFFFF) == (MsgSubType.TEXT_REPOST & 0xFFFF)) { log.warn("AddBlock: repost_disabled (login={}, blockchainName={}, blockNumber={})", diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java new file mode 100644 index 00000000..19d7f5f9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java @@ -0,0 +1,98 @@ +package server.logic.ws_protocol.JSON.messages; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.SignedMessageV2Entry; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public class Net_GetDirectMessages_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class); + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetDirectMessages_Request req = (Net_GetDirectMessages_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getPeerLogin() == null || req.getPeerLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "peerLogin обязателен"); + } + + int limit = req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(); + if (limit <= 0 || limit > MAX_LIMIT) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_LIMIT", "limit должен быть в диапазоне 1.." + MAX_LIMIT); + } + + String login = ctx.getLogin().trim(); + String peerLogin = req.getPeerLogin().trim(); + long beforeTimeMs = req.getBeforeTimeMs() == null ? 0L : req.getBeforeTimeMs(); + String beforeMessageKey = req.getBeforeMessageKey() == null ? "" : req.getBeforeMessageKey().trim(); + + try { + List page = SignedMessagesV2DAO.getInstance().listConversationPage( + login, + peerLogin, + beforeTimeMs, + beforeMessageKey, + limit + 1 + ); + + boolean hasMore = page.size() > limit; + if (hasMore) { + page = new ArrayList<>(page.subList(0, limit)); + } + + Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(login); + resp.setPeerLogin(peerLogin); + resp.setLimit(limit); + resp.setHasMore(hasMore); + + List items = new ArrayList<>(); + for (SignedMessageV2Entry entry : page) { + Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem(); + item.setMessageKey(entry.getMessageKey()); + item.setBaseKey(entry.getBaseKey()); + item.setFromLogin(entry.getFromLogin()); + item.setToLogin(entry.getToLogin()); + item.setMessageType(entry.getMessageType()); + item.setTimeMs(entry.getTimeMs()); + item.setNonce(entry.getNonce()); + item.setRevisionTimeMs(entry.getRevisionTimeMs()); + item.setReencryptedAtMs(entry.getReencryptedAtMs()); + item.setCreatedAtMs(entry.getCreatedAtMs()); + item.setReadAtMs(entry.getReadAtMs()); + item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock())); + items.add(item); + } + resp.setMessages(items); + + if (hasMore && !items.isEmpty()) { + Net_GetDirectMessages_Response.MessageItem last = items.get(items.size() - 1); + resp.setNextBeforeTimeMs(last.getTimeMs()); + resp.setNextBeforeMessageKey(last.getMessageKey()); + } + return resp; + } catch (Exception e) { + log.error("GetDirectMessages failed for login={} peerLogin={}", login, peerLogin, e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index b37804a1..2d681dc1 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -50,12 +50,20 @@ public final class SignedMessagesRealtime { long now = System.currentTimeMillis(); for (String targetLogin : targetLoginsForMessage(message)) { List sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin); + List sessionIdsToTrack = new ArrayList<>(); + for (ActiveSessionEntry s : sessions) { + String sessionId = s.getSessionId(); + if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { + continue; + } + sessionIdsToTrack.add(sessionId); + } + SignedMessagesV2DAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now); for (ActiveSessionEntry s : sessions) { String sessionId = s.getSessionId(); if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { continue; } - SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false); if (deliveredOnline) { counters.wsDelivered++; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Request.java new file mode 100644 index 00000000..47f1da4c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Request.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetDirectMessages_Request extends Net_Request { + private String peerLogin; + private Integer limit; + private Long beforeTimeMs; + private String beforeMessageKey; + + public String getPeerLogin() { return peerLogin; } + public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; } + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + public Long getBeforeTimeMs() { return beforeTimeMs; } + public void setBeforeTimeMs(Long beforeTimeMs) { this.beforeTimeMs = beforeTimeMs; } + public String getBeforeMessageKey() { return beforeMessageKey; } + public void setBeforeMessageKey(String beforeMessageKey) { this.beforeMessageKey = beforeMessageKey; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java new file mode 100644 index 00000000..fdb914cb --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java @@ -0,0 +1,71 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_GetDirectMessages_Response extends Net_Response { + private String login; + private String peerLogin; + private int limit; + private boolean hasMore; + private Long nextBeforeTimeMs; + private String nextBeforeMessageKey; + private List messages = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getPeerLogin() { return peerLogin; } + public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; } + public int getLimit() { return limit; } + public void setLimit(int limit) { this.limit = limit; } + public boolean isHasMore() { return hasMore; } + public void setHasMore(boolean hasMore) { this.hasMore = hasMore; } + public Long getNextBeforeTimeMs() { return nextBeforeTimeMs; } + public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; } + public String getNextBeforeMessageKey() { return nextBeforeMessageKey; } + public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; } + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + public static class MessageItem { + private String messageKey; + private String baseKey; + private String fromLogin; + private String toLogin; + private int messageType; + private long timeMs; + private long nonce; + private long revisionTimeMs; + private long reencryptedAtMs; + private long createdAtMs; + private Long readAtMs; + private String blobB64; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public String getFromLogin() { return fromLogin; } + public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; } + public String getToLogin() { return toLogin; } + public void setToLogin(String toLogin) { this.toLogin = toLogin; } + public int getMessageType() { return messageType; } + public void setMessageType(int messageType) { this.messageType = messageType; } + public long getTimeMs() { return timeMs; } + public void setTimeMs(long timeMs) { this.timeMs = timeMs; } + public long getNonce() { return nonce; } + public void setNonce(long nonce) { this.nonce = nonce; } + public long getRevisionTimeMs() { return revisionTimeMs; } + public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; } + public long getReencryptedAtMs() { return reencryptedAtMs; } + public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; } + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + public Long getReadAtMs() { return readAtMs; } + public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; } + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } + } +} diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 307739f3..b910adbd 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -44,7 +44,7 @@ webpush.vapid.subject=mailto:admin@shine.local # Тогда сервер будет выдавать временный username/password (TTL). # ------------------------------------------------------------ call.ice.stun.urls=stun:stun.l.google.com:19302 -call.ice.turn.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp +call.ice.turn.urls=turn:turn1.shineup.me:3478?transport=udp,turn:turn1.shineup.me:3478?transport=tcp call.ice.turn.ttlSec=600 call.ice.turn.userPrefix=shine call.ice.turn.sharedSecret= @@ -58,12 +58,24 @@ call.ice.turn.password= # Каждый блок описывает один TURN-узел. Новые узлы добавляются по индексу. # Приоритет авторизации на узел: sharedSecret -> статические username/password. # ------------------------------------------------------------ -call.ice.turn.servers.1.id=shineup-main-185 -call.ice.turn.servers.1.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp -call.ice.turn.servers.1.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106 +call.ice.turn.servers.1.id=turn1 +call.ice.turn.servers.1.urls=turn:turn1.shineup.me:3478?transport=udp,turn:turn1.shineup.me:3478?transport=tcp +call.ice.turn.servers.1.sharedSecret= call.ice.turn.servers.1.username= call.ice.turn.servers.1.password= +call.ice.turn.servers.2.id=turn2 +call.ice.turn.servers.2.urls=turn:turn2.shineup.me:3478?transport=udp,turn:turn2.shineup.me:3478?transport=tcp +call.ice.turn.servers.2.sharedSecret= +call.ice.turn.servers.2.username= +call.ice.turn.servers.2.password= + +call.ice.turn.servers.3.id=turn3 +call.ice.turn.servers.3.urls=turn:turn3.shineup.me:3478?transport=udp,turn:turn3.shineup.me:3478?transport=tcp +call.ice.turn.servers.3.sharedSecret= +call.ice.turn.servers.3.username= +call.ice.turn.servers.3.password= + # ------------------------------------------------------------ # Временные debug HTTP API для тестирования соединений # true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token) diff --git a/TASKS/01-07.04.26-каналы-api/01-ТЗ-каналы.md b/TASKS/01-07.04.26-каналы-api/01-ТЗ-каналы.md deleted file mode 100644 index 22688de5..00000000 --- a/TASKS/01-07.04.26-каналы-api/01-ТЗ-каналы.md +++ /dev/null @@ -1,220 +0,0 @@ -# Задача 01: Доработка вкладки «Каналы» (UI + API) - -## Кратко и по делу -Нужно довести вторую вкладку «Каналы» до полностью рабочего состояния на реальных данных сервера. - -Что должно работать: -- список каналов; -- вход в канал и чтение сообщений; -- вход в тред сообщения (история/ветка); -- ответ на сообщение; -- лайк/снятие лайка; -- подписка на пользователя; -- подписка на канал; -- видимое имя канала в формате `имя_пользователя/имя_канала`. - -Запись любых новых сущностей делается через `AddBlock` с подписью на клиенте. -Чтение делается через 3 API: -- `ListSubscriptionsFeed` -- `GetChannelMessages` -- `GetMessageThread` - -Техническая особенность (оставляем как есть): -- на экране каналов индикатор непрочитанного = общее число сообщений канала. - ---- - -## Подробное ТЗ - -### 1. Цель -Сделать рабочий каналовый сценарий «от списка до треда», где чтение строится на RPC API, а запись действий пользователя — только через `AddBlock`. - -### 2. Что уже есть в проекте - -#### 2.1 UI (частично) -- Есть страницы: - - `channels-list` - - `channel-view` - - `add-channel-view` -- Есть запросы чтения в клиенте: - - `authService.listSubscriptionsFeed(...)` - - `authService.getChannelMessages(...)` - - `authService.getMessageThread(...)` -- Есть fallback на mock-данные при ошибках сервера. - -#### 2.2 API/сервер (уже реализованы) -- `ListSubscriptionsFeed` -- `GetChannelMessages` -- `GetMessageThread` -- `AddBlock` - -#### 2.3 Тесты -- Есть интеграционный тест API каналов: `IT_06_ChannelsApi`. -- Есть тесты генерации блоков каналов/связей: `IT_03_AddBlock_NoAuth`. -- Формат `AddBlock` и его сборка/подпись описаны в `AddBlockSender`. - -### 3. Проблемы текущей реализации (что надо закрыть) -- Кнопки «подписаться на человека/канал» в списке каналов сейчас UI-only (модалка без реальной записи через `AddBlock`). -- `add-channel-view` пока не создает канал на сервере через `AddBlock` (`CreateChannelBody`), только делает `navigate`. -- `channel-view` добавляет пост локально (в память), а не отправляет блок `TEXT_POST` через `AddBlock`. -- Нет полноценного экрана треда сообщения с реальными `GetMessageThread` и действиями `ответить/лайк/убрать лайк` через блоки. -- Нет гарантированного отображения канала в требуемом формате `ownerLogin/channelName`. - -### 4. Функциональные требования - -#### 4.1 Список каналов -На вкладке «Каналы» отображать 3 группы: -- Мои каналы -- Каналы пользователей, на кого я подписан -- Каналы, на которые я подписан - -Источник данных: `ListSubscriptionsFeed`. - -Каждый канал показывать в формате: -- `ownerLogin/channelName` - -#### 4.2 Открытие канала -При входе в канал: -- загрузить сообщения через `GetChannelMessages`; -- показать список сообщений в хронологическом порядке (по текущему параметру `sort`); -- оставить техническую особенность непрочитанных как есть. - -#### 4.3 Открытие треда сообщения -При клике на сообщение: -- загрузить тред через `GetMessageThread`; -- показать `ancestors`, `focus`, `descendants`; -- из треда должны быть доступны действия: - - «Ответить» - - «Лайк» - - «Убрать лайк» - -Запись действий — только `AddBlock`. - -#### 4.4 Создание канала -В `add-channel-view` кнопка «Создать» должна: -- отправлять `AddBlock` с телом `CreateChannelBody`; -- после успеха возвращать к списку каналов и обновлять его. - -#### 4.5 Подписки -- Подписка на пользователя: `AddBlock` с `ConnectionBody` подтип `CONNECTION_FOLLOW`, target = HEADER пользователя. -- Подписка на канал: `AddBlock` с `ConnectionBody` подтип `CONNECTION_FOLLOW`, target = root блока канала (`CreateChannelBody` или HEADER для канала `0`). - -### 5. API (форматы) - -## 5.1 ListSubscriptionsFeed (чтение) -Request: -```json -{ - "op": "ListSubscriptionsFeed", - "requestId": "...", - "payload": { - "login": "A1", - "limit": 200 - } -} -``` - -Response (смысловые поля): -- `ownedChannels[]` -- `followedUsersChannels[]` -- `followedChannels[]` - ---- - -## 5.2 GetChannelMessages (чтение) -Request: -```json -{ - "op": "GetChannelMessages", - "requestId": "...", - "payload": { - "channel": { - "ownerBlockchainName": "A1-001", - "channelRootBlockNumber": 0, - "channelRootBlockHash": "" - }, - "limit": 200, - "sort": "asc" - } -} -``` - -Response (смысловые поля): -- `channel` -- `messages[]` - ---- - -## 5.3 GetMessageThread (чтение) -Request: -```json -{ - "op": "GetMessageThread", - "requestId": "...", - "payload": { - "message": { - "blockchainName": "A1-001", - "blockNumber": 15, - "blockHash": "..." - }, - "depthUp": 20, - "depthDown": 2, - "limitChildrenPerNode": 50 - } -} -``` - -Response (смысловые поля): -- `ancestors[]` -- `focus` -- `descendants[]` - ---- - -## 5.4 AddBlock (запись) -Любое изменение (создать канал, пост, reply, реакция, подписка) записывается через: -```json -{ - "op": "AddBlock", - "requestId": "...", - "payload": { - "blockchainName": "A1-001", - "blockNumber": 6, - "prevBlockHash": "<64-hex>", - "blockBytesB64": "" - } -} -``` - -Важно: -- `blockBytesB64` формируется на клиенте. -- Подпись блока формируется на клиенте приватным blockchain key пользователя. -- Перед добавлением блока клиент берет актуальный курсор цепочки с сервера. - -### 6. Типы блоков для каналов и связей (через AddBlock) -- Создание канала: `CreateChannelBody` -- Пост/ответ: `TextBody` (`TEXT_POST`, `TEXT_REPLY`) -- Реакции: `ReactionBody` (лайк/снятие лайка) -- Подписки: `ConnectionBody` (`CONNECTION_FOLLOW`) - -### 7. Критерии приемки -- Список каналов отображается с реальными данными API. -- Формат названия канала в UI: `ownerLogin/channelName`. -- Создание канала реально пишет блок и канал появляется после обновления. -- Отправка поста/ответа/реакций реально пишет блок и видна после перечитки API. -- Подписка на пользователя/канал реально пишет блок и отражается в выдаче. -- Переход в тред сообщения показывает реальные `ancestors/focus/descendants`. -- Непрочитанные в списке каналов = общее число сообщений (временное правило). - -### 8. Локальный запуск (уже сделано) -Команда: -```bash -./gradlew startLocal -``` - -Что делает: -- чистит логи; -- билдит сервер; -- запускает локальный WS сервер; -- запускает локальный HTTP сервер клиента; -- открывает браузер по URL с параметром `localWsPort`. diff --git a/TASKS/01-07.04.26-каналы-api/01-кратко-для-внешних.md b/TASKS/01-07.04.26-каналы-api/01-кратко-для-внешних.md deleted file mode 100644 index e8c7e839..00000000 --- a/TASKS/01-07.04.26-каналы-api/01-кратко-для-внешних.md +++ /dev/null @@ -1,25 +0,0 @@ -# Краткое описание задачи - -Нужно сделать полностью рабочую вкладку «Каналы» в SHiNE. - -Пользователь должен: -- видеть список каналов; -- открывать канал и читать сообщения; -- открывать тред сообщения; -- отвечать, ставить и убирать лайк; -- подписываться на пользователей и каналы. - -Чтение данных идет через 3 API: -- `ListSubscriptionsFeed` -- `GetChannelMessages` -- `GetMessageThread` - -Все действия записи делаются только через `AddBlock` с подписью на клиенте. - -Формат имени канала в интерфейсе: -- `имя_пользователя/имя_канала` - -Локальный запуск проекта: -```bash -./gradlew startLocal -``` diff --git a/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md b/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md deleted file mode 100644 index 5460016e..00000000 --- a/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md +++ /dev/null @@ -1,153 +0,0 @@ -# Задача 02: Web Push + подписанный API отправки личных сообщений - -## Контекст (по текущему состоянию проекта) -- Уже есть JSON WebSocket API для личных сообщений: `SendDirectMessage`, `AckIncomingMessage`, `UpsertPushToken`. -- Сейчас серверный fallback-пуш реализован через FCM (`FcmPushSender`) и ключ `fcm.server.key`. -- Клиент уже регистрирует service worker и токен Firebase, затем аплоадит push token на сервер. - -## Цель -Добавить полностью рабочий сценарий доставки личных сообщений с приоритетом: -1) онлайн-доставка в активную WebSocket-сессию; -2) если не подтверждено — Web Push; -3) поддержать отдельный API отправки без авторизации, где доступ проверяется цифровой подписью Ed25519 по `clientKey` отправителя. - ---- - -## Предварительная спецификация подписанного пакета (v1) -> ВАЖНО: финально фиксируется после уточнений по endian/кодировкам/лимитам. - -Пакет (binary): -1. `prefix` — ASCII-константа, например `SHINE_MESSAGE`. -2. `toLoginLen` — 1 байт. -3. `toLogin` — ASCII, длина = `toLoginLen`. -4. `fromLoginLen` — 1 байт. -5. `fromLogin` — ASCII, длина = `fromLoginLen`. -6. `timeMs` — 8 байт (unix ms). -7. `nonce32` — 4 байта случайное число. -8. `messageType` — 4 байта. -9. `targetMode` — 1 байт: - - `0` = всем сессиям пользователя, - - `1` = конкретной сессии. -10. Если `targetMode=1`: - - `sessionIdLen` — 1 байт, - - `sessionId` — ASCII. -11. `messageLen` — 2 байта. -12. `messageBytes` — бинарные данные длиной `messageLen`. -13. `signature64` — 64 байта, Ed25519 подпись всего блока **без** `signature64`. - -Ограничения (первичный draft): -- общий размер пакета ≤ 4000 байт; -- логины/префикс/идентификатор сессии — ASCII; -- повторы отсекаются по `(fromLogin, timeMs, nonce32)` в окне TTL. - ---- - -## Сервер: что доработать - -### 1) Новый endpoint без авторизации -Операция (через WS JSON обертку) условно `SendSignedDirectMessage`: -- принимает пакет (base64 binary blob); -- парсит и валидирует формат; -- достает `fromLogin`, поднимает `clientKey` пользователя; -- проверяет подпись Ed25519; -- проверяет анти-replay (time window + nonce); -- отправляет сообщение по правилам маршрутизации; -- пишет результат (messageId, каналы доставки, причины недоставки). - -### 2) Маршрутизация доставки -Для `targetMode=1`: -- если целевая сессия онлайн и ACK пришел вовремя — успех; -- иначе отправка в Web Push этой сессии (если есть subscription). - -Для `targetMode=0`: -- обход всех сессий пользователя; -- сначала online delivery + ACK; -- для непринятых/офлайн — Web Push по соответствующим subscription; -- если subscription отсутствует — тихий skip. - -### 3) Миграция от FCM к Web Push -- добавить конфиг VAPID (`webpush.public.key`, `webpush.private.key`, `webpush.subject`); -- хранить на сервере не только token, а web-push subscription (endpoint + keys); -- сделать отправщик Web Push и заменить/расширить текущий `FcmPushSender`. - -### 4) Безопасность -- строгая ASCII-валидация логинов/sessionId; -- лимиты длины всех полей; -- rate limit на endpoint; -- audit-лог неуспешных проверок подписи/формата; -- защита от replay. - ---- - -## Клиент (shine-UI): что доработать - -1. Перейти на стандартный Web Push flow: - - регистрация service worker; - - `PushManager.subscribe(...)` с VAPID public key; - - отправка subscription на сервер (`UpsertPushSubscription` или расширение `UpsertPushToken`). - -2. Service worker: - - `push` handler получает payload целиком; - - показывает системное уведомление; - - при клике открывает/фокусирует нужный чат. - -3. Online-сообщения: - - сохранить текущий event-канал `IncomingDirectMessage`; - - обязателен ACK (`AckIncomingMessage` уже есть). - -4. Keep-alive: - - UI отправляет `Ping` раз в 60 секунд при активной сессии. - ---- - -## Документация -Сделать отдельный документ настройки Web Push: -- как сгенерировать VAPID ключи; -- какие параметры прописать на сервере и в UI; -- как проверить локально e2e (онлайн + офлайн пуш); -- ограничения payload и рекомендации по ретраям. - ---- - -## Этапы реализации (предложение) -1. Зафиксировать бинарный формат + валидации. -2. Реализовать серверный parser/validator/signature verify/replay guard. -3. Реализовать Web Push sender + storage subscription. -4. Подключить новый endpoint и маршрутизацию доставки. -5. Обновить UI (subscription + service worker + ping timer). -6. Добавить интеграционные тесты (online ACK / offline push / bad signature / replay / oversize). -7. Добавить документацию. - ---- - -## Что нужно уточнить до разработки -1. Endian для `timeMs/nonce/messageType/messageLen` (big-endian или little-endian). -2. Что именно подписывается: строго весь префикс..messageBytes (без подписи) — подтвердить. -3. Диапазон допустимых `messageType`. -4. TTL окна для анти-replay (например 5 минут / 15 минут). -5. Лимиты длин для login/session/message. -6. Можно ли временно оставить FCM как fallback, пока не готов Web Push в проде. -7. Формат сообщения в `messageBytes`: opaque bytes или UTF-8 строка. - - -## Статус реализации (12.04.2026) - -### Что уже внедрено в коде -- `SendDirectMessage` переведён на signed-binary payload (`blobB64`) без обязательной авторизации WS-сессии. -- Внедрён бинарный парсер пакета формата `SHiNE_msg + version(1) + ... + signature64`. -- Проверка подписи Ed25519 делается по `clientKey` отправителя через `shine-server-crypto` (`Ed25519Util`). -- Добавлен anti-replay guard `(from_login, time_ms, nonce)` с TTL 15 минут. -- Добавлено историческое хранилище `signed_direct_messages_history` с сырым пакетом `raw_packet`. -- Логика доставки: сначала WS+ACK, затем fallback на Web Push (по подписке конкретной session). -- Поле типа сообщения переведено на `uint16`, пока поддерживается только `1`. -- Для `targetMode=1` при несуществующей сессии возвращается `success` с `sessionNotFound=true` и `delivered=0`. -- UI переведён с Firebase/FCM на браузерный `PushManager.subscribe` + Service Worker `push`. -- Добавлен keep-alive ping из UI раз в 60 секунд при авторизованной сессии. - -### Что настроить в окружении -- В `application.properties` задать: - - `webpush.vapid.public` - - `webpush.vapid.private` - - `webpush.vapid.subject` -- В `shine-UI/index.html` задать публичный VAPID ключ в `window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__`. - diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..9393aae8 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,86 @@ +# Third-party notices + +This file lists third-party components and assets used by SHiNE. It is a practical attribution file for repository publication; individual dependency artifacts may include additional license text in their own packages. + +## Twemoji + +SHiNE UI uses Twemoji graphics for emoji rendering in the chat emoji picker and emoji-only messages. + +- Source: https://github.com/twitter/twemoji +- Graphics license: Creative Commons Attribution 4.0 International (CC BY 4.0) +- Code license: MIT +- License text: https://creativecommons.org/licenses/by/4.0/ + +Attribution: + +Emoji graphics by Twemoji, licensed under CC BY 4.0. + +Changes in SHiNE: + +- Emoji are stored and sent as normal Unicode text. +- Twemoji SVG graphics are used only as a visual rendering layer in the web UI. +- The UI loads Twemoji SVG assets from the pinned `twitter/twemoji@14.0.2` package via jsDelivr. + +## QR Code Generator + +SHiNE UI includes `shine-UI/js/vendor-qrcode-generator.js`. + +- Project: QR Code Generator for JavaScript +- Copyright: Kazuhiko Arase +- License: MIT +- Source: http://www.d-project.com/ + +## SHiNE project-owned visual assets + +SHiNE logos, icons and generated visual assets in `shine-UI/assets/` and `shine-UI/img/` are project-owned assets, unless a specific file says otherwise. + +## Solana JavaScript libraries + +SHiNE UI and Solana tooling use Solana JavaScript libraries, including `@solana/web3.js`. + +- Package: `@solana/web3.js` +- License: MIT +- Source: https://github.com/solana-foundation/solana-web3.js + +Some Solana JavaScript dependency trees include additional packages under permissive licenses such as MIT, Apache-2.0, BSD, ISC, 0BSD and CC0-1.0. + +Known stricter dependency: + +- Package: `rpc-websockets` +- License: LGPL-3.0-only +- Used transitively through Solana JavaScript dependencies in `shine-solana/shine` and `SHiNE-browser-plugin-wallet`. + +## Noble cryptography libraries + +SHiNE browser wallet/vendor bundles include Noble cryptography code. + +- Packages: `@noble/curves`, `@noble/hashes` +- License: MIT +- Source: https://github.com/paulmillr/noble-curves and https://github.com/paulmillr/noble-hashes + +## Java server dependencies + +The SHiNE Java server uses third-party dependencies from Maven Central, including: + +- Eclipse Jetty (`org.eclipse.jetty:*`) - EPL-2.0 / Apache-2.0 family licensing +- Bouncy Castle (`org.bouncycastle:bcprov-jdk18on`) - Bouncy Castle permissive license +- Jackson (`com.fasterxml.jackson.core:jackson-databind`) - Apache-2.0 +- Logback (`ch.qos.logback:logback-classic`) - EPL-1.0 / LGPL-2.1 +- SLF4J (`org.slf4j:slf4j-api`) - MIT +- SQLite JDBC (`org.xerial:sqlite-jdbc`) - Apache-2.0 +- Web Push Java library (`nl.martijndwars:web-push`) - Apache-2.0 +- JUnit (`org.junit:*`) - EPL-2.0 + +## Gradle Wrapper + +The repository includes Gradle wrapper scripts. + +- License: Apache License 2.0 +- Source: https://gradle.org/ + +## Espressif code snippets + +The ESP32 prototype area includes Espressif audio codec helper files with SPDX/license headers. + +- Files include `ESPRESSIF MIT License` and `SPDX-License-Identifier: Apache-2.0` notices. +- Original copyright notices are preserved in the source files. diff --git a/TODO/2026-06-26_1800_корректное_завершение_за_30с.md b/TODO/2026-06-26_1800_корректное_завершение_за_30с.md index d6e24e9b..c525498b 100644 --- a/TODO/2026-06-26_1800_корректное_завершение_за_30с.md +++ b/TODO/2026-06-26_1800_корректное_завершение_за_30с.md @@ -35,5 +35,5 @@ ## Какие документы потом обновить -- `Deploy/`; -- `Dev_Docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления. +- `deploy/`; +- `docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления. diff --git a/TODO/2026-06-26_1810_подключение_устройств_по_qr.md b/TODO/2026-06-26_1810_подключение_устройств_по_qr.md index 73b4f6bf..2e8419c2 100644 --- a/TODO/2026-06-26_1810_подключение_устройств_по_qr.md +++ b/TODO/2026-06-26_1810_подключение_устройств_по_qr.md @@ -24,6 +24,6 @@ QR-подключение других устройств сейчас есть ## Какие документы потом обновить -- `Dev_Docs/Solana_Architecture/README.md`; +- `docs/Solana_Architecture/README.md`; - `TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md`; - `TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md`. diff --git a/TODO/README.md b/TODO/README.md index 11562752..6d4c72ff 100644 --- a/TODO/README.md +++ b/TODO/README.md @@ -12,16 +12,30 @@ - откуда продолжать; - какие документы потом надо обновить. - Это не активная разработка. Тут только план и контекст. -- Старую папку `Dev_Docs/Future_Features/` считать архивной и больше не использовать как источник новых задач. +- Старую папку `docs/Future_Features/` считать архивной и больше не использовать как источник новых задач. ## Текущие задачи - `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом. -- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - постоянный server-to-server WebSocket, push новых блоков и DM, ACK и backfill. - `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии. - `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений. -## Перенесённые планы из `Dev_Docs/Future_Features/` +## Децентрализация + +Текущий production-режим SHiNE считается односерверным. Задачи по нескольким серверам, Arweave и realtime PDA/Solana sync вынесены в `Децентрализация/` и не блокируют выкладку текущей версии на GitHub. + +- `Децентрализация/односерверный_production_режим.md` - границы текущей production-версии с одним сервером. +- `Децентрализация/запись_блокчейнов_в_arweave.md` - будущая запись/архивация блокчейнов в Arweave. +- `Децентрализация/realtime_pda_solana_sync.md` - будущая онлайн-синхронизация PDA и Solana. +- `Децентрализация/межсерверная_передача_сообщений.md` - будущая доставка сообщений между серверами. +- `Децентрализация/межсерверные_звонки.md` - будущая маршрутизация звонков между серверами. +- `Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - перенесённый старый план постоянного server-to-server WS и DM sync. + +## Новые фишки которые надо доделать + +- `Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/` - отложенная новая контентная модель блокчейна, не входящая в текущий односерверный production-релиз. + +## Перенесённые планы из `docs/Future_Features/` ### near @@ -35,6 +49,7 @@ - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. +- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - подготовить перевод серверной БД с `SQLite` на `PostgreSQL` для более серьёзной конкурентной нагрузки и дальнейшего масштабирования. ### dao_запуск diff --git a/TODO/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md b/TODO/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md deleted file mode 100644 index c98a3ca0..00000000 --- a/TODO/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md +++ /dev/null @@ -1,64 +0,0 @@ -# ESP32 как аппаратный кошелёк (device-сессия) - -## Суть фичи - -ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения. - -## Два ключевых сценария - -### Сценарий 1 — Создание делегированной сессии -1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X» -2. Сервер → ESP32 (device-сессия): «запрос на одобрение» -3. Пользователь нажимает «Да» на сенсорном экране ESP32 -4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера - -### Сценарий 2 — Подпись транзакции / блока -1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это» -2. ESP32 показывает запрос на экране, пользователь подтверждает -3. ESP32 подписывает нужным ключом → ответ через сервер → браузер - -## Что нужно сделать - -### ESP32 (основная работа) -- [ ] Инициализация WiFi (SSID/пароль в NVS) -- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером -- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS -- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу -- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет -- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх) -- [ ] Переподключение при разрыве (reconnect loop) - -### Сервер (минимальные изменения) -- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions` -- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии -- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено) -- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ -- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда - -### Клиент (отдельный этап) -- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest` - -## Что уже готово (переиспользуем) - -- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него. -- **Ed25519 на ESP32** — библиотека `` уже используется в скетче. Подписи работают. -- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко. -- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере. -- **Аутентификация** — схема `AuthChallenge` → `CreateAuthSession` через Ed25519 уже полностью реализована. - -## Оценка сложности - -| Компонент | Сложность | -|---|---| -| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя | -| ESP32: обработчик входящих + UI подтверждений | Средняя | -| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя | -| Браузерное расширение | Высокая (отдельный этап) | - -**Итого фазы ESP32 + сервер: ~1–1.5 недели.** - -## С чего начинать - -1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`. -2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI. -3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают. diff --git a/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md b/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md index 4969ec50..3b328cb8 100644 --- a/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md +++ b/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md @@ -104,9 +104,9 @@ ## Какие документы нужно будет обновить при реализации -- `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд. -- `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции. -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий. +- `docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд. +- `docs/API/` если изменится публичный серверный API или появятся новые операции. +- `docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий. - Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве. ## С какого места продолжать позже diff --git a/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md b/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md index d5098dbe..cc980c7e 100644 --- a/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md +++ b/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md @@ -56,11 +56,9 @@ - Код формирования репоста в `auth-service.js` не удалён: его можно будет использовать как основу при возвращении к задаче. - Код отображения target-полей и перехода к оригиналу не удалён: он нужен для будущей проверки и возможной совместимости с уже созданными тестовыми блоками. -## Почему это не лежит в Pending_Features +## Почему это лежит в TODO -`Dev_Docs/Pending_Features/` предназначена для фич, которые уже реализованы и ждут ручной проверки. - -Репосты сейчас не подходят под этот статус: они не должны проверяться как готовая фича, потому что пользовательский сценарий временно закрыт, а серверная запись новых репостов заблокирована. Поэтому старый pending-файл удалён, а задача перенесена сюда как будущая. +Репосты сейчас не должны проверяться как готовая фича, потому что пользовательский сценарий временно закрыт, а серверная запись новых репостов заблокирована. Поэтому задача остаётся в TODO как будущая. ## Что сделать при возврате к реализации @@ -82,11 +80,11 @@ - отображение `targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`. 7. Добавить или обновить тесты на успешный репост и отказ некорректных target-полей. 8. Обновить документацию: - - `Dev_Docs/Blockchain/11_TEXT_Blocks.md`; - - `Dev_Docs/Blockchain/CHANGELOG.md`; - - `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`; + - `docs/Blockchain/11_TEXT_Blocks.md`; + - `docs/Blockchain/CHANGELOG.md`; + - `docs/API/04_Add_Block_to_Blockchain_API.md`; - документы API чтения каналов/тредов, если изменятся поля ответа. -9. После реализации перенести задачу из `TODO/` в `Dev_Docs/Pending_Features/` как фичу, требующую ручной проверки. +9. После реализации отдельно согласовать ручную проверку пользовательского сценария. ## Минимальный чек-лист ручной проверки в будущем diff --git a/TODO/medium/2026-05-25_1106_shine_balance_wallet.md b/TODO/medium/2026-05-25_1106_shine_balance_wallet.md index 6e35bca1..cb735c8b 100644 --- a/TODO/medium/2026-05-25_1106_shine_balance_wallet.md +++ b/TODO/medium/2026-05-25_1106_shine_balance_wallet.md @@ -47,10 +47,10 @@ ## Документы, которые обновить при реализации -- `Dev_Docs/Blockchain/`, если появятся или изменятся блоки баланса. -- `Dev_Docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат. -- `Dev_Docs/API/`, если меняется серверный API. -- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации. +- `docs/Blockchain/`, если появятся или изменятся блоки баланса. +- `docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат. +- `docs/API/`, если меняется серверный API. +- после реализации отдельно согласовать ручную проверку. - Документацию Solana-регистрации, если баланс будет связан с Solana-модулем. ## Минимальная проверка в будущем diff --git a/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md b/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md index f2b1cb9e..a9b7f457 100644 --- a/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md +++ b/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md @@ -34,10 +34,10 @@ ## Документы, которые нужно обновить при возврате -- `Dev_Docs/Keys/README.md` -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` -- `Dev_Docs/API/` -- `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов. +- `docs/Keys/README.md` +- `docs/Personal_Messages/Протокол_DM_v1.md` +- `docs/API/` +- `docs/Blockchain/`, если появятся новые блоки или команды для файлов. ## С какого места продолжать diff --git a/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md b/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md index 59489884..4b1af4c2 100644 --- a/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md +++ b/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md @@ -84,11 +84,11 @@ ## Что нужно обновить при реализации - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` -- `Dev_Docs/Solana_Architecture/README.md` -- `Dev_Docs/Инициализация_Solana_регистрации/README.md` -- `Dev_Docs/Keys/README.md` -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий -- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы +- `docs/Solana_Architecture/README.md` +- `docs/Инициализация_Solana_регистрации/README.md` +- `docs/Keys/README.md` +- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий +- `docs/API/`, если появятся новые серверные операции или изменятся ответы ## Что пока не делать diff --git a/TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md b/TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md index 61424ab8..12d561b6 100644 --- a/TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md +++ b/TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md @@ -37,9 +37,8 @@ ## Что обновить при возврате -- `Dev_Docs/Pending_Features/README.md` +- после реализации отдельно согласовать ручную проверку - `shine-UI/js/pages/connect-device-view.js` - `shine-UI/js/pages/device-qr-view.js` - `shine-UI/js/services/qr-key-transfer-service.js` - документацию по ключам, если формат переноса меняется - diff --git a/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md new file mode 100644 index 00000000..3be89265 --- /dev/null +++ b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md @@ -0,0 +1,36 @@ +# Переход с SQLite на PostgreSQL + +## Зачем + +Текущая серверная база на `SQLite` удобна для простого односерверного режима, но она хуже подходит для большого числа параллельных записей, роста нагрузки и дальнейшего масштабирования сервера. + +`PostgreSQL` нужен как следующий уровень серверной БД для более надёжной конкурентной записи, более предсказуемой работы под нагрузкой и дальнейшего роста проекта. + +## Что сделать + +- Подготовить план переноса серверной БД с `SQLite` на `PostgreSQL`. +- Найти все места, где код завязан на особенности `SQLite`. +- Проверить все DAO и SQL-запросы на совместимость с `PostgreSQL`. +- Продумать схему миграции существующей production/test базы без потери данных. +- Отдельно проверить транзакции, `UPSERT`, индексы, case-insensitive сравнения и миграции схемы. +- После этого подготовить отдельный этап внедрения и переключения сервера. + +## Что уже есть в коде + +- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту. +- Основная серверная логика уже разделена по модулям. +- Но SQL и миграции сейчас написаны под `SQLite` и потребуют отдельного прохода. + +## Откуда продолжать + +- Начать с инвентаризации всех DAO и схемы БД. +- После этого сделать отдельный документ с оценкой объёма работ по переносу. +- Затем решить, будет ли это: + - полный перевод сервера на `PostgreSQL`; + - или поддержка двух драйверов на переходный период. + +## Что потом обновить + +- Серверную документацию по БД и миграциям. +- Инструкции по локальному запуску сервера. +- Скрипты деплоя и настройки окружения. diff --git a/TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md b/TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md index c4c06a9d..4de664eb 100644 --- a/TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md +++ b/TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md @@ -58,8 +58,8 @@ ## Документы, которые обновить при реализации - Документацию UI/кошельков, если такая есть. -- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации. -- `Dev_Docs/API/`, только если появится новый серверный API или логирование. +- после реализации отдельно согласовать ручную проверку. +- `docs/API/`, только если появится новый серверный API или логирование. ## Минимальная проверка diff --git a/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md b/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md index e1b3eeec..70f6d862 100644 --- a/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md +++ b/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md @@ -69,7 +69,7 @@ git show 0240db5:shine-solana/shine/programs/shine_login_guard/src/lib.rs - `shine-solana/shine/doc/programs/shine_login_guard.md` 5. Архитектурная документация: - - `Dev_Docs/Solana_Architecture/README.md` + - `docs/Solana_Architecture/README.md` 6. UI-логика precheck: - `shine-UI/js/pages/register-view.js` @@ -108,7 +108,7 @@ git show 0240db5:shine-solana/shine/programs/shine_login_guard/src/lib.rs 4. Проверить, что `build.rs` всё ещё генерирует `generated_dictionary.rs` в прежнем формате. 5. Сверить актуальность словарей в `src/dictionaries`. 6. Обновить `shine_login_guard.md` обратно под словарную логику. -7. Обновить `Dev_Docs/Solana_Architecture/README.md`. +7. Обновить `docs/Solana_Architecture/README.md`. ### Проверка после возврата diff --git a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md index 54a734c6..e4b1bcb9 100644 --- a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md +++ b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md @@ -28,6 +28,6 @@ ## Какие документы потом обновить -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории; -- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации; -- при необходимости `Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки. +- `docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории; +- `docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации; +- при необходимости `docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки. diff --git a/TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md b/TODO/Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md similarity index 70% rename from TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md rename to TODO/Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md index 5e59f1af..362db242 100644 --- a/TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md +++ b/TODO/Децентрализация/2026-06-26_1805_межсерверный_ws_и_dm_sync.md @@ -2,7 +2,9 @@ ## Зачем -Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации ещё нужен постоянный межсерверный канал: +Текущий production-режим SHiNE рассчитан на один основной сервер. Межсерверная синхронизация относится к будущей децентрализации и не должна блокировать выкладку односерверной production-версии. + +Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации в будущем ещё нужен постоянный межсерверный канал: - живое подключение к партнёру; - push новых блоков; @@ -35,7 +37,7 @@ ## Какие документы потом обновить -- `Dev_Docs/Blockchain/sync-between-servers.md`; -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`; -- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`; -- `Dev_Docs/API/`. +- `docs/Blockchain/sync-between-servers.md`; +- `docs/Personal_Messages/Протокол_DM_v1.md`; +- `docs/Personal_Messages/Формат_DM_v1.md`; +- `docs/API/`. diff --git a/TODO/Децентрализация/README.md b/TODO/Децентрализация/README.md new file mode 100644 index 00000000..2236815c --- /dev/null +++ b/TODO/Децентрализация/README.md @@ -0,0 +1,16 @@ +# Децентрализация + +Папка для задач, которые нужны для будущего режима с несколькими серверами, Solana/PDA-синхронизацией и внешним хранением данных. + +## Текущий статус + +Сейчас production-режим SHiNE считается односерверным: один сервер обслуживает пользователей, сообщения, звонки и запись данных. Задачи из этой папки не являются блокерами для выкладки текущего репозитория на GitHub и запуска одного production-сервера. + +## Задачи + +- `односерверный_production_режим.md` - зафиксировать границы текущей production-версии. +- `запись_блокчейнов_в_arweave.md` - вынести долговременную запись блокчейнов в Arweave. +- `realtime_pda_solana_sync.md` - сделать онлайн-синхронизацию PDA/Solana в реальном времени. +- `межсерверная_передача_сообщений.md` - реализовать доставку сообщений между серверами. +- `межсерверные_звонки.md` - реализовать маршрутизацию звонков между серверами. +- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - старый план постоянного server-to-server WS и DM sync, перенесённый в контекст децентрализации. diff --git a/TODO/Децентрализация/realtime_pda_solana_sync.md b/TODO/Децентрализация/realtime_pda_solana_sync.md new file mode 100644 index 00000000..cf036690 --- /dev/null +++ b/TODO/Децентрализация/realtime_pda_solana_sync.md @@ -0,0 +1,30 @@ +# Realtime-синхронизация PDA и Solana + +## Зачем + +В будущем PDA-записи и Solana-состояние должны автоматически и быстро синхронизироваться с серверным состоянием, чтобы данные пользователей, homeserver-сессии и связанные записи не расходились. + +## Что сделать + +1. Определить, какие серверные события должны обновлять PDA. +2. Добавить очередь/воркер для надёжной отправки изменений в Solana. +3. Добавить периодическую сверку серверного состояния с PDA. +4. Добавить обработку ошибок, повторов и конфликтов версий. +5. Добавить мониторинг задержек и неуспешных Solana-транзакций. + +## Что учесть + +- Solana/Anchor-модуль находится в `shine-solana/shine/` и ведётся отдельно от основного server/UI deploy. +- Перед изменениями внутри Solana-модуля нужно читать `shine-solana/shine/AGENTS.md`. +- Основная инструкция по Solana-регистрации находится в `docs/Инициализация_Solana_регистрации/README.md`. +- Формат пользовательской PDA-записи описан в `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`. + +## Документы, которые потом нужно обновить + +- `docs/Инициализация_Solana_регистрации/README.md`; +- `docs/Solana_Architecture/README.md`; +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`, если меняется формат PDA. + +## Статус + +Отложено до этапа децентрализации. diff --git a/docs/ИТХ/README.md b/TODO/Децентрализация/ИТХ/README.md similarity index 100% rename from docs/ИТХ/README.md rename to TODO/Децентрализация/ИТХ/README.md diff --git a/docs/ИТХ/Спецификация_ИТХ_v1.md b/TODO/Децентрализация/ИТХ/Спецификация_ИТХ_v1.md similarity index 100% rename from docs/ИТХ/Спецификация_ИТХ_v1.md rename to TODO/Децентрализация/ИТХ/Спецификация_ИТХ_v1.md diff --git a/TODO/Децентрализация/запись_блокчейнов_в_arweave.md b/TODO/Децентрализация/запись_блокчейнов_в_arweave.md new file mode 100644 index 00000000..1d702707 --- /dev/null +++ b/TODO/Децентрализация/запись_блокчейнов_в_arweave.md @@ -0,0 +1,29 @@ +# Запись блокчейнов в Arweave + +## Зачем + +Для будущей децентрализации нужно долговременное внешнее хранение блокчейнов, чтобы данные не зависели только от одного серверного диска. + +## Что сделать + +1. Определить, какие блокчейны и какие диапазоны блоков записываются в Arweave. +2. Зафиксировать формат пачки блоков, метаданных, ссылок и контрольных хэшей. +3. Добавить безопасный механизм публикации без хранения приватного JWK в git. +4. Добавить проверку уже загруженных диапазонов, чтобы не плодить дубли. +5. Описать восстановление блокчейна из Arweave при потере локальных данных. + +## Важные ограничения + +- Любое изменение формата блокчейна требует отдельного предупреждения и явного подтверждения пользователя. +- Добавление данных в блокчейн должно выполняться только через `AddBlock`. +- Секреты Arweave нельзя хранить в репозитории. + +## Документы, которые потом нужно обновить + +- `docs/Blockchain/README.md`; +- `docs/Blockchain/CHANGELOG.md`; +- документы deploy/секретов в `deploy/`, если появятся новые параметры. + +## Статус + +Отложено до этапа децентрализации. diff --git a/TODO/Децентрализация/межсерверная_передача_сообщений.md b/TODO/Децентрализация/межсерверная_передача_сообщений.md new file mode 100644 index 00000000..6f45c7b0 --- /dev/null +++ b/TODO/Децентрализация/межсерверная_передача_сообщений.md @@ -0,0 +1,30 @@ +# Межсерверная передача сообщений + +## Зачем + +Когда у SHiNE появится несколько серверов, пользователи на разных серверах должны получать личные сообщения без ручной синхронизации и без привязки к одному центральному узлу. + +## Что сделать + +1. Определить протокол server-to-server доставки DM. +2. Добавить маршрутизацию получателя по серверу, user id, публичному ключу или PDA. +3. Добавить ACK, повторы, дедупликацию и backfill пропущенных сообщений. +4. Разделить realtime-доставку и восстановление истории. +5. Описать поведение при недоступности удалённого сервера. + +## Что учесть + +- Логика DM должна соответствовать документам в `docs/Personal_Messages/`. +- При изменении формата signed DM-блока или правил доставки нужно обновлять протокол и байтовый формат DM. +- Если появятся новые server API/WebSocket операции, нужно обновить `docs/API/`. + +## Документы, которые потом нужно обновить + +- `docs/Personal_Messages/Протокол_DM_v1.md`; +- `docs/Personal_Messages/Формат_DM_v1.md`; +- `docs/API/`; +- `docs/API/09_Operations_Index.md`, если добавляются новые `op`. + +## Статус + +Отложено до этапа децентрализации. diff --git a/TODO/Децентрализация/межсерверные_звонки.md b/TODO/Децентрализация/межсерверные_звонки.md new file mode 100644 index 00000000..0bae8762 --- /dev/null +++ b/TODO/Децентрализация/межсерверные_звонки.md @@ -0,0 +1,29 @@ +# Межсерверные звонки + +## Зачем + +В будущем пользователи на разных серверах должны иметь возможность устанавливать звонки так же, как пользователи одного сервера. + +## Что сделать + +1. Определить протокол межсерверной сигнализации звонков. +2. Добавить маршрутизацию offer/answer/ICE-кандидатов между серверами. +3. Добавить обработку статусов занятости, отказа, таймаута и ошибок маршрута. +4. Добавить диагностику доставки сигналов между серверами. +5. Проверить совместимость с текущими логами `CallDeliveryReport`. + +## Что учесть + +- Специальная диагностика установки звонков идёт через `CallDeliveryReport`. +- На production важно сохранять поля `reason`, `failureStage`, `pcConnectionState`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`. +- Межсерверные звонки не должны ломать текущий односерверный сценарий. + +## Документы, которые потом нужно обновить + +- `docs/API/`, если добавляются или меняются операции сигнализации; +- документы по звонкам/диагностике, если они будут выделены отдельно; +- deploy-документы, если появятся новые параметры TURN/server-to-server маршрутизации. + +## Статус + +Отложено до этапа децентрализации. diff --git a/TODO/Децентрализация/односерверный_production_режим.md b/TODO/Децентрализация/односерверный_production_режим.md new file mode 100644 index 00000000..c0cb9718 --- /dev/null +++ b/TODO/Децентрализация/односерверный_production_режим.md @@ -0,0 +1,23 @@ +# Односерверный production-режим + +## Зачем + +Перед выкладкой репозитория на GitHub и запуском production нужно явно зафиксировать, что текущая стабильная версия работает как один основной сервер. + +## Что считаем текущей нормой + +- Один production-сервер обслуживает пользователей, сообщения, звонки и серверные данные. +- Децентрализованные сценарии не считаются обязательными для первого production-релиза. +- Межсерверная доставка сообщений, межсерверные звонки, realtime PDA/Solana sync и запись блокчейнов в Arweave вынесены в отдельные будущие задачи. +- Код и документация текущего production не должны создавать ожидание, что несколько серверов уже работают как единая realtime-сеть. + +## Что сделать перед возвратом к децентрализации + +1. Проверить актуальные документы по API, blockchain, DM и deploy. +2. Выделить минимальный протокол server-to-server взаимодействия. +3. Решить, какие данные остаются локальными, какие реплицируются между серверами, а какие записываются во внешнее долговременное хранилище. +4. После изменения API, blockchain-форматов или DM-протокола обновить соответствующие документы по правилам проекта. + +## Статус + +Отложено. Текущий production работает как один сервер. diff --git a/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/01_Презентация_для_людей.md b/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/01_Презентация_для_людей.md new file mode 100644 index 00000000..5a1d35a2 --- /dev/null +++ b/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/01_Презентация_для_людей.md @@ -0,0 +1,275 @@ +# Новая логика контента в блокчейне SHiNE + +## Зачем это нужно + +Сейчас блокчейн SHiNE хорошо умеет хранить обычные сообщения, ответы, лайки и связи между людьми. + +Новая модель добавляет поверх этого более понятный смысл контента: + +- обычный текст; +- упражнение; +- услуга / процедура; +- курс; +- стартовая страница канала (`entrypoint`). + +Это нужно для того, чтобы канал стал не просто лентой постов, а полноценным пространством знаний, практик, услуг и сообществ. + +## Что меняется для людей + +### 1. В канале появятся понятные виды материалов + +Сообщение можно будет создать не только как обычный текст, но и как: + +- упражнение; +- услугу / процедуру; +- курс; +- стартовую страницу канала. + +Смысл в том, что приложение и сервер будут понимать, что это за материал, а не просто показывать любой текст одинаково. + +### 2. У канала будет стартовая страница + +У канала появится отдельное стартовое сообщение `entrypoint`. + +Это не курс и не оглавление, а именно главная точка входа в канал: + +- короткое объяснение, о чём канал; +- описание структуры; +- ссылки на нужные материалы; +- удобное начало для новых людей. + +У канала в каждый момент времени будет только одна актуальная стартовая страница. +Если её исправляют, то сохраняется история версий. +Если её удаляют, для интерфейса считается, что стартовой страницы у канала сейчас нет. + +### 3. Курс, упражнение и услуга / процедура будут отличаться по смыслу + +Это важно для логики и статистики. + +- `Упражнение` — то, что человек может делать много раз. +- `Услуга / процедура` — то, что тоже можно проходить много раз, но обычно с участием другого человека. +- `Курс` — то, что можно начать, закончить или бросить. + +За счёт этого сервер сможет честно считать активность, а интерфейс сможет показывать человеку именно те действия, которые подходят к данному типу материала. + +### 4. Появятся статусные действия + +На контент можно будет не только ответить или поставить лайк, но и отметить свой путь: + +- сделал один раз; +- заинтересовался и рассматривает; +- начал; +- закончил / освоил / знаю; +- бросил. + +При этом: + +- для упражнений и услуг / процедур будет отдельно считаться, сколько раз человек сделал / прошёл; +- для упражнений и курсов будет храниться текущий статус. + +Текущий статус определяется просто: + +- последнее статусное действие и считается актуальным. + +Например: + +- если последнее действие “заинтересовался и рассматривает”, значит человек присматривается, но ещё не начал; +- если последнее действие `started`, значит материал сейчас в процессе; +- если последнее действие `abandoned`, значит человек бросил; +- если последнее действие `completed`, значит для системы он завершил / освоил материал. + +### 5. К действиям можно добавлять живой текст + +Практически любое статусное действие можно будет сопровождать коротким комментарием. + +Например: + +- “Начал изучать, потому что давно хотел разобраться”; +- “Бросил, пока нет времени”; +- “Прошёл процедуру, стало заметно легче”. + +Это важно, потому что сам блокчейн будет хранить не только формальный статус, но и живую человеческую причину или заметку. + +### 6. Появится подтверждение статуса другими людьми + +Отдельный человек сможет подтвердить чей-то статус. + +Примеры: + +- подтвердить, что человек действительно занимался; +- подтвердить, что он реально прошёл услугу; +- подтвердить, что он освоил материал. + +Подтверждение — это не замена статуса, а отдельное мнение / свидетельство со стороны. + +### 7. Появится отдельный тип «мнение» + +На любое сообщение можно будет ответить не только обычным ответом, но и специальным типом ответа: `мнение`. + +Это по сути тоже текстовый ответ, но с отдельным смыслом: + +- это отзыв; +- это оценка; +- это мнение о материале; +- это явная метка для будущего анализа нейронками. + +То есть: + +- обычный ответ нужен для разговора; +- `мнение` нужно для отзыва, оценки и анализа реакции людей. + +## Что остаётся как раньше + +### Комментарии + +Обычные ответы на сообщения остаются. +То есть обсуждение материалов не ломается и не меняется концептуально. + +### Лайки контента + +Лайк на сообщение, курс, упражнение или услугу остаётся обычной реакцией на конкретный блок. + +### Лайк пользователю + +Лайк пользователю не будет считаться реакцией на сообщение. +Он относится к графу связей между людьми. + +Это удобно, потому что: + +- лайк человека — это отношение к человеку; +- лайк материала — это отношение к контенту. + +## Сообщество вокруг канала + +Канал сможет работать не только как лента, но и как сообщество. + +Для этого появятся простые действия: + +- заявка на вступление; +- самостоятельный выход; +- принятие; +- исключение. + +Сервер сможет понимать: + +- кто только подал заявку; +- кто уже принят; +- кто вышел; +- кто был исключён. + +## Личный канал и лента достижений + +У каждого человека по смыслу появляется два важных пространства: + +- канал его обычных постов; +- отдельная лента его тренировок и достижений. + +В обычном канале человек сможет: + +- писать посты; +- делиться мыслями; +- публиковать материалы; +- обсуждать темы как раньше. + +А в ленте достижений будут видны его реальные действия: + +- какие упражнения он делал; +- какие услуги / процедуры проходил; +- какие курсы его заинтересовали; +- какие курсы он начал; +- какие курсы он закончил; +- что он бросил. + +То есть блокчейн SHiNE сможет хранить не только слова человека, но и его путь, активность и историю практики. + +## Что смогут делать авторы контента + +Создатели контента в своих каналах смогут публиковать не только обычные посты, но и: + +- упражнения; +- курсы; +- стартовую страницу канала; +- услуги / процедуры, которые они оказывают. + +Это превращает канал в сочетание: + +- блога; +- базы знаний; +- пространства обучения; +- каталога услуг и практик. + +## Что увидит человек в интерфейсе + +На специальных сообщениях в UI можно будет показывать отдельные кнопки действий. + +Например: + +- `Выполнил упражнение` +- `Прошёл процедуру` +- `Заинтересовало` +- `Начал курс` +- `Закончил курс` + +То есть материал можно будет не просто прочитать, а сразу отметить реальное действие. + +Также при ответе на любое сообщение можно будет выбрать: + +- обычный ответ; +- `мнение / отзыв`. + +## Как будет работать лента достижений + +Если кто-то зайдёт в твою ленту достижений, он сможет: + +- прочитать, что ты делал; +- оставить мнение / отзыв; +- подтвердить, что это действительно было. + +Это даёт основу для мягкой “сертификации” внутри SHiNE. + +Например: + +- человек прошёл курс и получил подтверждения; +- человек прошёл процедуру и получил отзыв; +- человек регулярно делает упражнения, и это видно в его истории. + +Так постепенно у пользователя появляется не только лента постов, но и лента достижений, подтверждений и репутации. + +## Ссылки внутри SHiNE + +Для переходов между материалами вводятся простые внутренние адреса: + +- обычная ссылка: `SHiNE/alice-001/157` +- особополная ссылка: `SHiNE/alice-001/157/ХЭШ` + +Первая форма — основная и каноническая. +Вторая нужна там, где хочется добавить ещё и точную проверку по хэшу. + +## Что это даёт в итоге + +После внедрения новая блокчейн-логика позволит: + +- строить каналы как структурированные пространства, а не просто как поток постов; +- выделять упражнения, услуги и курсы как отдельные сущности; +- показывать стартовую страницу канала; +- хранить путь человека по материалу; +- хранить отдельную ленту его действий и достижений; +- считать активность и статусы; +- подтверждать результаты другими людьми; +- развивать сообщество вокруг канала. + +И самое важное: всё это можно добавить как расширение уже существующего блокчейна SHiNE, не разрушая старую модель сообщений. + +## Отдельный вопрос для будущего + +Отзывы о людях как о людях — полезная идея, но её стоит дополнительно обдумать. + +Например, на вкладке связей в будущем можно: + +- писать человеку отзыв; +- смотреть все отзывы о человеке; +- выводить сначала отзывы близких друзей, родственников, друзей и контактов, а уже потом остальные. + +Но этот слой нужно делать осторожно, чтобы он не стал слишком жёстким или неприятным для людей. + +Поэтому отзывы о людях как отдельная социальная механика требуют дополнительного обсуждения и проектирования. diff --git a/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/02_ТЗ_новых_типов_блоков.md b/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/02_ТЗ_новых_типов_блоков.md new file mode 100644 index 00000000..6fafb893 --- /dev/null +++ b/TODO/Новые фишки которые надо доделать/Новая_контентная_модель_блокчейна/02_ТЗ_новых_типов_блоков.md @@ -0,0 +1,670 @@ +# ТЗ: новая контентная модель блокчейна SHiNE + +## Статус документа + +Этот документ описывает предлагаемые новые типы блоков и правила их обработки. + +Цель: + +- добавить новую семантику контента; +- не ломать существующие блоки `type=0..4`; +- внедрить всё как расширение блокчейна за счёт новых форматов. + +Документ является проектным ТЗ на реализацию в сервере, БД, API чтения и UI. + +## 1. Базовые принципы + +### 1.1. Совместимость + +Старые типы не меняются: + +- `type=0` — TECH +- `type=1` — TEXT +- `type=2` — REACTION +- `type=3` — CONNECTION +- `type=4` — USER_PARAM + +Новые сущности и действия добавляются только как новые `type` и новые `body`. + +Это означает: + +- старые блоки продолжают читаться как раньше; +- старые `TEXT_POST`, `TEXT_REPLY`, `REACTION_LIKE` и остальные форматы не ломаются; +- существующий блокчейн остаётся валидным; +- новый функционал появляется только там, где клиент и сервер умеют его понимать. + +### 1.2. Общая стратегия + +Новая модель делится на четыре слоя: + +1. контентные сущности; +2. текстовые отзывы и мнения; +3. статусные действия пользователей; +4. community-события вокруг канала. + +### 1.3. Редактирование и удаление + +Для новых контентных сущностей сохраняется действующий принцип SHiNE: + +- редактирование всегда ссылается на оригинальный блок; +- тип сущности edit не меняет; +- удаление выполняется через `edit` с пустым текстом; +- отдельный `DELETE`-подтип не вводится. + +Это правило особенно важно для: + +- `plain_text` +- `exercise` +- `service` +- `course` +- `entrypoint` + +В пользовательских текстах и UI желательно использовать русские названия: + +- обычный текст; +- упражнение; +- услуга / процедура; +- курс; +- стартовое сообщение канала. + +## 2. Канонические внутренние ссылки + +В новой модели поддерживаются только две формы внутренней ссылки: + +- каноническая: `SHiNE//` +- особополная: `SHiNE///` + +Примеры: + +- `SHiNE/alice-001/157` +- `SHiNE/alice-001/157/abcd1234...` + +Правила: + +- канонической считается именно короткая форма без хэша; +- форма с хэшем используется как усиленный вариант для точной проверки; +- внутри UI и серверной логики ссылка должна приводиться как минимум к паре: + - `blockchainName` + - `blockNumber` +- если хэш присутствует, он участвует в дополнительной валидации ссылки. + +## 3. Новые контентные сущности + +## 3.1. Новый `type=5` — `CONTENT` + +Назначение: + +- хранение новых смысловых материалов канала; +- сохранение линии канала; +- поддержка edit-версий и логического удаления. + +### 3.1.1. Подтипы `CONTENT` + +- `subType=10` — `CONTENT_PLAIN` +- `subType=11` — `CONTENT_EDIT_PLAIN` +- `subType=20` — `CONTENT_EXERCISE` +- `subType=21` — `CONTENT_EDIT_EXERCISE` +- `subType=30` — `CONTENT_SERVICE` +- `subType=31` — `CONTENT_EDIT_SERVICE` +- `subType=40` — `CONTENT_COURSE` +- `subType=41` — `CONTENT_EDIT_COURSE` +- `subType=50` — `CONTENT_ENTRYPOINT` +- `subType=51` — `CONTENT_EDIT_ENTRYPOINT` + +### 3.1.2. Семантика подтипов + +- `CONTENT_PLAIN` — обычный текст нового поколения. +- `CONTENT_EXERCISE` — упражнение, которое можно выполнять многократно. +- `CONTENT_SERVICE` — услуга / процедура, которую можно проходить многократно. +- `CONTENT_COURSE` — курс / оглавление. +- `CONTENT_ENTRYPOINT` — стартовое сообщение канала. + +### 3.1.3. Почему `entrypoint` отдельный тип + +`entrypoint` не считается курсом. + +Это отдельная сущность, потому что: + +- она описывает вход в канал; +- по ней нельзя делать `started / completed / abandoned`; +- у канала в каждый момент времени должна быть только одна актуальная стартовая страница. + +### 3.1.4. Ограничение на `entrypoint` + +Для одного канала допускается только один исходный блок `CONTENT_ENTRYPOINT`. + +Правила: + +- если entrypoint уже существует, создать второй нельзя; +- изменять можно только через `CONTENT_EDIT_ENTRYPOINT`; +- если entrypoint логически удалён, UI должен считать, что стартовой страницы больше нет; +- исторический блок при этом остаётся в цепочке. + +### 3.1.5. Формат body для `CONTENT_*` + +Для `version=1` рекомендуется использовать формат, максимально совместимый по логике с текущими `TEXT_POST` / `TEXT_EDIT_POST`. + +#### Создающие блоки + +Для: + +- `CONTENT_PLAIN` +- `CONTENT_EXERCISE` +- `CONTENT_SERVICE` +- `CONTENT_COURSE` +- `CONTENT_ENTRYPOINT` + +body: + +```text +ContentLineBody_v1 +- lineCode: int32 +- prevLineNumber: int32 +- prevLineHash32: [32] +- thisLineNumber: int32 +- textLenBytes: uint16 +- text UTF-8 +``` + +#### Edit-блоки + +Для: + +- `CONTENT_EDIT_PLAIN` +- `CONTENT_EDIT_EXERCISE` +- `CONTENT_EDIT_SERVICE` +- `CONTENT_EDIT_COURSE` +- `CONTENT_EDIT_ENTRYPOINT` + +body: + +```text +ContentEditBody_v1 +- lineCode: int32 +- prevLineNumber: int32 +- prevLineHash32: [32] +- thisLineNumber: int32 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- textLenBytes: uint16 +- text UTF-8 +``` + +Правила: + +- edit всегда ссылается на оригинальный блок соответствующего типа; +- `toBlockchainName` в edit не хранится; +- `textLen=0` означает логическое удаление содержимого; +- тип исходной сущности edit не меняет. + +### 3.1.6. Что считается комментарием + +Комментарии не требуют нового формата. + +Для обсуждения новых контентных сущностей продолжают использоваться уже существующие: + +- `TEXT_REPLY` +- `TEXT_EDIT_REPLY` + +Это позволяет не ломать старую reply-механику и reuse текущую модель тредов. + +## 4. Текстовые отзывы + +## 4.1. Новый `type=6` — `TEXT_RATING` + +Назначение: + +- текстовая оценка / отзыв на объект; +- без числовой шкалы; +- с возможностью редактирования и логического удаления. + +Смысл `TEXT_RATING`: + +- это текст; +- это специальный отзыв / мнение / оценка; +- это явный сигнал, что перед нами не просто комментарий, а осмысленный отзыв; +- в будущем это поле можно отдельно анализировать нейронками. + +### 4.1.1. Подтипы + +- `subType=10` — `TEXT_RATING_POST` +- `subType=11` — `TEXT_RATING_EDIT` + +### 4.1.2. Где разрешён `TEXT_RATING_POST` + +Разрешён на target: + +- `HEADER` пользователя; +- контентный блок `type=5`; +- при необходимости в будущем — на другие target-блоки по отдельному решению. + +Сейчас в данном ТЗ: + +- отзыв / оценка на пользователя — да; +- отзыв / оценка на контент — да; +- отзыв / лайк на канал целиком — не вводится, только оставляется как будущая возможность. + +### 4.1.3. Где и как используется `TEXT_RATING_POST` + +`TEXT_RATING_POST` можно создавать: + +- как отзыв на контентный блок; +- как отзыв на пользователя через target на `HEADER`; +- как специальный ответ вместо обычного комментария. + +Практическое правило для UI: + +- при ответе на любое сообщение пользователь может выбрать: + - обычный ответ; + - `мнение / отзыв`. + +### 4.1.4. Формат body + +#### Создание + +```text +TextRatingBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- textLenBytes: uint16 +- text UTF-8 +``` + +#### Редактирование + +```text +TextRatingEditBody_v1 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- textLenBytes: uint16 +- text UTF-8 +``` + +Правила: + +- edit ссылается на оригинальный `TEXT_RATING_POST`; +- пустой текст в edit означает логическое удаление отзыва. + +## 5. Статусные действия и накопительные события + +## 5.1. Новый `type=7` — `STATUS_ACTION` + +Назначение: + +- хранение действий пользователя по отношению к контенту; +- вычисление текущего статуса; +- накопительный учёт повторных прохождений; +- подтверждение статусов другими людьми. + +### 5.1.1. Подтипы + +- `subType=10` — `STATUS_DONE_ONCE` +- `subType=20` — `STATUS_INTERESTED` +- `subType=30` — `STATUS_STARTED` +- `subType=40` — `STATUS_COMPLETED` +- `subType=50` — `STATUS_ABANDONED` +- `subType=60` — `STATUS_CONFIRMED` + +### 5.1.2. Матрица допустимости по контенту + +`STATUS_DONE_ONCE` разрешён только для: + +- `CONTENT_EXERCISE` +- `CONTENT_SERVICE` + +`STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED` разрешены только для: + +- `CONTENT_EXERCISE` +- `CONTENT_COURSE` + +`CONTENT_ENTRYPOINT` не поддерживает: + +- `interested` +- `started` +- `completed` +- `abandoned` + +### 5.1.3. Как считать текущее состояние + +Для пары: + +- `actorLogin` +- `targetBlock` + +актуальным статусом считается последнее по времени статусное событие из набора: + +- `STATUS_INTERESTED` +- `STATUS_STARTED` +- `STATUS_COMPLETED` +- `STATUS_ABANDONED` + +Следствия: + +- у одного пользователя по одному объекту в каждый момент времени только один актуальный статус; +- если последним пришёл `interested`, статус считается “заинтересовался / рассматривает, но ещё не начал”; +- если последним пришёл `started`, статус считается “в процессе”; +- если последним пришёл `completed`, статус считается “завершён / освоен / знаю”; +- если последним пришёл `abandoned`, статус считается “брошен”. + +### 5.1.4. Как считать количество прохождений + +`STATUS_DONE_ONCE` не меняет текущий статус. + +Он считается отдельно как накопительное событие. + +Сервер должен уметь считать: + +- сколько раз пользователь сделал упражнение; +- сколько раз пользователь прошёл услугу / процедуру. + +### 5.1.5. Дополнительный текст действия + +Каждое действие `STATUS_*` может содержать дополнительный текст-комментарий. + +Примеры: + +- как именно делал упражнение; +- чем заинтересовал курс; +- с какими мыслями начал курс; +- почему бросил; +- что именно подтверждает подтверждающий человек. + +### 5.1.6. Подтверждение статуса + +`STATUS_CONFIRMED` разрешён только на target-статусы: + +- `STATUS_DONE_ONCE` +- `STATUS_INTERESTED` +- `STATUS_STARTED` +- `STATUS_COMPLETED` +- `STATUS_ABANDONED` + +Это значит: + +- подтверждение не ставится прямо на курс или упражнение; +- подтверждение ставится на конкретный статусный блок другого человека. + +Подтверждение: + +- не меняет основной статус автора; +- не меняет счётчик `done_once`; +- хранится как отдельное мнение / свидетельство. + +### 5.1.7. Формат body + +Для `STATUS_DONE_ONCE`, `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_COMPLETED`, `STATUS_ABANDONED`: + +```text +StatusActionBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- noteLenBytes: uint16 +- note UTF-8 +``` + +Для `STATUS_CONFIRMED`: + +```text +StatusConfirmBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- noteLenBytes: uint16 +- note UTF-8 +``` + +На уровне бинарного формата тело можно оставить одинаковым. +Различие задаётся `subType` и правилами валидации target. + +## 6. Community-события + +## 6.1. Новый `type=8` — `COMMUNITY_EVENT` + +Назначение: + +- заявки в сообщество; +- выход из сообщества; +- принятие; +- исключение. + +### 6.1.1. Подтипы + +- `subType=10` — `COMMUNITY_JOIN_REQUEST` +- `subType=20` — `COMMUNITY_LEAVE` +- `subType=30` — `COMMUNITY_ACCEPT` +- `subType=40` — `COMMUNITY_REMOVE` + +### 6.1.2. Базовая логика + +`COMMUNITY_JOIN_REQUEST` + +- создаёт пользователь; +- target — `CONTENT_ENTRYPOINT` канала; +- может содержать текст заявки. + +`COMMUNITY_LEAVE` + +- создаёт сам участник; +- target — `CONTENT_ENTRYPOINT` канала; +- подтверждение не требуется; +- может содержать текст. + +`COMMUNITY_ACCEPT` + +- создаёт владелец канала; +- target — конкретный блок `COMMUNITY_JOIN_REQUEST`; +- может содержать текст. + +`COMMUNITY_REMOVE` + +- создаёт владелец канала; +- target — `CONTENT_ENTRYPOINT` канала; +- body дополнительно хранит `subjectLogin`, кого исключили; +- может содержать текст. + +### 6.1.3. Текущее членство + +Пользователь считается текущим участником сообщества, если: + +- у него есть хотя бы одно принятие в это сообщество; +- после этого принятия нет более позднего: + - `COMMUNITY_LEAVE` + - `COMMUNITY_REMOVE` + +Заявка сама по себе членство не создаёт. + +### 6.1.4. Формат body + +Для `JOIN_REQUEST` и `LEAVE`: + +```text +CommunityActionBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- noteLenBytes: uint16 +- note UTF-8 +``` + +Для `ACCEPT`: + +```text +CommunityAcceptBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- noteLenBytes: uint16 +- note UTF-8 +``` + +Для `REMOVE`: + +```text +CommunityRemoveBody_v1 +- toBlockchainNameLen: uint8 +- toBlockchainName UTF-8 +- toBlockGlobalNumber: int32 +- toBlockHash32: [32] +- subjectLoginLen: uint8 +- subjectLogin ASCII +- noteLenBytes: uint16 +- note UTF-8 +``` + +## 7. Что остаётся на старых типах + +### 7.0. Обычный канал и лента достижений + +На уровне продукта рекомендуется различать: + +- обычный канал постов пользователя; +- отдельную ленту его действий и достижений. + +В обычном канале пользователь: + +- пишет посты; +- публикует материалы; +- общается и обсуждает. + +В ленте достижений видны события: + +- какие упражнения он делал; +- какие услуги / процедуры проходил; +- какие курсы его заинтересовали; +- какие курсы он начал; +- какие курсы он завершил; +- что он бросил. + +В данном ТЗ эта модель фиксируется как продуктовая логика. +Конкретный способ хранения можно реализовать: + +- либо отдельным специальным каналом; +- либо отдельным режимом чтения по статусным блокам. + +### 7.1. Лайк пользователю + +Лайк пользователю не вводится как `REACTION`. + +Он остаётся в слое социальных связей: + +- через `CONNECTION` +- как будущий отдельный подтип связи + +В этом ТЗ сам новый подтип связи не описывается детально. +Нужно только зафиксировать правило: + +- лайк человека относится к графу связей, а не к реакции на блок. + +### 7.2. Лайк контента + +Лайк на: + +- `CONTENT_PLAIN` +- `CONTENT_EXERCISE` +- `CONTENT_SERVICE` +- `CONTENT_COURSE` +- `CONTENT_ENTRYPOINT` + +может использовать уже существующий: + +- `REACTION_LIKE` +- `REACTION_UNLIKE` + +Отдельный новый формат для лайка контента не нужен. + +### 7.3. Канал целиком + +В текущем ТЗ не вводятся: + +- отзыв на канал целиком; +- лайк канала целиком. + +Это оставляется как будущая возможность. + +### 7.4. Отзывы о людях + +Отзывы о человеке как о человеке в текущем ТЗ допустимы через `TEXT_RATING` на `HEADER`. + +Но продуктовую модель их показа нужно отдельно продумать. + +Направление для будущего: + +- просмотр отзывов о человеке на вкладке связей; +- приоритетный вывод отзывов от близких друзей, родственников, друзей и контактов; +- затем вывод остальных отзывов. + +Эта тема полезна, но требует дополнительной осторожной проработки с точки зрения UX и социальных рисков. + +## 8. Требования к серверу + +Сервер после внедрения должен уметь: + +1. Валидировать новые `type=5..8`. +2. Хранить новые блоки без ломки старого чтения. +3. Определять текущий статус пользователя по объекту: + - `interested` + - `started` + - `completed` + - `abandoned` +4. Считать накопительные события `done_once` для: + - `exercise` + - `service` +5. Считать подтверждения статусов. +6. Определять единственный актуальный `entrypoint` канала. +7. Определять текущее членство в сообществе канала. +8. Поддерживать внутренние ссылки вида: + - `SHiNE//` + - `SHiNE///` + +## 9. Требования к UI + +UI после внедрения должен уметь: + +1. Показывать разные карточки для: + - текста + - упражнения + - услуги + - курса + - entrypoint +2. Показывать стартовую страницу канала, если `entrypoint` существует. +3. Не показывать entrypoint, если он логически удалён. +4. Давать человеку только допустимые действия по типу материала. +5. Показывать: + - текущий статус; + - количество `done_once`; + - подтверждения статуса. +6. Показывать отдельные действия-кнопки на специальных блоках, например: + - `Выполнил упражнение` + - `Прошёл процедуру` + - `Заинтересовало` + - `Начал курс` + - `Закончил курс` +7. При ответе на сообщение давать выбор: + - обычный ответ; + - `мнение / отзыв`. +8. Открывать внутренние ссылки SHiNE. + +## 10. Вывод по совместимости + +Предлагаемая модель реализуема без слома старого блокчейна. + +Причина: + +- старые `type=0..4` не меняются; +- новые сущности вводятся только как новые `type=5..8`; +- существующие `reply`, `like`, `edit`, `HEADER`, `CREATE_CHANNEL` и `CONNECTION` продолжают работать как раньше; +- старые клиенты смогут игнорировать новые типы как неизвестные; +- новые клиенты смогут постепенно включать поддержку нового функционала. + +Итог: + +- это расширение формата блокчейна; +- это не миграция со сломом старых блоков; +- это можно внедрять поэтапно. diff --git a/TODO_Будущие_доработки.md b/TODO_Будущие_доработки.md deleted file mode 100644 index df15b60f..00000000 --- a/TODO_Будущие_доработки.md +++ /dev/null @@ -1,41 +0,0 @@ -# TODO: Будущие доработки - -## 1) Полный переход на `ReceiveOutcomingMessage` -- Сейчас в UI используется `ReceiveOutcomingMessage` с fallback на `SendMessagePair`. -- Fallback нужен только временно для совместимости со старыми серверами. -- После обновления всех серверов: -- убрать вызов `SendMessagePair` из UI, -- убрать регистрацию `SendMessagePair` на сервере (оставить только `ReceiveOutcomingMessage`). - -## 2) Реальная мультисерверная доставка -- Сейчас фактически предполагается 1 сервер на пользователя. -- Нужно реализовать штатную мультисерверную схему: -- пересылка исходящих сообщений между серверами пользователя A, -- пересылка входящих сообщений между серверами пользователя B, -- дедупликация на уровне БД для затухания дублей. - -## 3) Надёжная доставка при перезапуске сервера -- Сейчас возможен сценарий: запись уже сохранена в БД, но сервер не успел переслать дальше из-за перезапуска. -- Нужно добавить механизм «store + guaranteed forward»: -- очередь/аутбокс для межсерверной пересылки, -- фоновый ретрай до подтверждения отправки, -- корректная остановка (graceful shutdown) с дожатием критичных задач. - -## 4) Политика идемпотентности -- Сохранить принцип: пара (`incoming`, `outgoing`) пишется одной транзакцией, либо обе, либо ни одной. -- Не допускать частичного состояния, при котором в БД есть только один блок пары. - -## 5) Наблюдаемость и аналитика -- Добавить метрики по доставке: -- количество дублей, -- количество успешных вставок пар, -- доля доставок в WS/push, -- количество ретраев межсерверной пересылки. - -## 6) Ограничение текущих звонков (важно) -- Сейчас звонки работают только в рамках одного сигнального сервера (или единого контура, где обе стороны уже подключены). -- Сценарий «пользователь A на своих серверах, пользователь B на других серверах» пока не поддержан. -- TODO на будущее: -- временная межсерверная авторизация/сессия для старта звонка, -- отправка сигнальных сообщений между разными серверами пользователей, -- аккуратное завершение временной сессии после установления/завершения звонка. diff --git a/TODO_Звонки_межсерверность.md b/TODO_Звонки_межсерверность.md deleted file mode 100644 index cfd29f0a..00000000 --- a/TODO_Звонки_межсерверность.md +++ /dev/null @@ -1,41 +0,0 @@ -# TODO: Звонки и межсерверность - -## Текущее ограничение -- Текущая реализация звонков фактически работает в одном сигнальном контуре (один сервер/единый кластер, где обе стороны уже присутствуют). -- Если пользователь A подключён к серверу A, а пользователь B к серверу B (и между ними нет общего сигнального слоя), `CallInviteBroadcast`/`CallSignalToSession` не смогут полноценно провести звонок между ними. - -## Почему так сейчас -- Сигналинг звонка привязан к активным сессиям и событиям на конкретном сервере. -- Выбор целевой сессии (`sessionId`) и обмен `OFFER/ANSWER/ICE` происходит в рамках текущего сигнального контура. -- Push решает только «разбудить/уведомить», но не заменяет межсерверный сигнальный канал. - -## Что можно сделать дальше -- Добавить временное межсерверное подключение именно для старта и ведения звонка: -- инициатор получает short-lived access на сервер callee (или через доверенный межсерверный gateway), -- в рамках короткой сессии отправляет invite/signal для конкретного `callId`, -- после завершения звонка временная сессия закрывается автоматически. - -## Что нужно доработать для этого -1. Межсерверная доверенная модель: -- подпись/верификация межсерверных вызовов, -- allowlist доверенных серверов и ротация ключей. - -2. Короткоживущая «call-only» авторизация: -- отдельный тип токена/сессии с TTL (например 1–3 минуты), -- минимальные права только на `CallInviteBroadcast/CallSignalToSession`. - -3. Маршрутизация сессий пользователя между серверами: -- где находится активная сессия callee, -- как доставлять `stop_call` и terminal-сигналы на все устройства callee. - -4. Идемпотентность и дедупликация: -- защита от повторов межсерверных сигналов по `callId + eventId`, -- корректная обработка out-of-order событий. - -5. Наблюдаемость: -- метрики межсерверной доставки сигналов, -- диагностика по стадиям звонка и причинам срыва. - -## Временный рабочий подход (до межсерверности) -- Держать звонки в одном сигнальном контуре. -- Использовать WebPush как fallback-уведомление (`incoming_call`/`stop_call`) для офлайн-сессий. diff --git a/VERSION.properties b/VERSION.properties index 3be4f4b0..3cb97578 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.327 -server.version=1.2.298 +client.version=1.2.346 +server.version=1.2.318 diff --git a/build.gradle b/build.gradle index 7d9532eb..8014732a 100644 --- a/build.gradle +++ b/build.gradle @@ -185,60 +185,6 @@ tasks.named('build') { finalizedBy tasks.named('integrationTest') } -tasks.register('deployServerProduction', JavaExec) { - group = "!!deployment" - description = "Production deploy: build → upload to shineup.me → restart service (только после явного подтверждения)" - - classpath = sourceSets.test.runtimeClasspath - mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain" - workingDir = file('SHiNE-server') - - dependsOn shadowJar - systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me") - systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player") - systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server") - systemProperty "it.service", System.getProperty("it.service", "shine-server") - systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar") - - dependsOn testClasses -} - -tasks.register('deployUIProduction', Exec) { - group = "!!deployment" - description = "Production UI deploy: shineup.me (только после явного подтверждения)" - workingDir = rootDir - commandLine 'bash', file('deploy_shine-ui_production_shineupme.sh').absolutePath -} - -tasks.register('deployServer', Exec) { - group = "!!deployment" - description = "Default deploy server: server2.shineup.me" - dependsOn shadowJar - workingDir = rootDir - environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath - commandLine 'bash', file('deploy_shine-server_test2.sh').absolutePath -} - -tasks.register('deployUI', Exec) { - group = "!!deployment" - description = "Default deploy UI: server2.shineup.me" - workingDir = rootDir - commandLine 'bash', file('deploy_shine-ui_production_server2shineupme.sh').absolutePath -} - -tasks.register('deployServerTest2') { - group = "!!deployment" - description = "Явный алиас второго production deploy server: server2.shineup.me" - dependsOn tasks.named('deployServer') -} - -tasks.register('deployUITest2') { - group = "!!deployment" - description = "Явный алиас второго production deploy UI: server2.shineup.me" - dependsOn tasks.named('deployUI') -} - - tasks.register('startLocal', Exec) { group = "!!run" description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing" diff --git a/codex-agent-VPS/.env.example b/codex-agent-VPS/.env.example deleted file mode 100644 index 9b15d6d0..00000000 --- a/codex-agent-VPS/.env.example +++ /dev/null @@ -1,31 +0,0 @@ -TELEGRAM_BOT_TOKEN=replace_me -OPENAI_API_KEY= -ALLOWED_TELEGRAM_USERNAME=owner_username -ALLOWED_TELEGRAM_PLAYERS=user_one:User One,user_two:User Two -ALLOWED_TELEGRAM_CHANNEL_USERNAME= -BOT_USERNAME=your_bot_username -TELEGRAM_API_BASE_URL=https://api.telegram.org -OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe -TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 -OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 -OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES=25165824 -OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS=900 -OPENAI_TRANSCRIBE_OVERLAP_SECONDS=2 -OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS=24 -OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS=1800 -FFMPEG_BIN=ffmpeg -FFPROBE_BIN=ffprobe -OPENAI_TTS_MODEL=gpt-4o-mini-tts -OPENAI_TTS_VOICE=alloy -OPENAI_TTS_RESPONSE_FORMAT=opus -OPENAI_TTS_TIMEOUT_SECONDS=180 -OPENAI_TTS_CHUNK_CHARS=3500 -OPENAI_VOICE_REWRITE_MODEL=gpt-4.1-nano -OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS=90 -OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS=12000 -OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS=900 -CODEX_BIN=/home/your_user/.local/bin/codex -CODEX_WORKDIR=/home/your_user -CODEX_TIMEOUT_SECONDS=900 -MAX_RETRIES=3 -DATA_DIR=./data diff --git a/codex-agent-VPS/.gitignore b/codex-agent-VPS/.gitignore deleted file mode 100644 index 2af457e8..00000000 --- a/codex-agent-VPS/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -data/ -logs/ -run/ -__pycache__/ diff --git a/codex-agent-VPS/AGENTS.md b/codex-agent-VPS/AGENTS.md deleted file mode 100644 index 770e488a..00000000 --- a/codex-agent-VPS/AGENTS.md +++ /dev/null @@ -1,86 +0,0 @@ -# AGENTS - -## Назначение -- `codex-agent-VPS` — переносимая версия Telegram-бота для запуска `codex` CLI на VPS. -- Папку можно ставить в любое место на Linux-сервере, если там есть `python3`, `systemd`, `codex` и доступ в интернет. -- Конфигурация делается через `.env`. - -## Состав папки -- `README.md` — краткое описание структуры. -- `Agent-server-package/` — готовый набор файлов для копирования на VPS. -- `.env.example` — пример конфигурации. -- `AGENTS.md` — инструкция по установке и настройке. - -## Требования к VPS -- Linux-сервер с `systemd`. -- Установленные `python3`, `curl`, `ffmpeg`. -- Установленный `codex` CLI. -- Выполненный `codex login` под тем пользователем, от которого будет работать сервис. -- Telegram bot token. -- Telegram usernames разрешённых пользователей. - -## Установка через Codex -1. Скопировать содержимое `Agent-server-package/` на сервер в нужное место, например: - - `/home/your_user/codex-agent` -2. Установить `codex` CLI под рабочим пользователем. -3. Выполнить под этим же пользователем: - - `codex login` -4. Установить системные зависимости: - - `python3` - - `ffmpeg` -5. Взять `.env.example` из корня `codex-agent-VPS` и создать на сервере `.env`. -6. В `.env` заполнить: - - `TELEGRAM_BOT_TOKEN` - - `ALLOWED_TELEGRAM_USERNAME` - - `ALLOWED_TELEGRAM_PLAYERS` - - `BOT_USERNAME` - - `CODEX_BIN` - - `CODEX_WORKDIR` -7. Если нужны voice/audio и голосовые ответы, дополнительно задать: - - `OPENAI_API_KEY` -8. В `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service` заменить: - - `your_user` - - `/home/your_user/codex-agent` - на реальные значения. -9. Скопировать unit в: - - `/etc/systemd/system/shine-agent-bot-coder.service` -10. Выполнить: - - `sudo systemctl daemon-reload` - - `sudo systemctl enable --now shine-agent-bot-coder` -11. Проверить: - - `sudo systemctl status shine-agent-bot-coder --no-pager` - - `sudo journalctl -u shine-agent-bot-coder -f` - -## Настройка доступа -- `ALLOWED_TELEGRAM_USERNAME` — основной разрешённый пользователь. -- `ALLOWED_TELEGRAM_PLAYERS` — дополнительные разрешённые пользователи: - - `username1:Имя 1,username2:Имя 2` -- Все пользователи из whitelist в этой версии считаются полноправными. -- Все входящие задачи попадают в одну общую очередь и выполняются строго последовательно. - -## Поведение агента -- Бот принимает текст, voice и audio. -- Для каждого пользователя ведётся отдельная история. -- Все задачи запускаются через `codex exec`. -- Рабочая директория задаётся через `CODEX_WORKDIR`. -- Вызов идёт без sandbox/approval ограничений: `--dangerously-bypass-approvals-and-sandbox`. - -## Что обычно меняют при переносе -- `.env` -- `Agent-server-package/scripts/systemd/shine-agent-bot-coder.service` -- при необходимости `Agent-server-package/AGENT.md` - -## Полезные команды -- Проверка установки Codex: - - `codex --version` - - `codex doctor` -- Self-test без Telegram: - - `python3 py_bot_service.py --selftest-codex "Ответь одной строкой: Codex работает"` -- Проверка сервиса: - - `sudo systemctl status shine-agent-bot-coder --no-pager` - - `sudo journalctl -u shine-agent-bot-coder -f` - -## Примечания -- Если `codex doctor` пишет, что credentials не найдены, нужно выполнить `codex login`. -- Если `OPENAI_API_KEY` пустой, текстовые задачи через `codex` будут работать, а voice/audio и TTS-функции — нет. -- Если у пользователя в Telegram нет username, whitelist по username его не пропустит. diff --git a/codex-agent-VPS/Agent-server-package/.env.example b/codex-agent-VPS/Agent-server-package/.env.example deleted file mode 100644 index 9b15d6d0..00000000 --- a/codex-agent-VPS/Agent-server-package/.env.example +++ /dev/null @@ -1,31 +0,0 @@ -TELEGRAM_BOT_TOKEN=replace_me -OPENAI_API_KEY= -ALLOWED_TELEGRAM_USERNAME=owner_username -ALLOWED_TELEGRAM_PLAYERS=user_one:User One,user_two:User Two -ALLOWED_TELEGRAM_CHANNEL_USERNAME= -BOT_USERNAME=your_bot_username -TELEGRAM_API_BASE_URL=https://api.telegram.org -OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe -TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300 -OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900 -OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES=25165824 -OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS=900 -OPENAI_TRANSCRIBE_OVERLAP_SECONDS=2 -OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS=24 -OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS=1800 -FFMPEG_BIN=ffmpeg -FFPROBE_BIN=ffprobe -OPENAI_TTS_MODEL=gpt-4o-mini-tts -OPENAI_TTS_VOICE=alloy -OPENAI_TTS_RESPONSE_FORMAT=opus -OPENAI_TTS_TIMEOUT_SECONDS=180 -OPENAI_TTS_CHUNK_CHARS=3500 -OPENAI_VOICE_REWRITE_MODEL=gpt-4.1-nano -OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS=90 -OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS=12000 -OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS=900 -CODEX_BIN=/home/your_user/.local/bin/codex -CODEX_WORKDIR=/home/your_user -CODEX_TIMEOUT_SECONDS=900 -MAX_RETRIES=3 -DATA_DIR=./data diff --git a/codex-agent-VPS/Agent-server-package/AGENT.md b/codex-agent-VPS/Agent-server-package/AGENT.md deleted file mode 100644 index 8831ff17..00000000 --- a/codex-agent-VPS/Agent-server-package/AGENT.md +++ /dev/null @@ -1,51 +0,0 @@ -# AGENT.md для codex-agent-VPS - -Ты запущен как обработчик входящего Telegram-сообщения от пользователя. - -## Контекст -- `codex-agent-VPS` — Telegram-бот, который принимает сообщения, ведёт историю, ставит задачи в очередь и последовательно запускает `codex` CLI на VPS. -- Текстовые сообщения обрабатываются напрямую. -- Voice и audio сначала распознаются через OpenAI transcription, затем передаются как текстовая задача. -- История диалога хранится в JSONL-файле, путь передаётся в промпте. -- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение. -- Основная реализация сервиса — Python-скрипт `py_bot_service.py`. - -## Пользователи и доступ -- Разрешённые пользователи задаются через `ALLOWED_TELEGRAM_USERNAME` и `ALLOWED_TELEGRAM_PLAYERS`. -- Все разрешённые пользователи считаются полноправными. -- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом. -- Все входящие задачи попадают в одну общую очередь и выполняются строго по одной. - -## Очередь и состояние -- Сервис ведёт состояние активной задачи и текущего файла истории. -- После рестарта сервис продолжает незавершённую обработку с учётом сохранённого состояния. -- Истории диалогов хранятся отдельно по username: `data/history//`. -- Архив истории после `/new`: `data/history//archive/`. -- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume. -- Дедупликация Telegram update обязательна, чтобы одно сообщение не обрабатывалось повторно. -- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус и повторяет его каждые 2 минуты. - -## Голосовые ответы -- Озвучивание финальных ответов настраивается персонально командами `/voice_on` и `/voice_off`. -- Для новых пользователей озвучивание включено по умолчанию. -- Адаптация текста перед озвучкой настраивается командами `/voice_rewrite_on` и `/voice_rewrite_off`. -- Если озвучивание включено, после текстового финального ответа сервис дополнительно отправляет voice-файл через OpenAI TTS. -- Промежуточные статусы озвучивать не нужно. - -## Команды -- `/status` — состояние очереди и персональных настроек. -- `/settings` — текущие пользовательские настройки. -- `/queue` — список задач в очереди. -- `/tasks` — список задач и предложений пользователя. -- `/new` — архивировать историю и начать новую Codex-сессию. -- `/stop` — остановить текущую задачу. -- `/cancel ` — удалить задачу по id или очистить очередь. -- `/restart` и `/restart_service` — отложенный рестарт после текущей задачи. -- `/restart_hard`, `/restart_now`, `/restart_force` — жёсткий рестарт прямо сейчас. - -## Правила ответа -- Пиши содержательно и коротко. -- Не упоминай внутренние служебные детали, файловую систему и технические логи, если это не нужно пользователю. -- Если запрос требует действий с кодом или файлами, выполняй их в рабочей директории `CODEX_WORKDIR`. -- Если данных недостаточно, задай ровно один уточняющий вопрос. -- Если в промпте есть пометка retry, учитывай текущее состояние и продолжай аккуратно, а не начинай заново без причины. diff --git a/codex-agent-VPS/Agent-server-package/py_bot_service.py b/codex-agent-VPS/Agent-server-package/py_bot_service.py deleted file mode 100644 index e96dee36..00000000 --- a/codex-agent-VPS/Agent-server-package/py_bot_service.py +++ /dev/null @@ -1,2960 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import fcntl -import json -import mimetypes -import os -import random -import re -import shutil -import string -import subprocess -import tempfile -import threading -import time -import traceback -import uuid -from pathlib import Path -from typing import Any, Callable -from urllib import error, request - -DEFAULT_ALLOWED_PLAYERS = ",".join([ - "user_one:User One", - "user_two:User Two", -]) - -TASK_STATUS_LABELS = { - "new": "новая", - "approved": "одобрена", - "rejected": "отклонена", - "needs_work": "на доработку", - "done": "сделана", -} - - -def now_iso() -> str: - return dt.datetime.now(dt.timezone.utc).isoformat() - - -def normalize_username(value: str | None) -> str: - if not value: - return "" - value = value.strip() - if value.startswith("@"): - value = value[1:] - 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 compact_spaces(text: str) -> str: - return re.sub(r"\s+", " ", (text or "").strip()) - - -def split_long_text(text: str, chunk_size: int = 3500) -> list[str]: - text = (text or "").strip() - if not text: - return ["(пустой ответ)"] - return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] - - -def split_final_private_text(text: str, first_chunk_size: int = 3900, second_chunk_size: int = 3900) -> list[str]: - text = (text or "").strip() - if not text: - return ["(пустой ответ)"] - if len(text) <= first_chunk_size: - return [text] - first = text[:first_chunk_size].rstrip() - rest = text[first_chunk_size:].lstrip() - if len(rest) <= second_chunk_size: - return [first, rest] - second = rest[:second_chunk_size].rstrip() - if len(second) > 40: - second = second[:-40].rstrip() - second = second.rstrip() + "\n...[ответ обрезан]" - return [first, second] - - -def split_text_for_tts(text: str, chunk_size: int) -> list[str]: - text = (text or "").strip() - if not text: - return [] - chunks: list[str] = [] - current = "" - paragraphs = re.split(r"\n\s*\n", text) - for paragraph in paragraphs: - paragraph = paragraph.strip() - if not paragraph: - continue - if len(paragraph) > chunk_size: - if current: - chunks.append(current) - current = "" - for i in range(0, len(paragraph), chunk_size): - part = paragraph[i:i + chunk_size].strip() - if part: - chunks.append(part) - continue - candidate = paragraph if not current else f"{current}\n\n{paragraph}" - if len(candidate) <= chunk_size: - current = candidate - else: - if current: - chunks.append(current) - current = paragraph - if current: - chunks.append(current) - return chunks - - -def read_env_file(path: Path) -> dict[str, str]: - result: dict[str, str] = {} - if not path.exists(): - return result - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - result[key] = value - 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 VoiceReplyError(RuntimeError): - pass - - -class JsonLineStore: - @staticmethod - def load(path: Path) -> list[dict[str, Any]]: - if not path.exists(): - return [] - items: list[dict[str, Any]] = [] - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - items.append(json.loads(line)) - return items - - @staticmethod - def save(path: Path, items: list[dict[str, Any]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - with tmp.open("w", encoding="utf-8") as f: - for item in items: - f.write(json.dumps(item, ensure_ascii=False) + "\n") - tmp.replace(path) - - @staticmethod - def append(path: Path, item: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(item, ensure_ascii=False) + "\n") - - -class TelegramApi: - def __init__(self, token: str, base_url: str = "https://api.telegram.org"): - self.token = token - self.api_root = (base_url or "https://api.telegram.org").rstrip("/") - self.base = f"{self.api_root}/bot{token}/" - self.file_base = f"{self.api_root}/file/bot{token}/" - - def call(self, method: str, payload: dict[str, Any] | None = None, timeout: int = 60) -> dict[str, Any]: - data = None - headers = {} - if payload is not None: - data = json.dumps(payload).encode("utf-8") - headers["Content-Type"] = "application/json" - req = request.Request(self.base + method, data=data, headers=headers, method="POST") - try: - with request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode("utf-8") - except error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Telegram HTTP {e.code}: {body}") from e - except Exception as e: - raise RuntimeError(f"Telegram request failed: {e}") from e - - result = json.loads(raw) - if not result.get("ok"): - raise RuntimeError(f"Telegram API error: {result}") - return result - - def call_multipart( - self, - method: str, - fields: dict[str, Any], - files: dict[str, tuple[str, bytes, str]], - timeout: int = 120, - ) -> dict[str, Any]: - boundary = "----shine-tg-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) - body = bytearray() - for name, value in fields.items(): - if value is None: - continue - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n\r\n' - f"{value}\r\n" - ).encode("utf-8") - ) - for name, (filename, data, mime) in files.items(): - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' - f"Content-Type: {mime}\r\n\r\n" - ).encode("utf-8") - ) - body.extend(data) - body.extend(b"\r\n") - body.extend(f"--{boundary}--\r\n".encode("utf-8")) - - req = request.Request(self.base + method, data=bytes(body), method="POST") - req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") - try: - with request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode("utf-8") - except error.HTTPError as e: - body_text = e.read().decode("utf-8", errors="replace") - raise RuntimeError(f"Telegram HTTP {e.code}: {body_text}") from e - except Exception as e: - raise RuntimeError(f"Telegram multipart request failed: {e}") from e - - result = json.loads(raw) - if not result.get("ok"): - raise RuntimeError(f"Telegram API error: {result}") - return result - - def get_updates(self, offset: int | None, timeout_sec: int) -> list[dict[str, Any]]: - payload: dict[str, Any] = {"timeout": timeout_sec, "allowed_updates": ["message", "channel_post"]} - if offset is not None: - payload["offset"] = offset - result = self.call("getUpdates", payload=payload, timeout=timeout_sec + 15) - return result.get("result", []) - - 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 - return self.call("sendMessage", payload=payload, timeout=30) - - def edit_message_text(self, chat_id: int | str, message_id: int, text: str) -> dict[str, Any]: - payload: dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text} - return self.call("editMessageText", 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 send_voice_upload( - self, - chat_id: int | str, - voice_bytes: bytes, - filename: str, - caption: str = "", - reply_to_message_id: int | None = None, - ) -> dict[str, Any]: - fields: dict[str, Any] = {"chat_id": chat_id} - if caption: - fields["caption"] = caption - if reply_to_message_id is not None: - fields["reply_to_message_id"] = reply_to_message_id - return self.call_multipart( - "sendVoice", - fields=fields, - files={"voice": (filename, voice_bytes, "audio/ogg")}, - timeout=180, - ) - - def delete_webhook(self) -> None: - self.call("deleteWebhook", payload={"drop_pending_updates": False}, timeout=30) - - -class BotConfig: - def __init__(self, root_dir: Path): - env = dict(os.environ) - env.update(read_env_file(root_dir / ".env")) - - 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.telegram_api_base_url = env.get("TELEGRAM_API_BASE_URL", "https://api.telegram.org").strip() or "https://api.telegram.org" - 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.openai_transcribe_max_upload_bytes = max(1_000_000, int(env.get("OPENAI_TRANSCRIBE_MAX_UPLOAD_BYTES", str(24 * 1024 * 1024)))) - self.openai_transcribe_max_chunk_seconds = max(60, int(env.get("OPENAI_TRANSCRIBE_MAX_CHUNK_SECONDS", "900"))) - self.openai_transcribe_overlap_seconds = max(0, int(env.get("OPENAI_TRANSCRIBE_OVERLAP_SECONDS", "2"))) - self.openai_transcribe_reencode_bitrate_kbps = max(12, int(env.get("OPENAI_TRANSCRIBE_REENCODE_BITRATE_KBPS", "24"))) - self.openai_transcribe_ffmpeg_timeout_seconds = max(30, int(env.get("OPENAI_TRANSCRIBE_FFMPEG_TIMEOUT_SECONDS", "1800"))) - self.ffmpeg_bin = env.get("FFMPEG_BIN", "ffmpeg").strip() or "ffmpeg" - self.ffprobe_bin = env.get("FFPROBE_BIN", "ffprobe").strip() or "ffprobe" - self.openai_tts_model = env.get("OPENAI_TTS_MODEL", "gpt-4o-mini-tts") - self.openai_tts_voice = env.get("OPENAI_TTS_VOICE", "alloy") - self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") - self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) - self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) - self.openai_voice_rewrite_model = env.get("OPENAI_VOICE_REWRITE_MODEL", "gpt-4.1-nano") - self.openai_voice_rewrite_timeout_seconds = int(env.get("OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS", "90")) - self.openai_voice_rewrite_max_input_chars = max(1000, int(env.get("OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS", "12000"))) - self.openai_voice_rewrite_max_output_tokens = max(200, int(env.get("OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS", "900"))) - self.codex_bin = Path(env.get( - "CODEX_BIN", - str(Path.home() / ".local/bin/codex") - )) - self.codex_workdir = Path(env.get("CODEX_WORKDIR", str(Path.home()))) - self.codex_timeout_seconds = int(env.get("CODEX_TIMEOUT_SECONDS", "900")) - self.max_retries = max(1, int(env.get("MAX_RETRIES", "3"))) - self.data_dir = (root_dir / env.get("DATA_DIR", "./data")).resolve() - self.agent_instructions_file = (root_dir / "AGENT.md").resolve() - - @staticmethod - def _required(env: dict[str, str], key: str) -> str: - value = env.get(key, "").strip() - if not value: - raise RuntimeError(f"Не задан обязательный параметр: {key}") - return value - - -class ShinePyBotService: - def __init__(self, config: BotConfig): - self.cfg = config - self.telegram = TelegramApi(config.telegram_bot_token, config.telegram_api_base_url) - - self.queue_file = config.data_dir / "py_queue.jsonl" - self.state_file = config.data_dir / "py_state.json" - self.processed_updates_file = config.data_dir / "py_processed_updates.log" - self.lock_file = config.data_dir / "py_app.lock" - self.history_dir = config.data_dir / "history" - self.history_archive_dir = self.history_dir / "archive" - self.task_center_dir = config.data_dir / "task_center" - self.task_center_file = self.task_center_dir / "items.json" - self.max_processed_updates = 5000 - - self.queue_lock = threading.RLock() - self.task_center_lock = threading.RLock() - self.stop_event = threading.Event() - self.worker = threading.Thread(target=self._worker_loop, name="shine-py-bot-worker", daemon=True) - - self.queue: list[dict[str, Any]] = [] - self.state: dict[str, Any] = {} - self.processed_updates: list[str] = [] - self.active_job_id: str | None = None - self.active_job_started_at: float | None = None - self.active_process: subprocess.Popen[str] | None = None - self.active_process_lock = threading.Lock() - self.stop_current_job = False - self.lock_fd = None - self.last_heartbeat_at: float = 0.0 - self.restart_requested = False - - def _is_owner(self, username: str) -> bool: - return self._is_allowed_user(username) - - def _is_allowed_player(self, username: str) -> bool: - return False - - 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 _display_name(self, username: str) -> str: - uname = normalize_username(username) - if uname == self.cfg.allowed_username: - return "Айдар" - return self._player_name(uname) - - def _known_usernames(self) -> dict[str, str]: - users = {self.cfg.allowed_username: "Айдар"} - users.update(self.cfg.allowed_players) - return users - - def _find_user_by_text(self, text: str) -> str: - source = normalize_username(text) - if source in self._known_usernames(): - return source - source_lower = (text or "").strip().lower() - aliases = { - "айдар": self.cfg.allowed_username, - "айдару": self.cfg.allowed_username, - "айдара": self.cfg.allowed_username, - "милана": "malvviiina", - "милане": "malvviiina", - "милану": "malvviiina", - "миланы": "malvviiina", - "сергей": "zodiaktechnika32", - "сергею": "zodiaktechnika32", - "сергея": "zodiaktechnika32", - "иван": "oidasyda", - "ивану": "oidasyda", - "ивана": "oidasyda", - "ворон": "blackbyrd1", - "ворону": "blackbyrd1", - "ворона": "blackbyrd1", - "дима": "dimasol1", - "диме": "dimasol1", - "диму": "dimasol1", - "димы": "dimasol1", - } - for alias, username in aliases.items(): - if re.search(rf"(^|\W){re.escape(alias)}($|\W)", source_lower, flags=re.IGNORECASE): - return username - for username, name in self._known_usernames().items(): - if username and username in source_lower: - return username - if name and name.lower() in source_lower: - return username - return "" - - def run(self) -> None: - self._ensure_dirs() - self._acquire_single_instance_lock() - self._load_state() - self._load_queue() - self._load_processed_updates() - self._recover_active_jobs_after_restart() - self.telegram.delete_webhook() - self._init_offset_if_missing() - self.worker.start() - self._append_history_event("service_started", {"allowedUsername": self.cfg.allowed_username}) - print(f"[py-bot] Запущен. allowed user: @{self.cfg.allowed_username}", flush=True) - - try: - while not self.stop_event.is_set(): - try: - offset = self.state.get("offset") - updates = self.telegram.get_updates(offset=offset, timeout_sec=25) - except Exception as e: - print(f"[py-bot] Ошибка getUpdates: {e}", flush=True) - time.sleep(2) - continue - - for update in updates: - update_id = update.get("update_id") - if isinstance(update_id, int): - self.state["offset"] = update_id + 1 - self._persist_state() - self._handle_update(update) - finally: - self.shutdown() - - def shutdown(self) -> None: - if self.stop_event.is_set(): - pass - self.stop_event.set() - self._stop_active_codex_process() - if self.worker.is_alive(): - self.worker.join(timeout=10) - if self.lock_fd is not None: - try: - fcntl.flock(self.lock_fd, fcntl.LOCK_UN) - finally: - self.lock_fd.close() - self.lock_fd = None - self._append_history_event("service_stopped", {}) - - def _ensure_dirs(self) -> None: - self.cfg.data_dir.mkdir(parents=True, exist_ok=True) - self.history_dir.mkdir(parents=True, exist_ok=True) - self.history_archive_dir.mkdir(parents=True, exist_ok=True) - self.task_center_dir.mkdir(parents=True, exist_ok=True) - - def _acquire_single_instance_lock(self) -> None: - self.lock_file.parent.mkdir(parents=True, exist_ok=True) - self.lock_fd = self.lock_file.open("a+") - try: - fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise RuntimeError(f"Уже запущен другой инстанс (lock: {self.lock_file})") - - def _load_state(self) -> None: - if self.state_file.exists(): - 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 - user_settings = self.state.get("user_settings") - if not isinstance(user_settings, dict): - user_settings = {} - self.state["user_settings"] = user_settings - if not self.state.get("current_history_file"): - 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() - self._persist_state() - - def _persist_state(self) -> None: - self.state["updated_at"] = now_iso() - tmp = self.state_file.with_suffix(".tmp") - tmp.write_text(json.dumps(self.state, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(self.state_file) - - def _load_queue(self) -> None: - self.queue = JsonLineStore.load(self.queue_file) - - def _persist_queue(self) -> None: - JsonLineStore.save(self.queue_file, self.queue) - - def _load_processed_updates(self) -> None: - if not self.processed_updates_file.exists(): - self.processed_updates = [] - return - lines = [x.strip() for x in self.processed_updates_file.read_text(encoding="utf-8").splitlines() if x.strip()] - if len(lines) > self.max_processed_updates: - lines = lines[-self.max_processed_updates:] - self.processed_updates_file.write_text("\n".join(lines) + "\n", encoding="utf-8") - self.processed_updates = lines - - def _mark_processed_update(self, update_key: str) -> bool: - if update_key in self.processed_updates: - return True - self.processed_updates.append(update_key) - if len(self.processed_updates) > self.max_processed_updates: - self.processed_updates = self.processed_updates[-self.max_processed_updates:] - self.processed_updates_file.write_text("\n".join(self.processed_updates) + "\n", encoding="utf-8") - else: - with self.processed_updates_file.open("a", encoding="utf-8") as f: - f.write(update_key + "\n") - return False - - def _recover_active_jobs_after_restart(self) -> None: - recovered_ids: list[str] = [] - for job in self.queue: - if job.get("status") == "active": - job["status"] = "pending" - job["retry_reason"] = "service_restart_recovery" - job["updated_at"] = now_iso() - recovered_ids.append(job.get("id", "")) - if recovered_ids: - self._persist_queue() - self._append_history_event("active_jobs_recovered", {"jobIds": recovered_ids}) - - def _init_offset_if_missing(self) -> None: - if self.state.get("offset") is not None: - return - try: - updates = self.telegram.get_updates(offset=None, timeout_sec=0) - if updates: - self.state["offset"] = int(updates[-1]["update_id"]) + 1 - else: - self.state["offset"] = 0 - self._persist_state() - except Exception as e: - print(f"[py-bot] Не удалось инициализировать offset: {e}", flush=True) - self.state["offset"] = 0 - self._persist_state() - - def _current_history_file(self) -> Path: - return self._current_history_file_for_user(self.cfg.allowed_username) - - 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 - self._user_settings(uname) - 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 _user_session_state(self, username: str) -> dict[str, Any]: - 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) - if not isinstance(session, dict): - session = {} - sessions[uname] = session - return session - - def _current_history_file_for_user(self, username: str) -> Path: - session = self._user_session_state(username) - return Path(session["current_history_file"]) - - def _codex_thread_id_for_user(self, username: str) -> str: - thread_id = (self._user_session_state(username).get("codex_thread_id") or "").strip() - return thread_id - - def _set_codex_thread_id_for_user(self, username: str, thread_id: str) -> None: - session = self._user_session_state(username) - normalized = (thread_id or "").strip() - if normalized: - session["codex_thread_id"] = normalized - else: - session.pop("codex_thread_id", None) - self._persist_state() - - 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)) - 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: - 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 = archive_dir / current.name - current.replace(archived) - else: - 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 - previous = sessions.get(uname) if isinstance(sessions.get(uname), dict) else {} - sessions[uname] = {"current_history_file": str(new_file)} - if reason != "command_new" and isinstance(previous, dict): - thread_id = (previous.get("codex_thread_id") or "").strip() - if thread_id: - sessions[uname]["codex_thread_id"] = thread_id - 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": uname, "archived": str(archived)}, username=uname) - return archived - - def _user_settings(self, username: str) -> dict[str, Any]: - uname = normalize_username(username) or self.cfg.allowed_username - settings = self.state.get("user_settings") - if not isinstance(settings, dict): - settings = {} - self.state["user_settings"] = settings - user_settings = settings.get(uname) - if not isinstance(user_settings, dict): - user_settings = {} - settings[uname] = user_settings - if not isinstance(user_settings.get("voice_replies_enabled"), bool): - user_settings["voice_replies_enabled"] = True - if not isinstance(user_settings.get("voice_rewrite_enabled"), bool): - user_settings["voice_rewrite_enabled"] = True - if not isinstance(user_settings.get("single_status_message_enabled"), bool): - user_settings["single_status_message_enabled"] = True - return user_settings - - def _voice_replies_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("voice_replies_enabled")) - - def _set_voice_replies_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["voice_replies_enabled"] = enabled - self._persist_state() - - def _voice_rewrite_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("voice_rewrite_enabled")) - - def _set_voice_rewrite_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["voice_rewrite_enabled"] = enabled - self._persist_state() - - def _single_status_message_enabled(self, username: str) -> bool: - return bool(self._user_settings(username).get("single_status_message_enabled")) - - def _set_single_status_message_enabled(self, username: str, enabled: bool) -> None: - self._user_settings(username)["single_status_message_enabled"] = enabled - self._persist_state() - - def _remember_private_chat(self, username: str, chat_id: int) -> None: - uname = normalize_username(username) - if not uname: - return - private_chats = self.state.get("private_chat_ids") - if not isinstance(private_chats, dict): - private_chats = {} - self.state["private_chat_ids"] = private_chats - if private_chats.get(uname) == chat_id: - return - private_chats[uname] = chat_id - self._persist_state() - - def _private_chat_id_for_user(self, username: str) -> int | None: - private_chats = self.state.get("private_chat_ids") - if not isinstance(private_chats, dict): - return None - chat_id = private_chats.get(normalize_username(username)) - return self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else None - - def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: - row = {"ts": now_iso(), "type": event_type} - row.update(payload) - JsonLineStore.append(history_path, row) - - 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 _load_task_items(self) -> list[dict[str, Any]]: - with self.task_center_lock: - if not self.task_center_file.exists(): - return [] - try: - data = json.loads(self.task_center_file.read_text(encoding="utf-8")) - except Exception: - return [] - return data if isinstance(data, list) else [] - - def _save_task_items(self, items: list[dict[str, Any]]) -> None: - with self.task_center_lock: - self.task_center_file.parent.mkdir(parents=True, exist_ok=True) - tmp = self.task_center_file.with_suffix(".tmp") - tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(self.task_center_file) - - def _next_task_item_id(self, items: list[dict[str, Any]]) -> str: - max_num = 0 - for item in items: - raw = str(item.get("id") or "") - match = re.match(r"TC-(\d+)$", raw) - if match: - max_num = max(max_num, int(match.group(1))) - return f"TC-{max_num + 1:04d}" - - def _create_task_item( - self, - *, - kind: str, - title: str, - text: str, - source_username: str, - target_username: str, - source_message_id: int | None = None, - source_chat_id: int | None = None, - opinion: str = "", - ) -> dict[str, Any]: - items = self._load_task_items() - item = { - "id": self._next_task_item_id(items), - "kind": kind, - "status": "new", - "title": compact_spaces(title)[:160] or ("Предложение" if kind == "proposal" else "Задача"), - "text": (text or "").strip(), - "opinion": (opinion or "").strip(), - "source_username": normalize_username(source_username), - "source_name": self._display_name(source_username), - "target_username": normalize_username(target_username), - "target_name": self._display_name(target_username), - "source_chat_id": source_chat_id, - "source_message_id": source_message_id, - "created_at": now_iso(), - "updated_at": now_iso(), - } - items.append(item) - self._save_task_items(items) - self._append_history_event("task_center_item_created", { - "itemId": item["id"], - "kind": kind, - "sourceUsername": item["source_username"], - "targetUsername": item["target_username"], - "title": item["title"], - }, username=source_username) - return item - - def _task_items_for_user(self, username: str, *, include_done: bool = False) -> list[dict[str, Any]]: - uname = normalize_username(username) - items = self._load_task_items() - result = [ - item for item in items - if normalize_username(item.get("target_username")) == uname - and (include_done or item.get("status") != "done") - ] - return sorted(result, key=lambda x: str(x.get("created_at") or "")) - - def _task_center_counts_text(self, username: str) -> str: - counts: dict[str, int] = {} - for item in self._task_items_for_user(username): - status = str(item.get("status") or "new") - counts[status] = counts.get(status, 0) + 1 - active_total = sum(counts.values()) - if active_total <= 0: - return "" - parts = [] - for status in ("new", "approved", "needs_work", "rejected"): - count = counts.get(status, 0) - if count: - parts.append(f"{TASK_STATUS_LABELS.get(status, status)}: {count}") - return f"Напоминание по задачам: всего активных {active_total}; " + ", ".join(parts) + "." - - def _format_task_items(self, username: str, *, include_done: bool = False) -> str: - items = self._task_items_for_user(username, include_done=include_done) - if not items: - return f"Для {self._display_name(username)} активных задач и предложений нет." - lines = [f"Задачи и предложения для {self._display_name(username)}:"] - for item in items[:15]: - kind = "предложение" if item.get("kind") == "proposal" else "задача" - status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) - source = item.get("source_name") or item.get("source_username") or "неизвестно" - title = item.get("title") or "(без названия)" - lines.append(f"{item.get('id')} [{status}] {kind} от {source}: {title}") - if len(items) > 15: - lines.append(f"...и ещё {len(items) - 15}") - return "\n".join(lines) - - def _update_task_item_status(self, item_id: str, status: str) -> dict[str, Any] | None: - item_id = (item_id or "").strip().upper() - items = self._load_task_items() - updated = None - for item in items: - if str(item.get("id") or "").upper() == item_id: - item["status"] = status - item["updated_at"] = now_iso() - updated = item - break - if updated is not None: - self._save_task_items(items) - return updated - - def _find_first_task_item(self, *, source_username: str = "", target_username: str = "", kind: str = "") -> dict[str, Any] | None: - source = normalize_username(source_username) - target = normalize_username(target_username) - for item in self._load_task_items(): - if item.get("status") == "done": - continue - if source and normalize_username(item.get("source_username")) != source: - continue - if target and normalize_username(item.get("target_username")) != target: - continue - if kind and item.get("kind") != kind: - continue - return item - return None - - def _notify_user_about_task_item(self, username: str, item: dict[str, Any]) -> None: - chat_id = self._private_chat_id_for_user(username) - if chat_id is None: - return - kind = "предложение" if item.get("kind") == "proposal" else "задача" - source = item.get("source_name") or item.get("source_username") or "кто-то" - title = item.get("title") or "(без названия)" - status = TASK_STATUS_LABELS.get(str(item.get("status") or "new"), str(item.get("status") or "new")) - if item.get("status") == "new": - text = f"У тебя новое {kind} от {source}: {title}\nID: {item.get('id')}" - else: - text = f"Обновление по {kind} {item.get('id')}: статус «{status}».\n{title}" - self._safe_send(chat_id, text) - - 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 начинает новую Codex-сессию и архивирует текущую историю." - ) - reminder = self._task_center_counts_text(uname) - if reminder: - text = f"{text}\n\n{reminder}\nКоманда /tasks покажет список." - 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): - return chat_id - current = chat_id - visited: set[int] = set() - while current not in visited: - visited.add(current) - next_chat_id = migrations.get(str(current)) - if not isinstance(next_chat_id, int): - break - current = next_chat_id - return current - - def _remember_chat_migration(self, old_chat_id: int, new_chat_id: int, source: str) -> None: - if old_chat_id == new_chat_id: - return - migrations = self.state.get("chat_id_migrations") - if not isinstance(migrations, dict): - migrations = {} - self.state["chat_id_migrations"] = migrations - if migrations.get(str(old_chat_id)) == new_chat_id: - return - migrations[str(old_chat_id)] = new_chat_id - self._persist_state() - with self.queue_lock: - changed = False - for job in self.queue: - if job.get("chat_id") == old_chat_id: - job["chat_id"] = new_chat_id - job["updated_at"] = now_iso() - changed = True - if changed: - self._persist_queue() - self._append_history_event("chat_migrated_to_supergroup", { - "oldChatId": old_chat_id, - "newChatId": new_chat_id, - "source": source, - }) - - @staticmethod - def _extract_migrate_to_chat_id(error_text: str) -> int | None: - match = re.search(r'"migrate_to_chat_id"\s*:\s*(-?\d+)', error_text) - if match: - return int(match.group(1)) - match = re.search(r"'migrate_to_chat_id'\s*:\s*(-?\d+)", error_text) - if match: - return int(match.group(1)) - return None - - def _handle_update(self, update: dict[str, Any]) -> None: - message = update.get("message") - update_type = "message" - if not isinstance(message, dict): - message = update.get("channel_post") - update_type = "channel_post" - if not isinstance(message, dict): - return - chat = message.get("chat") or {} - chat_id = chat.get("id") - message_id = message.get("message_id") - chat_type = str(chat.get("type") or "") - chat_username = normalize_username(chat.get("username")) - chat_title = str(chat.get("title") or "") - sender = message.get("from") or {} - username = normalize_username(sender.get("username")) - author_signature = str(message.get("author_signature") or "").strip() - author_username = username or normalize_username(author_signature) - if not isinstance(chat_id, int) or not isinstance(message_id, int): - return - - migrate_to_chat_id = message.get("migrate_to_chat_id") - if isinstance(migrate_to_chat_id, int): - self._remember_chat_migration(chat_id, migrate_to_chat_id, "telegram_message") - return - - update_key = f"{chat_id}:{message_id}" - if self._mark_processed_update(update_key): - return - - is_channel_post = update_type == "channel_post" or chat_type == "channel" - is_group_message = update_type == "message" and chat_type in ("group", "supergroup") - is_allowed_channel = ( - not is_channel_post - or not self.cfg.allowed_channel_username - or chat_username == self.cfg.allowed_channel_username - ) - 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() - 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) - if is_private: - self._remember_private_chat(actor_username, chat_id) - 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, - actor_username, - message["voice"].get("file_id"), - duration_seconds=message["voice"].get("duration"), - telegram_file_size=message["voice"].get("file_size"), - media_type="voice", - update_type=update_type, - chat_username=chat_username, - chat_title=chat_title, - author_signature=author_signature, - chat_type=chat_type, - ) - return - if message.get("audio"): - self._enqueue_voice_job( - chat_id, - message_id, - actor_username, - message["audio"].get("file_id"), - duration_seconds=message["audio"].get("duration"), - telegram_file_size=message["audio"].get("file_size"), - media_type="audio", - update_type=update_type, - chat_username=chat_username, - chat_title=chat_title, - author_signature=author_signature, - chat_type=chat_type, - ) - return - self._safe_send(chat_id, "Поддерживаются текст, voice и audio.", reply_to=message_id) - return - - if text.startswith("/"): - self._handle_command(chat_id, message_id, actor_username, text) - return - - if self._handle_task_center_text(chat_id, message_id, actor_username, text): - return - - self._append_history(history_path, "incoming_text", { - "chatId": chat_id, - "messageId": message_id, - "updateType": update_type, - "chatType": chat_type, - "chatUsername": chat_username, - "chatTitle": chat_title, - "username": actor_username, - "authorSignature": author_signature, - "text": text, - }) - 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 - 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(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() - if chat_type == "private": - self._ensure_job_status_message( - job["id"], - chat_id, - message_id, - f"Задача #{job['num']} получена.\nСтатус: в очереди.", - ) - else: - self._safe_send(chat_id, f"Принял задачу #{job['num']}", reply_to=message_id) - - def _enqueue_voice_job( - self, - chat_id: int, - message_id: int, - username: str, - file_id: str | None, - *, - duration_seconds: int | None = None, - telegram_file_size: int | None = None, - media_type: str = "voice", - update_type: str = "message", - chat_username: str = "", - chat_title: str = "", - author_signature: str = "", - chat_type: str = "", - ) -> None: - if not file_id: - self._safe_send(chat_id, "Не удалось прочитать file_id голосового.", reply_to=message_id) - return - history_path = self._current_history_file_for_user(username) - self._append_history(history_path, "incoming_voice", { - "chatId": chat_id, - "messageId": message_id, - "updateType": update_type, - "chatType": chat_type, - "chatUsername": chat_username, - "chatTitle": chat_title, - "username": username, - "authorSignature": author_signature, - "fileId": file_id, - "mediaType": media_type, - "durationSeconds": duration_seconds, - "fileSize": telegram_file_size, - }) - 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["telegram_duration_seconds"] = duration_seconds or 0 - job["telegram_file_size"] = telegram_file_size or 0 - 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() - if chat_type == "private": - self._ensure_job_status_message( - job["id"], - chat_id, - message_id, - f"Voice для задачи #{job['num']} получен.\nСтатус: в очереди.", - ) - else: - self._safe_send(chat_id, f"Принял voice в задачу #{job['num']}", reply_to=message_id) - - def _build_job_base(self, chat_id: int, message_id: int, username: str, history_file: str) -> dict[str, Any]: - with self.queue_lock: - num = int(self.state.get("next_job_number", 1)) - self.state["next_job_number"] = num + 1 - self._persist_state() - return { - "id": str(uuid.uuid4()), - "num": num, - "status": "pending", - "type": "text", - "chat_id": chat_id, - "message_id": message_id, - "username": username, - "update_type": "message", - "chat_type": "", - "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": "", - "last_error": "", - "created_at": now_iso(), - "updated_at": now_iso(), - "active_since": None, - "status_message_id": None, - "status_message_text": "", - } - - def _handle_task_center_text(self, chat_id: int, message_id: int, username: str, text: str) -> bool: - source_text = (text or "").strip() - lower = source_text.lower() - is_owner = self._is_owner(username) - - if re.search(r"\b(покажи|список|какие)\b.*\b(задач|задачи|предложени)", lower): - target = username - explicit_target = self._find_user_by_text(source_text) - if is_owner and explicit_target: - target = explicit_target - self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) - return True - - if is_owner: - assign_match = re.search( - r"(?:поставь|добавь|создай|запиши)\s+(?:задачу|задание)\s+(.+?)(?::|\s+-\s+|\s+—\s+)(.+)", - source_text, - flags=re.IGNORECASE | re.DOTALL, - ) - if assign_match: - target = self._find_user_by_text(assign_match.group(1)) - body = assign_match.group(2).strip() - if target and body: - item = self._create_task_item( - kind="task", - title=body, - text=body, - source_username=username, - target_username=target, - source_message_id=message_id, - source_chat_id=chat_id, - ) - self._notify_user_about_task_item(target, item) - self._safe_send( - chat_id, - f"Задача добавлена для {self._display_name(target)}: {item['id']} — {item['title']}", - reply_to=message_id, - ) - return True - - status_match = re.search(r"\b(одобрить|отклонить|доработать|закрыть|сделано|закрыта)\b(?:\s+(.+))?", lower, flags=re.IGNORECASE) - if status_match and ("задач" in lower or "предложени" in lower or re.search(r"tc-\d+", lower, flags=re.IGNORECASE)): - action = status_match.group(1) - tail = status_match.group(2) or "" - status = { - "одобрить": "approved", - "отклонить": "rejected", - "доработать": "needs_work", - "закрыть": "done", - "сделано": "done", - "закрыта": "done", - }.get(action, "new") - id_match = re.search(r"tc-\d+", source_text, flags=re.IGNORECASE) - item = None - if id_match: - item = self._update_task_item_status(id_match.group(0), status) - else: - source_user = self._find_user_by_text(tail) - item = self._find_first_task_item( - source_username=source_user, - target_username=username, - kind="proposal" if "предложени" in lower else "", - ) - if item: - item = self._update_task_item_status(str(item.get("id")), status) - if item: - label = TASK_STATUS_LABELS.get(status, status) - self._safe_send(chat_id, f"{item.get('id')} обновлена: {label}.", reply_to=message_id) - source_user = normalize_username(item.get("source_username")) - if source_user and source_user != username: - self._notify_user_about_task_item(source_user, item) - return True - - if not is_owner: - proposal_match = re.match(r"\s*(?:предложение|идея|заявка)\s*[::-]\s*(.+)", source_text, flags=re.IGNORECASE | re.DOTALL) - if proposal_match: - body = proposal_match.group(1).strip() - if body: - item = self._create_task_item( - kind="proposal", - title=body, - text=body, - opinion="Нужно решение Айдара: одобрить, отклонить или отправить на доработку.", - source_username=username, - target_username=self.cfg.allowed_username, - source_message_id=message_id, - source_chat_id=chat_id, - ) - self._notify_user_about_task_item(self.cfg.allowed_username, item) - self._safe_send( - chat_id, - f"Предложение отправлено Айдару как {item['id']}. Статус: новая.", - reply_to=message_id, - ) - return True - - return False - - def _handle_command(self, chat_id: int, message_id: int, username: str, text: str) -> None: - lower = text.lower() - command = lower.split(maxsplit=1)[0].split("@", 1)[0] - is_owner = self._is_owner(username) - if command in ("/start", "/help"): - self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) - return - if command == "/settings": - self._safe_send(chat_id, self._settings_text(username), reply_to=message_id) - return - if command == "/status": - self._safe_send(chat_id, self._status_text(username), reply_to=message_id) - return - if command == "/queue": - self._safe_send(chat_id, self._queue_text(), reply_to=message_id) - return - if command in ("/tasks", "/my_tasks"): - parts = text.split(maxsplit=1) - target = username - if is_owner and len(parts) > 1: - parsed = self._find_user_by_text(parts[1]) - if parsed: - target = parsed - self._safe_send(chat_id, self._format_task_items(target), reply_to=message_id) - return - if command == "/voice_on": - self._set_voice_replies_enabled(username, True) - self._append_history_event("voice_replies_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Озвучивание финальных ответов включено для вашего пользователя.", reply_to=message_id) - return - if command == "/voice_off": - self._set_voice_replies_enabled(username, False) - self._append_history_event("voice_replies_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Озвучивание финальных ответов выключено для вашего пользователя.", reply_to=message_id) - return - if command in ("/voice_status", "/voice_rewrite_status"): - self._safe_send(chat_id, self._status_text(username), reply_to=message_id) - return - if command == "/voice_rewrite_on": - self._set_voice_rewrite_enabled(username, True) - self._append_history_event("voice_rewrite_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Адаптация текста перед озвучкой включена для вашего пользователя.", reply_to=message_id) - return - if command == "/voice_rewrite_off": - self._set_voice_rewrite_enabled(username, False) - self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id) - return - if command == "/single_message_on": - self._set_single_status_message_enabled(username, True) - self._append_history_event("single_status_message_enabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке включён для вашего пользователя.", reply_to=message_id) - return - if command == "/single_message_off": - self._set_single_status_message_enabled(username, False) - self._append_history_event("single_status_message_disabled", {"username": normalize_username(username)}, username=username) - self._safe_send(chat_id, "Режим одного редактируемого сообщения в личке выключен. Бот будет отправлять отдельные сообщения по этапам.", reply_to=message_id) - return - if command == "/new": - archived = self._rotate_history("command_new", username) - self._safe_send(chat_id, f"История очищена. Новый диалог начат.\nАрхив: {archived.name}", reply_to=message_id) - return - if command in ("/restart_service", "/restart"): - if not is_owner: - self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) - return - self._append_history_event("restart_service_deferred_requested", { - "chatId": chat_id, - "messageId": message_id, - "username": username, - }, username=username) - self._safe_send( - chat_id, - "Отложенный рестарт принят. Если задача сейчас выполняется, сервис перезапустится после её завершения и до следующей задачи.", - reply_to=message_id, - ) - self._request_deferred_restart() - return - if command in ("/restart_hard", "/restart_now", "/restart_force"): - if not is_owner: - self._safe_send(chat_id, "Команда недоступна.", reply_to=message_id) - return - self._append_history_event("restart_service_hard_requested", { - "chatId": chat_id, - "messageId": message_id, - "username": username, - }, username=username) - self._safe_send( - chat_id, - "Выполняю жёсткий рестарт сервиса прямо сейчас. Активная задача, если есть, будет прервана и после старта вернётся в очередь.", - reply_to=message_id, - ) - self._schedule_self_restart("hard_restart_requested", force=True) - return - if command == "/stop": - stopped = self._cancel_active_job("stopped_by_user") - if stopped: - self._safe_send(chat_id, "Текущая задача остановлена и удалена из очереди.", reply_to=message_id) - else: - self._safe_send(chat_id, "Сейчас нет активной задачи.", reply_to=message_id) - return - if command == "/cancel": - parts = text.split(maxsplit=1) - if len(parts) < 2: - self._safe_send(chat_id, "Использование: /cancel ", reply_to=message_id) - return - arg = parts[1].strip() - if arg.lower() == "all": - with self.queue_lock: - self.stop_current_job = True - self._stop_active_codex_process() - count = len(self.queue) - self.queue = [] - self._persist_queue() - self._safe_send(chat_id, f"Удалено задач из очереди: {count}", reply_to=message_id) - return - cancelled = self._cancel_by_id_prefix(arg) - self._safe_send(chat_id, f"Задача удалена: {arg}" if cancelled else f"Задача не найдена: {arg}", reply_to=message_id) - return - - def _help_text(self, *, is_owner: bool) -> str: - lines = [ - "Доступные команды:", - "/status — активная задача и размер очереди", - "/settings — текущие настройки и команды для их изменения", - "/queue — список задач в очереди", - "/tasks — список ваших задач и предложений", - "/stop — остановить текущую задачу", - "/cancel — удалить задачу по id (префикс) или все", - "/new — архивировать историю и начать новую Codex-сессию", - "/help — эта справка", - ] - if is_owner: - lines.insert(-1, "/tasks <пользователь> — список задач пользователя") - lines.insert(-1, "/restart — отложенный рестарт после текущей задачи") - lines.insert(-1, "/restart_hard — жёсткий рестарт прямо сейчас") - return "\n".join(lines) - - def _settings_text(self, username: str) -> str: - voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" - rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" - lines = [ - "Текущие настройки:", - f"Озвучивание финальных ответов: {voice_status}", - f"Адаптация текста перед озвучкой: {rewrite_status}", - f"Режим одного редактируемого сообщения в личке: {single_message_status}", - "", - "Команды настроек:", - "/voice_on — включить озвучивание", - "/voice_off — выключить озвучивание", - "/voice_rewrite_on — адаптировать текст перед озвучкой", - "/voice_rewrite_off — озвучивать обычный текст без адаптации", - "/single_message_on — один редактируемый ответ в личке", - "/single_message_off — отдельные сообщения по этапам и финалу", - ] - return "\n".join(lines) - - def _status_text(self, username: str) -> str: - with self.queue_lock: - active = next((j for j in self.queue if j.get("status") == "active"), None) - pending = sum(1 for j in self.queue if j.get("status") == "pending") - voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" - rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" - single_message_status = "включён" if self._single_status_message_enabled(username) else "выключен" - settings_text = ( - f"Голосовые ответы: {voice_status}\n" - f"Адаптация текста перед озвучкой: {rewrite_status}\n" - f"Режим одного сообщения в личке: {single_message_status}" - ) - restart_text = "\nОтложенный рестарт: ожидает завершения текущей задачи" if self.restart_requested else "" - if not active: - return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}{restart_text}" - elapsed = int(time.time() - (self.active_job_started_at or time.time())) - return ( - f"Статус: активная задача #{active.get('num', '?')}\n" - f"Тип: {active.get('type', 'text')}\n" - f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" - f"Выполняется: {elapsed}с\n" - f"Pending: {pending}\n" - f"{settings_text}{restart_text}" - ) - - def _queue_text(self) -> str: - with self.queue_lock: - items = list(self.queue) - if not items: - return "Очередь пуста." - lines = [f"Очередь: {len(items)}"] - for i, job in enumerate(items[:10], start=1): - lines.append( - f"{i}) #{job.get('num', '?')} [{job.get('status')}] {job.get('type')} attempts={job.get('attempts', 0)}" - ) - if len(items) > 10: - lines.append(f"...и ещё {len(items) - 10} задач") - return "\n".join(lines) - - def _cancel_active_job(self, reason: str) -> bool: - with self.queue_lock: - active = next((j for j in self.queue if j.get("status") == "active"), None) - if not active: - return False - self.stop_current_job = True - self._stop_active_codex_process() - self.queue = [j for j in self.queue if j.get("id") != active.get("id")] - self._persist_queue() - self._append_history_event("job_stopped_by_user", {"jobId": active.get("id"), "reason": reason}) - return True - - def _cancel_by_id_prefix(self, prefix: str) -> bool: - prefix = prefix.strip().lower() - normalized_num = prefix.lstrip("#") - with self.queue_lock: - target = next( - ( - j for j in self.queue - if str(j.get("id", "")).lower().startswith(prefix) - or str(j.get("num", "")).lower() == normalized_num - ), - None - ) - if not target: - return False - if target.get("status") == "active": - self.stop_current_job = True - self._stop_active_codex_process() - self.queue = [j for j in self.queue if j.get("id") != target.get("id")] - self._persist_queue() - return True - - def _worker_loop(self) -> None: - while not self.stop_event.is_set(): - if self.restart_requested: - self._exit_for_restart("deferred_restart_before_next_job") - return - job = None - with self.queue_lock: - for item in self.queue: - if item.get("status") == "pending": - item["status"] = "active" - item["active_since"] = now_iso() - item["updated_at"] = now_iso() - self.active_job_id = item.get("id") - self.active_job_started_at = time.time() - job = dict(item) - self._persist_queue() - break - if not job: - time.sleep(0.5) - continue - - self.stop_current_job = False - self._process_job(job) - self.active_job_id = None - self.active_job_started_at = None - if self.restart_requested: - self._exit_for_restart("deferred_restart_after_job") - return - - def _process_job(self, job: dict[str, Any]) -> None: - job_id = job["id"] - job_num = job.get("num", "?") - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - history_path = Path(job["history_file"]) - private_single_message = ( - (job.get("chat_type") or "") == "private" - and self._single_status_message_enabled(job.get("username") or "") - ) - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняется.") - try: - if job.get("type") == "voice": - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: распознаю voice.") - recognized = self._transcribe_voice_job( - job, - status_cb=lambda note: self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: {note}", - ), - ) - job["text"] = recognized - self._append_history(history_path, "voice_transcription", {"jobId": job_id, "jobNum": job_num, "text": recognized}) - preview = recognized.strip() - if len(preview) > 800: - preview = preview[:800].rstrip() + " ...[обрезано]" - self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: voice распознан, отправляю в Codex.\n\nТекст:\n{preview}", - ) - - prompt = self._build_prompt(job) - self._append_history(history_path, "codex_request", {"jobId": job_id, "prompt": prompt}) - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: выполняю через Codex.") - answer = self._run_codex(prompt, job) - if private_single_message: - parts = split_final_private_text(answer) - self._set_job_status_text(job, parts[0]) - if len(parts) > 1: - self._safe_send(chat_id, parts[1], reply_to=message_id) - else: - for chunk in split_long_text(answer): - self._safe_send(chat_id, chunk, reply_to=message_id) - self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) - self._send_private_job_public_report(job, answer) - self._send_task_center_reminder(job) - if self._voice_replies_enabled(job.get("username") or ""): - self._send_voice_reply_for_answer(job, answer, history_path, job_id) - self._mark_job_done(job_id) - except Exception as e: - if self.stop_current_job: - self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)}) - self._set_job_status_text(job, f"Задача #{job_num} остановлена.") - 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: - retry_block = "" - retry_reason = (job.get("retry_reason") or "").strip() - if retry_reason: - retry_block = f"\n\nПометка retry: {retry_reason}" - return ( - "Пришло сообщение в Telegram.\n" - f"Тип: {job.get('type')}\n" - f"Источник Telegram: {job.get('update_type', 'message')}\n" - f"Тип чата: {job.get('chat_type') or ''}\n" - f"Канал/чат: @{job.get('chat_username') or ''} {job.get('chat_title') or ''}\n" - f"Username отправителя: @{job.get('username')}\n" - f"Подпись автора в Telegram: {job.get('author_signature') or ''}\n" - "Текст для обработки:\n" - f"{job.get('text')}\n\n" - f"История диалога (JSONL): {job.get('history_file')}\n" - f"Инструкции агента: {self.cfg.agent_instructions_file}\n" - f"Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.{retry_block}" - ) - - def _run_codex(self, prompt: str, job: dict[str, Any]) -> str: - username = job.get("username") or self.cfg.allowed_username - thread_id = self._codex_thread_id_for_user(username) - try: - return self._run_codex_once(prompt, job, thread_id=thread_id) - except RuntimeError as e: - if not thread_id or not self._is_missing_codex_session_error(str(e)): - raise - self._set_codex_thread_id_for_user(username, "") - self._append_history( - Path(job["history_file"]), - "system_event", - { - "event": "codex_thread_reset", - "reason": "missing_session", - "username": normalize_username(username), - "oldThreadId": thread_id, - }, - ) - return self._run_codex_once(prompt, job, thread_id="") - - def _run_codex_once(self, prompt: str, job: dict[str, Any], *, thread_id: str) -> str: - output_lines: list[str] = [] - job_id = str(job["id"]) - job_num = job.get("num", "?") - username = job.get("username") or self.cfg.allowed_username - with tempfile.NamedTemporaryFile(prefix="shine-codex-last-message-", suffix=".txt", delete=False) as tmp: - output_file = Path(tmp.name) - - cmd = [ - str(self.cfg.codex_bin), - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--json", - "-C", str(self.cfg.codex_workdir), - "-o", str(output_file), - ] - if thread_id: - cmd.extend(["resume", thread_id]) - cmd.append(prompt) - mode = f"resume {thread_id}" if thread_id else "new" - print(f"[py-bot] codex exec start job={job_id[:8]} mode={mode}", flush=True) - process = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, - ) - with self.active_process_lock: - self.active_process = process - - self.last_heartbeat_at = 0.0 - last_user_note = "" - last_user_note_at = 0.0 - codex_started_at = time.time() - last_job_message_at = codex_started_at - seen_thread_id = "" - - def on_line(line: str) -> None: - nonlocal last_user_note, last_user_note_at, last_job_message_at, seen_thread_id - output_lines.append(line) - current_thread_id = self._extract_codex_thread_id(line) - if current_thread_id: - seen_thread_id = current_thread_id - note = self._extract_codex_user_note(line) - now = time.time() - if note and note != last_user_note and now - last_user_note_at > 8: - self._set_job_status_text(job, f"Задача #{job_num} в работе.\nСтатус: {note}") - last_user_note = note - last_user_note_at = now - last_job_message_at = now - - reader_done = threading.Event() - - def reader() -> None: - if not process.stdout: - reader_done.set() - return - for line in process.stdout: - on_line(line.rstrip("\n")) - reader_done.set() - - t = threading.Thread(target=reader, name=f"codex-reader-{job_id[:8]}", daemon=True) - t.start() - - try: - deadline = time.time() + self.cfg.codex_timeout_seconds - return_code = None - while return_code is None: - return_code = process.poll() - now = time.time() - if return_code is not None: - break - if now >= deadline: - process.kill() - t.join(timeout=2) - raise RuntimeError(f"Codex timeout after {self.cfg.codex_timeout_seconds}s") - if now - codex_started_at >= 120 and now - last_job_message_at >= 120: - elapsed = self._format_duration(int(now - codex_started_at)) - self._set_job_status_text( - job, - f"Задача #{job_num} в работе.\nСтатус: выполняется уже {elapsed}, от Codex давно нет новых сообщений.", - ) - last_job_message_at = now - self.last_heartbeat_at = now - time.sleep(1) - finally: - with self.active_process_lock: - self.active_process = None - - reader_done.wait(timeout=2) - - if return_code != 0: - tail = "\n".join(output_lines[-40:]) - raise RuntimeError(f"Codex exited with code {return_code}. Output tail:\n{tail}") - - if seen_thread_id and seen_thread_id != thread_id: - self._set_codex_thread_id_for_user(username, seen_thread_id) - - if output_file.exists(): - answer = output_file.read_text(encoding="utf-8").strip() - try: - output_file.unlink(missing_ok=True) - except Exception: - pass - if answer: - return answer - - fallback = self._extract_fallback_message(output_lines) - if not fallback: - raise RuntimeError("Codex returned empty response") - return fallback - - def _stop_active_codex_process(self) -> bool: - with self.active_process_lock: - process = self.active_process - if process is None: - return False - if process.poll() is not None: - return False - process.terminate() - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - return True - - def _handle_job_failure(self, job: dict[str, Any], err: Exception) -> None: - job_id = job["id"] - job_num = job.get("num", "?") - error_text = str(err).strip() or err.__class__.__name__ - 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) - if not target: - return - attempts = int(target.get("attempts", 0)) + 1 - target["attempts"] = attempts - target["last_error"] = error_text[:1000] - target["updated_at"] = now_iso() - if retryable and attempts < self.cfg.max_retries: - target["status"] = "pending" - target["retry_reason"] = error_text[:200] - self._persist_queue() - will_retry = True - else: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - will_retry = False - - if will_retry: - self._set_job_status_text( - job, - f"{user_error_text}\nПовторю задачу #{job_num}: попытка {attempts + 1}/{self.cfg.max_retries}.", - ) - else: - self._set_job_status_text(job, f"{user_error_text}\nЗадача #{job_num} остановлена.") - - 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: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - - def _mark_job_removed(self, job_id: str) -> None: - with self.queue_lock: - self.queue = [j for j in self.queue if j.get("id") != job_id] - self._persist_queue() - - def _send_task_center_reminder(self, job: dict[str, Any]) -> None: - if job.get("chat_type") != "private": - return - username = job.get("username") or "" - reminder = self._task_center_counts_text(username) - if not reminder: - return - self._safe_send(int(job["chat_id"]), reminder, reply_to=int(job["message_id"])) - - def _remember_public_report_chat(self, chat_id: int) -> None: - if self.state.get("public_report_chat_id") == chat_id: - return - 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: - 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: - if isinstance(resolved_chat_id, int): - self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_file_error") - try: - sent = send(migrate_to_chat_id, None) - result = sent.get("result") or {} - message_id = result.get("message_id") - return message_id if isinstance(message_id, int) else None - except Exception as retry_error: - print(f"[py-bot] sendFile retry after migration error: {retry_error}", flush=True) - return None - print(f"[py-bot] sendFile error: {e}", flush=True) - return None - - def _safe_send_voice_upload( - self, - chat_id: int | str, - voice_bytes: bytes, - filename: str, - *, - caption: str = "", - reply_to: int | None = None, - ) -> int | None: - if not voice_bytes: - 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]: - return self.telegram.send_voice_upload( - target_chat_id, - voice_bytes, - filename, - caption=caption, - reply_to_message_id=target_reply_to, - ) - - try: - 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: - if isinstance(resolved_chat_id, int): - self._remember_chat_migration(resolved_chat_id, migrate_to_chat_id, "send_voice_upload_error") - try: - sent = send(migrate_to_chat_id, None) - result = sent.get("result") or {} - message_id = result.get("message_id") - return message_id if isinstance(message_id, int) else None - except Exception as retry_error: - print(f"[py-bot] sendVoiceUpload retry after migration error: {retry_error}", flush=True) - return None - print(f"[py-bot] sendVoiceUpload 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 None - print(f"[py-bot] sendMessage error: {e}", flush=True) - return None - - def _safe_edit(self, chat_id: int | str, message_id: int | None, text: str) -> bool: - text = (text or "").strip() - if not text or not message_id: - return False - 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 - try: - self.telegram.edit_message_text(resolved_chat_id, message_id, text) - return True - except Exception as e: - error_text = str(e) - if "message is not modified" in error_text: - return True - migrate_to_chat_id = self._extract_migrate_to_chat_id(error_text) - 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, "edit_message_error") - try: - self.telegram.edit_message_text(migrate_to_chat_id, message_id, text) - return True - except Exception as retry_error: - print(f"[py-bot] editMessageText retry after migration error: {retry_error}", flush=True) - return False - print(f"[py-bot] editMessageText error: {e}", flush=True) - return False - - def _ensure_job_status_message(self, job_id: str, chat_id: int, reply_to_message_id: int, text: str) -> int | None: - text = (text or "").strip() - if not text: - return None - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - existing_id = target.get("status_message_id") - existing_text = (target.get("status_message_text") or "").strip() - else: - existing_id = None - existing_text = "" - if existing_id and existing_text == text and self._safe_edit(chat_id, int(existing_id), text): - return int(existing_id) - if existing_id and self._safe_edit(chat_id, int(existing_id), text): - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - target["status_message_text"] = text - target["updated_at"] = now_iso() - self._persist_queue() - return int(existing_id) - message_id = self._safe_send(chat_id, text, reply_to=reply_to_message_id) - if message_id is not None: - with self.queue_lock: - target = next((j for j in self.queue if j.get("id") == job_id), None) - if target: - target["status_message_id"] = message_id - target["status_message_text"] = text - target["updated_at"] = now_iso() - self._persist_queue() - return message_id - - def _set_job_status_text(self, job: dict[str, Any], text: str) -> None: - if (job.get("chat_type") or "") != "private": - self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) - return - if not self._single_status_message_enabled(job.get("username") or ""): - self._safe_send(int(job["chat_id"]), text, reply_to=int(job["message_id"])) - return - message_id = self._ensure_job_status_message( - job["id"], - int(job["chat_id"]), - int(job["message_id"]), - text, - ) - if message_id is not None: - job["status_message_id"] = message_id - job["status_message_text"] = text - - def _request_deferred_restart(self) -> None: - if self.restart_requested: - return - self.restart_requested = True - self._append_history_event("restart_service_deferred_scheduled", {}) - with self.queue_lock: - has_active = any(j.get("status") == "active" for j in self.queue) - if not has_active: - threading.Thread( - target=lambda: self._exit_for_restart("deferred_restart_no_active_job"), - name="shine-py-bot-deferred-restart", - daemon=True, - ).start() - - def _exit_for_restart(self, reason: str) -> None: - print(f"[py-bot] restart now: {reason}", flush=True) - self._append_history_event("restart_service_executing", {"reason": reason}) - time.sleep(0.5) - os._exit(0) - - def _schedule_self_restart(self, reason: str = "restart_requested", *, force: bool = False) -> None: - if self.restart_requested and not force: - return - self.restart_requested = True - - def restart() -> None: - time.sleep(1.5) - print(f"[py-bot] restart requested by Telegram command: {reason}", flush=True) - if force: - self._stop_active_codex_process() - os._exit(0) - - threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() - - def _voice_reply_targets(self, job: dict[str, Any]) -> list[tuple[int | str, int | None, str]]: - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - targets: list[tuple[int | str, int | None, str]] = [(chat_id, message_id, "source")] - username = job.get("username") or "" - - private_chat_id = self._private_chat_id_for_user(username) - if private_chat_id is not None and private_chat_id != self._resolve_chat_id(chat_id): - targets.append((private_chat_id, None, "private")) - - report_chat_id = self._public_report_chat_id() - if report_chat_id is not None: - resolved_report_chat_id = self._resolve_chat_id(report_chat_id) if isinstance(report_chat_id, int) else report_chat_id - resolved_current_chat_id: int | str = self._resolve_chat_id(chat_id) - if resolved_report_chat_id != resolved_current_chat_id: - targets.append((report_chat_id, None, "public")) - - deduped: list[tuple[int | str, int | None, str]] = [] - seen: set[str] = set() - for target_chat_id, target_reply_to, label in targets: - resolved: int | str = self._resolve_chat_id(target_chat_id) if isinstance(target_chat_id, int) else target_chat_id - key = str(resolved) - if key in seen: - continue - seen.add(key) - deduped.append((target_chat_id, target_reply_to, label)) - return deduped - - def _send_voice_reply_for_answer( - self, - job: dict[str, Any], - answer: str, - history_path: Path, - job_id: str, - ) -> None: - chat_id = int(job["chat_id"]) - message_id = int(job["message_id"]) - job_num = job.get("num", "?") - username = job.get("username") or "" - if not self.cfg.openai_api_key: - note = "не настроен ключ OpenAI для озвучивания." - self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) - self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) - return - - voice_text = answer - rewrite_enabled = self._voice_rewrite_enabled(username) - if rewrite_enabled: - try: - voice_text = self._openai_rewrite_text_for_voice(answer) - self._append_history(history_path, "voice_rewrite_done", { - "jobId": job_id, - "jobNum": job_num, - "model": self.cfg.openai_voice_rewrite_model, - "sourceChars": len(answer or ""), - "resultChars": len(voice_text or ""), - }) - except VoiceReplyError as e: - self._append_history(history_path, "voice_rewrite_failed", { - "jobId": job_id, - "jobNum": job_num, - "model": self.cfg.openai_voice_rewrite_model, - "error": str(e), - }) - self._safe_send( - chat_id, - f"Не удалось адаптировать ответ #{job_num} для озвучки, озвучиваю обычный текст: {e}", - reply_to=message_id, - ) - voice_text = answer - - chunks = split_text_for_tts(voice_text, self.cfg.openai_tts_chunk_chars) - if not chunks: - return - - sent_count = 0 - total = len(chunks) - targets = self._voice_reply_targets(job) - print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total} targets={len(targets)} rewrite={rewrite_enabled}", flush=True) - for index, chunk in enumerate(chunks, start=1): - try: - audio = self._openai_tts(chunk) - except VoiceReplyError as e: - self._append_history(history_path, "voice_reply_failed", { - "jobId": job_id, - "jobNum": job_num, - "part": index, - "parts": total, - "error": str(e), - }) - self._safe_send(chat_id, f"Не удалось озвучить ответ #{job_num}: {e}", reply_to=message_id) - return - caption = f"Озвучка ответа #{job_num}" - if total > 1: - caption += f", часть {index}/{total}" - for target_chat_id, target_reply_to, target_label in targets: - message_sent = self._safe_send_voice_upload( - target_chat_id, - audio, - f"shine-answer-{job_num}-{index}.ogg", - caption=caption, - reply_to=target_reply_to, - ) - if message_sent is None: - self._append_history(history_path, "voice_reply_failed", { - "jobId": job_id, - "jobNum": job_num, - "part": index, - "parts": total, - "target": target_label, - "error": "Telegram не принял voice-файл озвучки.", - }) - if target_label == "source": - self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) - continue - sent_count += 1 - self._append_history(history_path, "voice_reply_sent", { - "jobId": job_id, - "jobNum": job_num, - "parts": total, - "messages": sent_count, - "targets": len(targets), - "rewriteEnabled": rewrite_enabled, - }) - print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) - - def _openai_rewrite_text_for_voice(self, text: str) -> str: - source = (text or "").strip() - if not source: - return "" - if len(source) > self.cfg.openai_voice_rewrite_max_input_chars: - source = source[:self.cfg.openai_voice_rewrite_max_input_chars].rstrip() + "\n\n...[текстовый ответ был длиннее и обрезан для голосовой версии]" - payload = { - "model": self.cfg.openai_voice_rewrite_model, - "messages": [ - { - "role": "system", - "content": ( - "Ты готовишь русскую версию финального ответа технического агента для озвучивания. " - "Не пересказывай заново и не меняй смысл: сохрани порядок мыслей, итог, предупреждения, статусы и важные действия. " - "Мягко убери только то, что плохо воспринимается на слух: длинные пути, хэши, ID, команды, JSON, " - "длинные списки файлов, точные размеры и счётчики символов. Если деталь важна, замени её коротким описанием. " - "Не добавляй новых фактов. Пиши естественно, без markdown, близко к исходному тексту." - ), - }, - { - "role": "user", - "content": f"Переделай этот финальный текст в вариант для озвучки:\n\n{source}", - }, - ], - "temperature": 0.2, - "max_tokens": self.cfg.openai_voice_rewrite_max_output_tokens, - } - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request("https://api.openai.com/v1/chat/completions", method="POST", data=data) - req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") - req.add_header("Content-Type", "application/json") - try: - with request.urlopen(req, timeout=self.cfg.openai_voice_rewrite_timeout_seconds) as resp: - raw = resp.read().decode("utf-8", errors="replace") - except TimeoutError as e: - raise VoiceReplyError( - f"OpenAI не успел адаптировать текст за {self.cfg.openai_voice_rewrite_timeout_seconds} секунд." - ) from e - except error.HTTPError as e: - detail = e.read().decode("utf-8", errors="replace") - if e.code == 401: - message = "OpenAI отклонил ключ API для адаптации текста." - elif e.code == 429: - message = "OpenAI временно ограничил адаптацию текста из-за лимита запросов." - elif e.code >= 500: - message = "OpenAI временно не смог адаптировать текст." - else: - message = f"OpenAI вернул ошибку HTTP {e.code} при адаптации текста." - if detail: - message = f"{message} Детали: {detail[:500]}" - raise VoiceReplyError(message) from e - except error.URLError as e: - raise VoiceReplyError(f"не удалось отправить текст в OpenAI для адаптации из-за сетевой ошибки: {e.reason}") from e - - try: - body = json.loads(raw) - content = (((body.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() - except Exception as e: - raise VoiceReplyError("OpenAI вернул неразборчивый ответ при адаптации текста.") from e - if not content: - raise VoiceReplyError("OpenAI вернул пустой текст адаптации.") - return content - - def _openai_tts(self, text: str) -> bytes: - payload = { - "model": self.cfg.openai_tts_model, - "voice": self.cfg.openai_tts_voice, - "input": text, - "response_format": self.cfg.openai_tts_response_format, - } - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request("https://api.openai.com/v1/audio/speech", method="POST", data=data) - req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") - req.add_header("Content-Type", "application/json") - try: - with request.urlopen(req, timeout=self.cfg.openai_tts_timeout_seconds) as resp: - audio = resp.read() - except TimeoutError as e: - raise VoiceReplyError(f"OpenAI не успел сгенерировать речь за {self.cfg.openai_tts_timeout_seconds} секунд.") from e - except error.HTTPError as e: - detail = e.read().decode("utf-8", errors="replace") - if e.code == 401: - message = "OpenAI отклонил ключ API для озвучивания." - elif e.code == 429: - message = "OpenAI временно ограничил озвучивание из-за лимита запросов." - elif e.code >= 500: - message = "OpenAI временно не смог сгенерировать речь." - else: - message = f"OpenAI вернул ошибку HTTP {e.code} при озвучивании." - if detail: - message = f"{message} Детали: {detail[:500]}" - raise VoiceReplyError(message) from e - except error.URLError as e: - raise VoiceReplyError(f"не удалось отправить текст в OpenAI TTS из-за сетевой ошибки: {e.reason}") from e - if not audio: - raise VoiceReplyError("OpenAI вернул пустой аудиофайл.") - return audio - - def _transcribe_voice_job( - self, - job: dict[str, Any], - *, - status_cb: Callable[[str], Any] | None = None, - ) -> str: - if not self.cfg.openai_api_key: - raise VoiceTranscriptionError( - "не настроен ключ OpenAI для распознавания.", - stage="config", - retryable=False, - ) - file_id = (job.get("telegram_file_id") or "").strip() - if not 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() - duration_seconds = int(job.get("telegram_duration_seconds") or 0) - telegram_file_size = int(job.get("telegram_file_size") or 0) - file_looks_big_for_cloud = self._telegram_cloud_download_is_likely_too_big(telegram_file_size) - if file_looks_big_for_cloud and status_cb is not None: - status_cb( - "файл большой, всё равно пробую скачать его из Telegram. " - f"Предварительный размер около {self._bytes_to_mb(telegram_file_size)} MB." - ) - 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, - ) - if file_looks_big_for_cloud and status_cb is not None: - status_cb( - "скачивание из Telegram прошло успешно. " - f"Фактический размер около {self._bytes_to_mb(len(file_bytes))} MB, дальше готовлю аудио и отправляю в OpenAI." - ) - prepared_parts = self._prepare_audio_parts_for_transcription( - file_bytes, - filename, - duration_seconds=duration_seconds, - job_id=job_id, - job_num=job_num, - ) - print( - f"[py-bot] transcribe prepared job={job_id} parts={len(prepared_parts)} duration={duration_seconds}s", - flush=True, - ) - parts_text: list[str] = [] - prompt_tail = "" - for index, (part_bytes, part_name) in enumerate(prepared_parts, start=1): - print( - f"[py-bot] transcribe part job={job_id} index={index}/{len(prepared_parts)} filename={part_name} size={len(part_bytes)} bytes", - flush=True, - ) - part_text = self._openai_transcribe(part_bytes, part_name, prompt=prompt_tail).strip() - if part_text: - parts_text.append(part_text) - prompt_tail = self._transcription_prompt_tail("\n".join(parts_text)) - text = "\n".join(parts_text).strip() - if not text: - 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]: - 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: - detail = str(e) - if "file is too big" in detail.lower(): - raise VoiceTranscriptionError( - "Файл большой: я попробовал скачать его через текущий Telegram Bot API, " - "но Telegram не дал это сделать. Для такого аудио нужен локальный `telegram-bot-api` " - "сервер или другой способ передать файл боту.", - stage="telegram_get_file_too_big", - retryable=False, - detail=detail, - ) from e - raise VoiceTranscriptionError( - "не удалось получить информацию о файле из Telegram.", - stage="telegram_get_file", - detail=detail, - ) from e - info = result.get("result") or {} - file_path = info.get("file_path") - if not file_path: - raise VoiceTranscriptionError( - "Telegram не вернул путь к файлу.", - stage="telegram_file_path", - retryable=True, - detail=json.dumps(info, ensure_ascii=False)[:1000], - ) - file_url = self.telegram.file_base + file_path.lstrip("/") - req = request.Request(file_url, method="GET") - 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. - if lower.endswith(".oga"): - base = original_name[:-4] if len(original_name) > 4 else "audio" - normalized = f"{base}.ogg" - else: - normalized = original_name - return data, normalized - - def _prepare_audio_parts_for_transcription( - self, - file_bytes: bytes, - filename: str, - *, - duration_seconds: int, - job_id: str, - job_num: Any, - ) -> list[tuple[bytes, str]]: - needs_duration_chunking = duration_seconds > self.cfg.openai_transcribe_max_chunk_seconds - if len(file_bytes) <= self.cfg.openai_transcribe_max_upload_bytes and not needs_duration_chunking: - return [(file_bytes, filename)] - ffmpeg_path = shutil.which(self.cfg.ffmpeg_bin) - ffprobe_path = shutil.which(self.cfg.ffprobe_bin) - if not ffmpeg_path or not ffprobe_path: - raise VoiceTranscriptionError( - "для длинного аудио нужен локальный `ffmpeg`/`ffprobe`, но они не найдены в системе.", - stage="audio_prepare_tools_missing", - retryable=False, - ) - with tempfile.TemporaryDirectory(prefix="shine-audio-") as tmpdir: - tmp = Path(tmpdir) - input_suffix = Path(filename).suffix or ".ogg" - input_path = tmp / f"source{input_suffix}" - input_path.write_bytes(file_bytes) - prepared_path = tmp / "prepared.ogg" - self._ffmpeg_reencode_audio(input_path, prepared_path) - prepared_bytes = prepared_path.read_bytes() - prepared_duration = self._ffprobe_duration_seconds(prepared_path) - if ( - len(prepared_bytes) <= self.cfg.openai_transcribe_max_upload_bytes - and prepared_duration <= self.cfg.openai_transcribe_max_chunk_seconds - ): - return [(prepared_bytes, prepared_path.name)] - chunk_length = self._choose_transcription_chunk_seconds(prepared_duration, len(prepared_bytes)) - print( - f"[py-bot] audio chunking job={job_id} num={job_num} duration={prepared_duration:.1f}s total_bytes={len(prepared_bytes)} chunk_seconds={chunk_length}", - flush=True, - ) - chunks: list[tuple[bytes, str]] = [] - offset = 0 - index = 1 - total_duration = max(1, int(prepared_duration + 0.999)) - while offset < total_duration: - chunk_path = tmp / f"chunk_{index:03d}.ogg" - self._ffmpeg_extract_audio_chunk(prepared_path, chunk_path, offset, chunk_length) - chunk_bytes = chunk_path.read_bytes() - if not chunk_bytes: - break - if len(chunk_bytes) > self.cfg.openai_transcribe_max_upload_bytes: - raise VoiceTranscriptionError( - "локальная нарезка аудио дала слишком большой кусок для OpenAI; нужно уменьшить размер чанка.", - stage="audio_chunk_too_large", - retryable=False, - ) - chunks.append((chunk_bytes, chunk_path.name)) - step = max(1, chunk_length - self.cfg.openai_transcribe_overlap_seconds) - offset += step - index += 1 - if not chunks: - raise VoiceTranscriptionError( - "не удалось подготовить куски аудио для распознавания.", - stage="audio_chunk_empty", - retryable=False, - ) - return chunks - - def _ffmpeg_reencode_audio(self, input_path: Path, output_path: Path) -> None: - cmd = [ - self.cfg.ffmpeg_bin, - "-y", - "-i", - str(input_path), - "-vn", - "-ac", - "1", - "-ar", - "16000", - "-c:a", - "libopus", - "-b:a", - f"{self.cfg.openai_transcribe_reencode_bitrate_kbps}k", - str(output_path), - ] - self._run_subprocess_checked(cmd, "audio_reencode_ffmpeg") - - def _ffmpeg_extract_audio_chunk(self, input_path: Path, output_path: Path, offset_seconds: int, chunk_seconds: int) -> None: - cmd = [ - self.cfg.ffmpeg_bin, - "-y", - "-ss", - str(offset_seconds), - "-t", - str(chunk_seconds), - "-i", - str(input_path), - "-vn", - "-acodec", - "copy", - str(output_path), - ] - self._run_subprocess_checked(cmd, "audio_chunk_ffmpeg") - - def _ffprobe_duration_seconds(self, audio_path: Path) -> float: - cmd = [ - self.cfg.ffprobe_bin, - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - str(audio_path), - ] - try: - result = subprocess.run( - cmd, - check=True, - capture_output=True, - text=True, - timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, - ) - except subprocess.TimeoutExpired as e: - raise VoiceTranscriptionError( - f"`ffprobe` не успел определить длительность аудио за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", - stage="audio_probe_timeout", - retryable=False, - ) from e - except subprocess.CalledProcessError as e: - detail = (e.stderr or e.stdout or "").strip() - raise VoiceTranscriptionError( - "не удалось определить длительность аудио через `ffprobe`.", - stage="audio_probe_failed", - retryable=False, - detail=detail[:1500], - ) from e - raw = (result.stdout or "").strip() - try: - return max(0.0, float(raw)) - except ValueError as e: - raise VoiceTranscriptionError( - "`ffprobe` вернул некорректную длительность аудио.", - stage="audio_probe_invalid", - retryable=False, - detail=raw[:300], - ) from e - - def _run_subprocess_checked(self, cmd: list[str], stage: str) -> None: - try: - subprocess.run( - cmd, - check=True, - capture_output=True, - text=True, - timeout=self.cfg.openai_transcribe_ffmpeg_timeout_seconds, - ) - except subprocess.TimeoutExpired as e: - raise VoiceTranscriptionError( - f"локальная обработка аудио не успела завершиться за {self.cfg.openai_transcribe_ffmpeg_timeout_seconds} секунд.", - stage=f"{stage}_timeout", - retryable=False, - ) from e - except subprocess.CalledProcessError as e: - detail = (e.stderr or e.stdout or "").strip() - raise VoiceTranscriptionError( - "локальная обработка аудио через `ffmpeg` завершилась с ошибкой.", - stage=f"{stage}_failed", - retryable=False, - detail=detail[:1500], - ) from e - - def _choose_transcription_chunk_seconds(self, duration_seconds: float, total_bytes: int) -> int: - max_chunk = self.cfg.openai_transcribe_max_chunk_seconds - safe_seconds = max(60, max_chunk - self.cfg.openai_transcribe_overlap_seconds) - if duration_seconds <= 0 or total_bytes <= 0: - return safe_seconds - bytes_per_second = total_bytes / max(duration_seconds, 1.0) - if bytes_per_second <= 0: - return safe_seconds - size_limited = int((self.cfg.openai_transcribe_max_upload_bytes * 0.9) / bytes_per_second) - return max(60, min(safe_seconds, size_limited if size_limited > 0 else safe_seconds)) - - @staticmethod - def _transcription_prompt_tail(text: str, limit: int = 1000) -> str: - source = compact_spaces(text) - if len(source) <= limit: - return source - return source[-limit:] - - def _telegram_cloud_download_is_likely_too_big(self, file_size: int) -> bool: - if file_size <= 0: - return False - using_cloud_api = self.cfg.telegram_api_base_url.rstrip("/") == "https://api.telegram.org" - return using_cloud_api and file_size > 20 * 1024 * 1024 - - @staticmethod - def _bytes_to_mb(value: int) -> str: - return f"{value / (1024 * 1024):.1f}" - - def _openai_transcribe(self, file_bytes: bytes, filename: str, prompt: str = "") -> str: - boundary = "----shine-boundary-" + "".join(random.choices("abcdef0123456789", k=16)) - mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" - - def text_part(name: str, value: str) -> bytes: - return ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n\r\n' - f"{value}\r\n" - ).encode("utf-8") - - body = bytearray() - body.extend(text_part("model", self.cfg.openai_transcribe_model)) - body.extend(text_part("response_format", "text")) - prompt = compact_spaces(prompt) - if prompt: - body.extend(text_part("prompt", prompt[:1000])) - body.extend( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' - f"Content-Type: {mime}\r\n\r\n" - ).encode("utf-8") - ) - body.extend(file_bytes) - body.extend(b"\r\n") - body.extend(f"--{boundary}--\r\n".encode("utf-8")) - - req = request.Request("https://api.openai.com/v1/audio/transcriptions", method="POST", data=bytes(body)) - 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=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") - 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: - s = (line or "").strip() - if not s.startswith("{"): - return None - try: - obj = json.loads(s) - except Exception: - return None - if obj.get("type") != "item.completed": - return None - item = obj.get("item") or {} - if item.get("type") != "agent_message": - return None - text = (item.get("text") or "").strip() - if not text: - return None - if len(text) > 220: - return text[:220] + "..." - return text - - @staticmethod - def _extract_fallback_message(lines: list[str]) -> str: - for line in reversed(lines): - line = line.strip() - if not line: - continue - if line.startswith("{") and '"type":' in line: - continue - if line.startswith("mcp:") or line.startswith("OpenAI Codex"): - continue - return line - return "" - - @staticmethod - def _extract_codex_thread_id(line: str) -> str: - s = (line or "").strip() - if not s.startswith("{"): - return "" - try: - obj = json.loads(s) - except Exception: - return "" - if obj.get("type") != "thread.started": - return "" - thread_id = (obj.get("thread_id") or "").strip() - return thread_id - - @staticmethod - def _is_missing_codex_session_error(text: str) -> bool: - lowered = (text or "").lower() - markers = [ - "session not found", - "conversation not found", - "thread not found", - "no session found", - "invalid session", - "unknown session", - "no conversation found", - "unknown thread", - ] - return any(marker in lowered for marker in markers) - - @staticmethod - def _format_duration(seconds: int) -> str: - seconds = max(0, seconds) - minutes, sec = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours}ч {minutes}м {sec}с" - if minutes: - return f"{minutes}м {sec}с" - return f"{sec}с" - - -def run_selftest(config: BotConfig, prompt: str) -> int: - cmd = [ - str(config.codex_bin), - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--json", - "-C", str(config.codex_workdir), - prompt, - ] - proc = subprocess.run( - cmd, - stdin=subprocess.DEVNULL, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - print(proc.stdout) - if proc.stderr: - print(proc.stderr) - return proc.returncode - - -def main() -> int: - parser = argparse.ArgumentParser(description="SHiNE Python Telegram bot wrapper for Codex CLI") - parser.add_argument("--selftest-codex", default="", help="Выполнить только codex exec с этим prompt и выйти") - args = parser.parse_args() - - root = Path(__file__).resolve().parent - cfg = BotConfig(root) - if args.selftest_codex: - return run_selftest(cfg, args.selftest_codex) - - service = ShinePyBotService(cfg) - try: - service.run() - except KeyboardInterrupt: - service.shutdown() - return 0 - except Exception as e: - print(f"[py-bot] FATAL: {e}", flush=True) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service b/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service deleted file mode 100644 index f166ef87..00000000 --- a/codex-agent-VPS/Agent-server-package/scripts/systemd/shine-agent-bot-coder.service +++ /dev/null @@ -1,23 +0,0 @@ -[Unit] -Description=SHiNE Agent Bot Coder (Telegram + Codex queue worker) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=your_user -Group=your_user -WorkingDirectory=/home/your_user/codex-agent -Environment=HOME=/home/your_user -Environment=PATH=/home/your_user/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -EnvironmentFile=/home/your_user/codex-agent/.env -ExecStart=/usr/bin/python3 /home/your_user/codex-agent/py_bot_service.py -Restart=always -RestartSec=5 -TimeoutStopSec=20 -SuccessExitStatus=143 0 -StandardOutput=append:/home/your_user/codex-agent/logs/service.log -StandardError=append:/home/your_user/codex-agent/logs/service.log - -[Install] -WantedBy=multi-user.target diff --git a/codex-agent-VPS/README.md b/codex-agent-VPS/README.md deleted file mode 100644 index 9ce3b9a2..00000000 --- a/codex-agent-VPS/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# codex-agent-VPS - -Переносимый комплект Telegram-бота для запуска `codex` CLI на VPS. - -## Структура -- `README.md` — краткое описание структуры. -- `AGENTS.md` — инструкции по установке и настройке через Codex. -- `.env.example` — верхнеуровневый пример конфига. -- `Agent-server-package/` — готовый комплект файлов для копирования на другой сервер. - -## Что копировать на сервер -На VPS обычно копируется содержимое папки: - -- `Agent-server-package/` - -Внутри неё лежат: -- `py_bot_service.py` -- `AGENT.md` -- `scripts/systemd/shine-agent-bot-coder.service` - -## Что настраивать -- взять `.env.example` из корня `codex-agent-VPS/` -- создать на сервере `.env` -- вписать Telegram bot token -- вписать разрешённые usernames -- указать путь к `codex` -- указать рабочую директорию `CODEX_WORKDIR` - -## Где инструкция -Полная инструкция по установке и настройке лежит в: - -- `AGENTS.md` diff --git a/create_git.sh b/create_git.sh deleted file mode 100644 index ac98d373..00000000 --- a/create_git.sh +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -GITHUB_USER="ai5590" -TOKEN_VAR_NAME="GIT_AI5590_CLASSIC_API_KEY" - -print_line() { - echo "------------------------------------------------------------" -} - -abort() { - echo - echo "Ошибка: $1" >&2 - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || abort "Не найдена команда '$1'. Установи её и запусти скрипт снова." -} - -get_token() { - if [[ -z "${GIT_AI5590_CLASSIC_API_KEY:-}" ]]; then - abort "Не задана переменная окружения ${TOKEN_VAR_NAME}. -Перед запуском выполни: -export ${TOKEN_VAR_NAME}=\"ТВОЙ_GITHUB_TOKEN\"" - fi -} - -show_intro() { - print_line - echo "Этот скрипт создаст новый репозиторий в GitHub в аккаунте '${GITHUB_USER}'," - echo "затем инициализирует git в текущей папке (если нужно)," - echo "добавит файлы, кроме самого этого скрипта, создаст первый commit и отправит проект в GitHub." - echo - echo "Скрипт работает с содержимым ТЕКУЩЕЙ папки:" - echo " $(pwd)" - echo - echo "Для авторизации используется переменная окружения:" - echo " ${TOKEN_VAR_NAME}" - print_line - echo -} - -ask_repo_name() { - local repo_name - read -r -p "Введите имя нового репозитория в GitHub: " repo_name - repo_name="$(echo "$repo_name" | xargs)" - - [[ -n "$repo_name" ]] || abort "Имя репозитория не может быть пустым." - - if [[ ! "$repo_name" =~ ^[A-Za-z0-9._-]+$ ]]; then - abort "Имя репозитория содержит недопустимые символы. -Разрешены: буквы, цифры, точка, дефис, подчёркивание." - fi - - REPO_NAME="$repo_name" -} - -ask_visibility() { - local answer - echo - read -r -p "Сделать репозиторий публичным? [y/N]: " answer - answer="${answer:-N}" - - case "$answer" in - y|Y|yes|YES|да|Да|ДА) - REPO_PRIVATE="false" - REPO_VISIBILITY_TEXT="public" - ;; - *) - REPO_PRIVATE="true" - REPO_VISIBILITY_TEXT="private" - ;; - esac -} - -ask_confirmation() { - echo - print_line - echo "Будет выполнено:" - echo "1. Создание GitHub-репозитория '${GITHUB_USER}/${REPO_NAME}' (${REPO_VISIBILITY_TEXT})" - echo "2. Подготовка git в текущей папке" - echo "3. Commit файлов из текущей папки, кроме самого этого скрипта" - echo "4. Push в ветку main" - print_line - echo - read -r -p "Продолжить? [y/N]: " confirm - confirm="${confirm:-N}" - - case "$confirm" in - y|Y|yes|YES|да|Да|ДА) ;; - *) echo "Отменено пользователем."; exit 0 ;; - esac -} - -check_not_inside_wrong_git_repo() { - if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - local top - top="$(git rev-parse --show-toplevel)" - if [[ "$top" != "$(pwd)" ]]; then - abort "Ты запустил скрипт внутри уже существующего git-репозитория, но не в его корне. -Корень репозитория: - $top - -Либо перейди в корень этого репозитория, либо запусти скрипт в папке, которая не вложена в другой git-репозиторий." - fi - fi -} - -create_github_repo() { - echo - echo "Создаю репозиторий в GitHub..." - - local http_code - local response_body_file - - response_body_file="$(mktemp)" - - http_code="$( - curl -sS \ - -o "$response_body_file" \ - -w "%{http_code}" \ - -X POST "https://api.github.com/user/repos" \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${GIT_AI5590_CLASSIC_API_KEY}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - -d "$(cat </dev/null 2>&1; then - echo "Git уже инициализирован." - else - git init - echo "Git инициализирован." - fi - - get_script_paths - - if [[ "$SCRIPT_INSIDE_PROJECT" == "true" ]]; then - echo "Скрипт находится внутри проекта и будет исключён из commit:" - echo " $SCRIPT_RELATIVE_PATH" - git add . ":!$SCRIPT_RELATIVE_PATH" - else - git add . - fi - - if git diff --cached --quiet; then - echo "В staged нет изменений. Возможно, файлы уже были закоммичены ранее." - else - git commit -m "Initial commit" - echo "Создан commit: Initial commit" - fi - - git branch -M main - - local remote_url="https://${GITHUB_USER}:${GIT_AI5590_CLASSIC_API_KEY}@github.com/${GITHUB_USER}/${REPO_NAME}.git" - - if git remote get-url origin >/dev/null 2>&1; then - echo "Remote 'origin' уже существует. Обновляю URL..." - git remote set-url origin "$remote_url" - else - git remote add origin "$remote_url" - fi -} - -push_to_github() { - echo - echo "Отправляю проект в GitHub..." - - git push -u origin main - - echo - echo "Готово." - echo "Репозиторий: https://github.com/${GITHUB_USER}/${REPO_NAME}" -} - -cleanup_remote_url() { - echo - echo "Убираю токен из remote URL, чтобы он не светился в git config..." - - local safe_url="https://github.com/${GITHUB_USER}/${REPO_NAME}.git" - git remote set-url origin "$safe_url" - - echo "Теперь origin = ${safe_url}" -} - -main() { - require_command git - require_command curl - require_command realpath - get_token - check_not_inside_wrong_git_repo - show_intro - ask_repo_name - ask_visibility - ask_confirmation - create_github_repo - prepare_git_repo - push_to_github - cleanup_remote_url -} - -main "$@" \ No newline at end of file diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md new file mode 100644 index 00000000..f83d1ee0 --- /dev/null +++ b/deploy/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS для deploy + +## Главное + +- Все вопросы деплоя SHiNE решать через эту папку `deploy/`. +- Скрипты лежат в `deploy/scripts/`. +- Production-серверы: `shineup.me` и `server2.shineup.me`. +- Test/devnet серверы: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. +- TURN-серверы: `turn1.shineup.me`, `turn2.shineup.me`, `turn3.shineup.me`. +- В deploy-документах и скриптах использовать домены, а не IP. + +## Production safety + +- Любой deploy на `shineup.me` или `server2.shineup.me` выполнять только после отдельного явного подтверждения пользователя. +- Перед production deploy обязательно проверить, что свежий бэкап лежит в `deploy/backup/archive/`. +- Production wrappers требуют наличие `deploy/backup/archive//MANIFEST.txt`. +- Секреты, `.env`, JWK, TURN shared secrets и приватные ключи не коммитить. + +## Как деплоить + +Сервер: + +```bash +bash deploy/scripts/_server.sh +``` + +UI: + +```bash +bash deploy/scripts/_ui.sh +``` + +Для настоящего `t2.shineup.me` использовать: + +```bash +bash deploy/scripts/test_t2_server.sh +bash deploy/scripts/test_t2_ui.sh +``` + +Не путать `server2.shineup.me` и `t2.shineup.me`. + +## Документы + +- `README.md` — карта deploy-папки. +- `PRODUCTION_SERVERS.md` — production. +- `TEST_SERVERS.md` — test/devnet. +- `TURN_SERVERS.md` — TURN. +- `CONFIGURE_TURN_IN_SHINE.md` — подключение TURN к SHiNE backend. +- `SETUP_SERVER_FROM_ZERO.md` — сервер + Caddy + UI с нуля. +- `SETUP_TURN_SERVER.md` — TURN с нуля. +- `backup/README.md` — backup. + +## Gradle + +- Gradle deploy-задачи удалены. +- Для сборки jar общий server deploy script вызывает `./gradlew shadowJar`. +- Для локального запуска можно использовать `./gradlew startLocal`. diff --git a/docs/deploy/agent-bot-coder-local-systemd.md b/deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md similarity index 62% rename from docs/deploy/agent-bot-coder-local-systemd.md rename to deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md index bc446945..8a4c0fc1 100644 --- a/docs/deploy/agent-bot-coder-local-systemd.md +++ b/deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md @@ -1,36 +1,48 @@ # Локальный деплой SHiNE-agent-bot-coder (systemd, пользователь ai) ## Где находится сервис -- Папка сервиса: `SHiNE-agent-bot-coder/` -- Systemd unit: `SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service` -- Скрипт установки: `SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh` + +- Папка сервиса находится рядом с репозиторием продукта: `../SHiNE-agent-bot-coder/` +- Systemd unit: `../SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service` +- Скрипт установки: `../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh` ## Предусловия + 1. Заполнен `.env` на основе `.env.example`. 2. Доступен рабочий Codex CLI: - `/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl` 3. На машине установлен `systemd --user`. ## Установка + Из корня репозитория: ```bash -bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh +bash ../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh +``` + +В `.env` агента `CODEX_WORKDIR` должен указывать на этот репозиторий продукта: + +```env +CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product ``` Скрипт: + 1. проверяет наличие `python3`; 2. копирует unit в `~/.config/systemd/user/`; 3. делает `systemctl --user daemon-reload`; 4. включает автозапуск и стартует сервис. ## Проверка + ```bash systemctl --user status shine-agent-bot-coder --no-pager journalctl --user -u shine-agent-bot-coder -f ``` ## Перезапуск после изменений + ```bash systemctl --user restart shine-agent-bot-coder ``` diff --git a/deploy/CONFIGURE_TURN_IN_SHINE.md b/deploy/CONFIGURE_TURN_IN_SHINE.md new file mode 100644 index 00000000..d35fb917 --- /dev/null +++ b/deploy/CONFIGURE_TURN_IN_SHINE.md @@ -0,0 +1,60 @@ +# Подключение TURN к SHiNE-серверу + +Клиент звонков запрашивает ICE-конфиг у backend через WS-операцию `GetCallIceConfig` и использует её для `RTCPeerConnection`. + +## Репозиторный конфиг + +В `SHiNE-server/src/main/resources/application.properties` можно хранить только публичные домены и пустые placeholders. + +Пример: + +```properties +call.ice.stun.urls=stun:stun.l.google.com:19302 +call.ice.turn.urls=turn:turn1.shineup.me:3478?transport=udp,turn:turn1.shineup.me:3478?transport=tcp +call.ice.turn.ttlSec=600 +call.ice.turn.userPrefix=shine +call.ice.turn.sharedSecret= + +call.ice.turn.servers.1.id=turn1 +call.ice.turn.servers.1.urls=turn:turn1.shineup.me:3478?transport=udp,turn:turn1.shineup.me:3478?transport=tcp +call.ice.turn.servers.1.sharedSecret= +``` + +## Production override + +Реальные `sharedSecret`, статические логины/пароли и другие секреты задавать только на сервере через внешний `application.properties` или override-конфиг. В git их не хранить. + +Рекомендуемый режим: + +- на coturn включить `use-auth-secret`; +- в coturn задать `static-auth-secret=`; +- в SHiNE-сервере задать такой же `call.ice.turn.sharedSecret` или `call.ice.turn.servers.N.sharedSecret`; +- сервер будет выдавать короткоживущие `turnUsername` / `turnPassword` с TTL. + +Fallback-режим: + +```properties +call.ice.turn.sharedSecret= +call.ice.turn.username=turn_user +call.ice.turn.password=turn_password +``` + +Fallback тоже не должен хранить реальные credentials в git. + +## Деплой после изменения TURN-настроек + +Для production сначала обновить бэкап в `deploy/backup/archive/`, затем выполнить нужный server deploy script: + +```bash +bash deploy/scripts/production_shineupme_server.sh +``` + +Если менялись только серверные TURN-настройки во внешнем override-конфиге, достаточно перезапустить соответствующий `shine-server.service`. + +## Проверка звонка + +1. Авторизоваться двумя клиентами. +2. Запустить звонок. +3. Проверить, что звонок устанавливается в сети, где прямой P2P затруднён. +4. В диагностике звонков смотреть `CallDeliveryReport`, `pcIceConnectionState`, `routeLabel`, `configuredTurnHosts*`, `reachableTurnHosts*`. +5. Если TURN недоступен, клиент должен откатиться к STUN-конфигу по умолчанию. diff --git a/deploy/PRODUCTION_SERVERS.md b/deploy/PRODUCTION_SERVERS.md new file mode 100644 index 00000000..0d9b82d9 --- /dev/null +++ b/deploy/PRODUCTION_SERVERS.md @@ -0,0 +1,59 @@ +# Production-серверы SHiNE + +Production-контуров два. В документах и скриптах использовать домены, а не IP: физический VPS можно заменить без изменения deploy-логики. + +## Основной production + +- Домен: `shineup.me` +- SSH: `player@shineup.me` +- Логин сервера SHiNE: `shineupme` +- Базовый каталог: `/home/player/SHiNE` +- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- Данные: `/home/player/SHiNE/shine-server/data/` +- Логи: `/home/player/SHiNE/shine-server/logs/app.log` +- systemd service: `shine-server.service` +- Caddy site: `shineup.me` +- WebSocket: `/ws` -> `127.0.0.1:7070` +- Solana cluster: `mainnet-beta` + +Deploy: + +```bash +bash deploy/scripts/production_shineupme_server.sh +bash deploy/scripts/production_shineupme_ui.sh +``` + +## Второй production + +- Домен: `server2.shineup.me` +- SSH: `player@server2.shineup.me` +- Логин сервера SHiNE: `server2shineupme` +- Базовый каталог: `/home/player/SHiNE` +- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- Данные: `/home/player/SHiNE/shine-server/data/` +- Логи: `/home/player/SHiNE/shine-server/logs/app.log` +- systemd service: `shine-server.service` +- Caddy site: `server2.shineup.me` +- WebSocket: `/ws` -> `127.0.0.1:7070` +- Solana cluster: `mainnet-beta` + +Deploy: + +```bash +bash deploy/scripts/production_server2_server.sh +bash deploy/scripts/production_server2_ui.sh +``` + +## Обязательное правило backup + +Перед любым production deploy нужно обновить локальный бэкап в `deploy/backup/archive/`. + +Production wrappers проверяют наличие `MANIFEST.txt` в `deploy/backup/archive//`. Если свежий бэкап не скопирован, deploy должен быть остановлен. + +## Запрещено + +- Не использовать IP как основной target deploy. +- Не возвращать старые deploy-алиасы с названием `test2`. +- Не считать `t2.shineup.me` вторым production: это test/devnet. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..78bd95ba --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,78 @@ +# Deploy SHiNE + +Эта папка — единая точка входа по деплою SHiNE. + +## Правило + +- Все deploy-документы, deploy-скрипты и backup-инструкции держать здесь. +- Deploy-привязки задавать через домены, а не через IP. +- Production-серверы менять только после отдельного явного подтверждения пользователя. +- Production deploy выполнять только после обновления локального бэкапа в `deploy/backup/archive/`. + +## Контуры + +- Основной production: `shineup.me`. +- Второй production: `server2.shineup.me`. +- Test/devnet стенд: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. + +## Основные документы + +- `PRODUCTION_SERVERS.md` — production-контуры. +- `TEST_SERVERS.md` — test/devnet-контуры. +- `TURN_SERVERS.md` — TURN-серверы. +- `CONFIGURE_TURN_IN_SHINE.md` — как подключить TURN к SHiNE backend. +- `SETUP_SERVER_FROM_ZERO.md` — настройка SHiNE-сервера и UI с нуля. +- `SETUP_TURN_SERVER.md` — настройка TURN через Caddy/DNS/TLS. +- `AGENT_BOT_CODER_LOCAL_SYSTEMD.md` — локальный systemd-deploy Telegram-бота агента-кодера. +- `AGENTS.md` — правила для агента при деплое. + +## Скрипты + +Общие скрипты: + +- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service. +- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`. + +Production wrappers: + +- `scripts/production_shineupme_server.sh` +- `scripts/production_shineupme_ui.sh` +- `scripts/production_server2_server.sh` +- `scripts/production_server2_ui.sh` + +Test/devnet wrappers: + +- `scripts/test_t1_server.sh` +- `scripts/test_t1_ui.sh` +- `scripts/test_t2_server.sh` +- `scripts/test_t2_ui.sh` +- `scripts/test_t3_server.sh` +- `scripts/test_t3_ui.sh` +- `scripts/test_t4_server.sh` +- `scripts/test_t4_ui.sh` + +## Примеры + +UI на настоящий тестовый `t2.shineup.me`: + +```bash +bash deploy/scripts/test_t2_ui.sh +``` + +Сервер на настоящий тестовый `t2.shineup.me`: + +```bash +bash deploy/scripts/test_t2_server.sh +``` + +Production UI на `server2.shineup.me`: + +```bash +bash deploy/scripts/production_server2_ui.sh +``` + +Production server на `shineup.me`: + +```bash +bash deploy/scripts/production_shineupme_server.sh +``` diff --git a/deploy/SETUP_SERVER_FROM_ZERO.md b/deploy/SETUP_SERVER_FROM_ZERO.md new file mode 100644 index 00000000..de4b6f6d --- /dev/null +++ b/deploy/SETUP_SERVER_FROM_ZERO.md @@ -0,0 +1,113 @@ +# Настройка SHiNE-сервера с нуля + +Инструкция описывает базовую подготовку существующего Linux/VPS-хоста под SHiNE server + UI. Привязка должна идти к домену, а не к IP. + +## 1. DNS + +Создать DNS-запись домена: + +- production: `shineup.me` или `server2.shineup.me`; +- test/devnet: `t1.shineup.me` ... `t4.shineup.me`. + +После смены физического сервера достаточно обновить DNS. + +## 2. Пользователь и пакеты + +```bash +sudo apt update +sudo apt install -y openjdk-17-jre-headless rsync caddy +sudo useradd -m -s /bin/bash player || true +``` + +## 3. Каталоги + +Production: + +```bash +mkdir -p /home/player/SHiNE/shine-server/data +mkdir -p /home/player/SHiNE/shine-server/logs +mkdir -p /home/player/SHiNE/shine-ui +``` + +Test/devnet: + +```bash +mkdir -p /home/player/tX/server/data +mkdir -p /home/player/tX/server/logs +mkdir -p /home/player/tX/UI +``` + +## 4. application.properties + +Каждый сервер должен иметь локальный внешний конфиг в рабочей директории сервиса. + +Production пример: + +```properties +server.port=7070 +server.SHiNE.login=shineupme +db.path=data/shine.sqlite +server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html +server.info.url=https://shineup.me +server.info.origin=production +solana.cluster=mainnet-beta +``` + +Test/devnet пример: + +```properties +server.port=7102 +server.SHiNE.login=server_t2 +db.path=data/shine.sqlite +server.ui.indexPath=/home/player/t2/UI/index.html +server.info.url=https://t2.shineup.me +server.info.origin=devnet +solana.cluster=devnet +solana.rpcUrl=https://api.devnet.solana.com +``` + +## 5. Caddy + +Минимальный site block: + +```caddyfile +t2.shineup.me { + encode zstd gzip + + @ws path /ws /ws/* + handle @ws { + reverse_proxy 127.0.0.1:7102 + } + + handle { + root * /home/player/t2/UI + try_files {path} /index.html + file_server + header -Etag + header { + Cache-Control "no-store, no-cache, must-revalidate, max-age=0" + Pragma "no-cache" + Expires "0" + } + } +} +``` + +Проверка: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl restart caddy +curl -I https://t2.shineup.me +``` + +## 6. Первый deploy + +Из репозитория: + +```bash +bash deploy/scripts/test_t2_server.sh +bash deploy/scripts/test_t2_ui.sh +``` + +Для production перед этими командами обязательно обновить `deploy/backup/archive/`. diff --git a/deploy/SETUP_TURN_SERVER.md b/deploy/SETUP_TURN_SERVER.md new file mode 100644 index 00000000..cb2d291f --- /dev/null +++ b/deploy/SETUP_TURN_SERVER.md @@ -0,0 +1,85 @@ +# Настройка TURN-сервера + +TURN-хосты SHiNE должны использовать домены: + +- `turn1.shineup.me` +- `turn2.shineup.me` +- `turn3.shineup.me` + +IP не фиксировать в документах и скриптах как основной идентификатор. + +## 1. DNS + +Создать или обновить DNS `A/AAAA` для нужного `turnX.shineup.me`. + +## 2. Пакеты + +```bash +sudo apt update +sudo apt install -y coturn caddy +``` + +Можно использовать вспомогательный скрипт на самом TURN-хосте: + +```bash +sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET +``` + +## 3. coturn + +Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override. + +Минимальная схема: + +```conf +listening-port=3478 +tls-listening-port=5349 +fingerprint +lt-cred-mech +realm=turn1.shineup.me +server-name=turn1.shineup.me +use-auth-secret +static-auth-secret= +no-multicast-peers +no-cli +``` + +Включить сервис: + +```bash +sudo systemctl enable coturn +sudo systemctl restart coturn +``` + +## 4. Caddy + +Caddy можно использовать для HTTPS health/check endpoint и автоматического TLS на домене. + +Пример: + +```caddyfile +turn1.shineup.me { + respond /health "ok" 200 +} +``` + +Проверка: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl restart caddy +curl -I https://turn1.shineup.me/health +``` + +## 5. Проверка портов + +```bash +sudo systemctl --no-pager --full status coturn caddy +sudo ss -lntup | grep -E ':(3478|5349)' +``` + +## 6. Важное + +- TURN credentials и shared secret не коммитить. +- Если TURN домен переносится на другой VPS, менять DNS, а не код приложения. +- После изменения TURN-адресов обновить серверные/UI настройки, где они используются. diff --git a/deploy/TEST_SERVERS.md b/deploy/TEST_SERVERS.md new file mode 100644 index 00000000..95af47d1 --- /dev/null +++ b/deploy/TEST_SERVERS.md @@ -0,0 +1,61 @@ +# Test/devnet серверы SHiNE + +Отдельный test/devnet стенд состоит из четырёх независимых SHiNE-инстансов: + +- `t1.shineup.me` +- `t2.shineup.me` +- `t3.shineup.me` +- `t4.shineup.me` + +Это не production. Все deploy-цели задаются через домены. + +## Общие параметры + +- SSH: `player@tX.shineup.me` +- Solana cluster: `devnet` +- Solana RPC: `https://api.devnet.solana.com` +- Caddy config: `/etc/caddy/Caddyfile` + +## Инстансы + +| Домен | Логин сервера | Сервер | UI | Порт | systemd | +|---|---|---|---|---|---| +| `t1.shineup.me` | `server_t1` | `/home/player/t1/server` | `/home/player/t1/UI` | `7101` | `shine-t1.service` | +| `t2.shineup.me` | `server_t2` | `/home/player/t2/server` | `/home/player/t2/UI` | `7102` | `shine-t2.service` | +| `t3.shineup.me` | `server_t3` | `/home/player/t3/server` | `/home/player/t3/UI` | `7103` | `shine-t3.service` | +| `t4.shineup.me` | `server_t4` | `/home/player/t4/server` | `/home/player/t4/UI` | `7104` | `shine-t4.service` | + +## Deploy + +```bash +bash deploy/scripts/test_t1_server.sh +bash deploy/scripts/test_t1_ui.sh + +bash deploy/scripts/test_t2_server.sh +bash deploy/scripts/test_t2_ui.sh + +bash deploy/scripts/test_t3_server.sh +bash deploy/scripts/test_t3_ui.sh + +bash deploy/scripts/test_t4_server.sh +bash deploy/scripts/test_t4_ui.sh +``` + +## Проверка + +```bash +curl -I https://t1.shineup.me +curl -I https://t2.shineup.me +curl -I https://t3.shineup.me +curl -I https://t4.shineup.me +``` + +На сервере: + +```bash +sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 +``` + +## Operational-нюанс + +Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов. Если после деплоя какой-то инстанс не подтянул `sync_servers`, рестартовать сервисы по одному с паузой. diff --git a/deploy/TURN_SERVERS.md b/deploy/TURN_SERVERS.md new file mode 100644 index 00000000..3107cb69 --- /dev/null +++ b/deploy/TURN_SERVERS.md @@ -0,0 +1,40 @@ +# TURN-серверы SHiNE + +TURN-серверы использовать и документировать через домены, а не через IP. + +## Текущие домены + +- `turn1.shineup.me` +- `turn2.shineup.me` +- `turn3.shineup.me` + +## Назначение + +TURN нужен для WebRTC-звонков, когда прямое peer-to-peer соединение невозможно из-за NAT/firewall. + +## Базовые ожидания + +- DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN. +- TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки. +- `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`. +- Секреты TURN не хранить в git. + +## Что проверять + +```bash +dig +short turn1.shineup.me +dig +short turn2.shineup.me +dig +short turn3.shineup.me +``` + +На каждом TURN-хосте: + +```bash +sudo systemctl --no-pager --full status coturn caddy +sudo ss -lntup | grep -E ':(3478|5349)' +``` + +## Где настраивать + +- TURN-хост с нуля: `SETUP_TURN_SERVER.md`. +- Подключение TURN к SHiNE backend: `CONFIGURE_TURN_IN_SHINE.md`. diff --git a/server-backup/AGENTS.md b/deploy/backup/AGENTS.md similarity index 63% rename from server-backup/AGENTS.md rename to deploy/backup/AGENTS.md index 5ecf1863..7d01fb60 100644 --- a/server-backup/AGENTS.md +++ b/deploy/backup/AGENTS.md @@ -1,7 +1,7 @@ -# AGENTS для server-backup +# AGENTS для deploy/backup ## Назначение -- Папка `server-backup/` хранит: +- Папка `deploy/backup/` хранит: - тяжёлые локальные бэкапы сервера (НЕ в git); - лёгкую схему восстановления (в git), чтобы можно было поднять сервер даже без полного архива. @@ -11,19 +11,20 @@ - `backup-version.properties` — версия контура бэкапа. ## Правила -- Полный бэкап складывать только в `server-backup/archive/`. -- `server-backup/archive/**` не коммитить. +- Полный бэкап складывать только в `deploy/backup/archive/`. +- `deploy/backup/archive/**` не коммитить, кроме `.gitkeep`. +- Перед production deploy проверить, что свежий бэкап скопирован в `deploy/backup/archive/<дата>/` и есть `MANIFEST.txt`. - Любое изменение схемы восстановления фиксировать в git. - После обновления схемы увеличивать `backup.schema.version`. - После нового полного бэкапа увеличивать `backup.full.version`. ## Как обновлять бэкап 1. Обновить схему: - - `bash server-backup/scheme/shineup.me/scripts/refresh_scheme.sh` + - `bash deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh` 2. Сделать новый полный бэкап: - - `bash server-backup/scheme/shineup.me/scripts/backup_full.sh` -3. Проверить `server-backup/archive/<дата>/MANIFEST.txt`. -4. Поднять версии в `server-backup/backup-version.properties`. + - `bash deploy/backup/scheme/shineup.me/scripts/backup_full.sh` +3. Проверить `deploy/backup/archive/<дата>/MANIFEST.txt`. +4. Поднять версии в `deploy/backup/backup-version.properties`. ## Как восстанавливать -- Смотреть `server-backup/scheme/shineup.me/docs/RESTORE.md`. +- Смотреть `deploy/backup/scheme/shineup.me/docs/RESTORE.md`. diff --git a/deploy/backup/README.md b/deploy/backup/README.md new file mode 100644 index 00000000..38a089df --- /dev/null +++ b/deploy/backup/README.md @@ -0,0 +1,9 @@ +# deploy/backup + +- `archive/` — локальные полные бэкапы по датам (не коммитятся, кроме `.gitkeep`). +- `scheme/` — лёгкая схема восстановления (коммитится). +- `backup-version.properties` — версии схемы и полного бэкапа. + +Production deploy требует, чтобы перед запуском свежий бэкап был скопирован в `deploy/backup/archive//` и содержал `MANIFEST.txt`. + +Основной сервер-источник: `shineup.me`. diff --git a/Players/blackbyrd1/files/.gitkeep b/deploy/backup/archive/.gitkeep similarity index 100% rename from Players/blackbyrd1/files/.gitkeep rename to deploy/backup/archive/.gitkeep diff --git a/server-backup/backup-version.properties b/deploy/backup/backup-version.properties similarity index 100% rename from server-backup/backup-version.properties rename to deploy/backup/backup-version.properties diff --git a/server-backup/scheme/shineup.me/captures/01_host_disk.txt b/deploy/backup/scheme/shineup.me/captures/01_host_disk.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/01_host_disk.txt rename to deploy/backup/scheme/shineup.me/captures/01_host_disk.txt diff --git a/server-backup/scheme/shineup.me/captures/02_enabled_services.txt b/deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/02_enabled_services.txt rename to deploy/backup/scheme/shineup.me/captures/02_enabled_services.txt diff --git a/server-backup/scheme/shineup.me/captures/03_running_services.txt b/deploy/backup/scheme/shineup.me/captures/03_running_services.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/03_running_services.txt rename to deploy/backup/scheme/shineup.me/captures/03_running_services.txt diff --git a/server-backup/scheme/shineup.me/captures/04_listen_ports.txt b/deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/04_listen_ports.txt rename to deploy/backup/scheme/shineup.me/captures/04_listen_ports.txt diff --git a/server-backup/scheme/shineup.me/captures/05_docker_ps.txt b/deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/05_docker_ps.txt rename to deploy/backup/scheme/shineup.me/captures/05_docker_ps.txt diff --git a/server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt b/deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt rename to deploy/backup/scheme/shineup.me/captures/06_home_player_sizes.txt diff --git a/server-backup/scheme/shineup.me/captures/07_var_sizes.txt b/deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/07_var_sizes.txt rename to deploy/backup/scheme/shineup.me/captures/07_var_sizes.txt diff --git a/server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt b/deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt similarity index 100% rename from server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt rename to deploy/backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt diff --git a/server-backup/scheme/shineup.me/configs/Caddyfile b/deploy/backup/scheme/shineup.me/configs/Caddyfile similarity index 100% rename from server-backup/scheme/shineup.me/configs/Caddyfile rename to deploy/backup/scheme/shineup.me/configs/Caddyfile diff --git a/server-backup/scheme/shineup.me/configs/systemd/agent-memory.service b/deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service similarity index 100% rename from server-backup/scheme/shineup.me/configs/systemd/agent-memory.service rename to deploy/backup/scheme/shineup.me/configs/systemd/agent-memory.service diff --git a/server-backup/scheme/shineup.me/configs/systemd/shine-server.service b/deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service similarity index 100% rename from server-backup/scheme/shineup.me/configs/systemd/shine-server.service rename to deploy/backup/scheme/shineup.me/configs/systemd/shine-server.service diff --git a/server-backup/scheme/shineup.me/configs/turnserver.conf b/deploy/backup/scheme/shineup.me/configs/turnserver.conf similarity index 99% rename from server-backup/scheme/shineup.me/configs/turnserver.conf rename to deploy/backup/scheme/shineup.me/configs/turnserver.conf index 4d8d5cfb..4ef9e9f9 100644 --- a/server-backup/scheme/shineup.me/configs/turnserver.conf +++ b/deploy/backup/scheme/shineup.me/configs/turnserver.conf @@ -706,4 +706,4 @@ syslog #no-tlsv1 #no-tlsv1_1 #no-tlsv1_2 -static-auth-secret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106 +static-auth-secret= diff --git a/server-backup/scheme/shineup.me/docs/RESTORE.md b/deploy/backup/scheme/shineup.me/docs/RESTORE.md similarity index 76% rename from server-backup/scheme/shineup.me/docs/RESTORE.md rename to deploy/backup/scheme/shineup.me/docs/RESTORE.md index 00613434..cfb2cd84 100644 --- a/server-backup/scheme/shineup.me/docs/RESTORE.md +++ b/deploy/backup/scheme/shineup.me/docs/RESTORE.md @@ -12,18 +12,18 @@ sudo apt install -y rsync caddy coturn docker.io ``` ## 3. Восстановление файлов из полного бэкапа -Предполагается, что полный бэкап лежит локально в `server-backup/archive/YYYY-MM-DD/`. +Предполагается, что полный бэкап лежит локально в `deploy/backup/archive/YYYY-MM-DD/`. ```bash -rsync -a server-backup/archive/YYYY-MM-DD/home-player/SHiNE/ player@NEW_SERVER:/home/player/SHiNE/ -rsync -a server-backup/archive/YYYY-MM-DD/home-player/sites/ player@NEW_SERVER:/home/player/sites/ -rsync -a server-backup/archive/YYYY-MM-DD/home-player/gitea/ player@NEW_SERVER:/home/player/gitea/ -rsync -a server-backup/archive/YYYY-MM-DD/home-player/agent-memory/ player@NEW_SERVER:/home/player/agent-memory/ +rsync -a deploy/backup/archive/YYYY-MM-DD/home-player/SHiNE/ player@NEW_SERVER:/home/player/SHiNE/ +rsync -a deploy/backup/archive/YYYY-MM-DD/home-player/sites/ player@NEW_SERVER:/home/player/sites/ +rsync -a deploy/backup/archive/YYYY-MM-DD/home-player/gitea/ player@NEW_SERVER:/home/player/gitea/ +rsync -a deploy/backup/archive/YYYY-MM-DD/home-player/agent-memory/ player@NEW_SERVER:/home/player/agent-memory/ -rsync -a server-backup/archive/YYYY-MM-DD/etc-system/caddy/ player@NEW_SERVER:/tmp/restore-caddy/ -rsync -a server-backup/archive/YYYY-MM-DD/var-lib/caddy/ player@NEW_SERVER:/tmp/restore-var-lib-caddy/ -rsync -a server-backup/archive/YYYY-MM-DD/etc-system/turnserver.conf player@NEW_SERVER:/tmp/turnserver.conf -rsync -a server-backup/archive/YYYY-MM-DD/etc-system/*.service player@NEW_SERVER:/tmp/ +rsync -a deploy/backup/archive/YYYY-MM-DD/etc-system/caddy/ player@NEW_SERVER:/tmp/restore-caddy/ +rsync -a deploy/backup/archive/YYYY-MM-DD/var-lib/caddy/ player@NEW_SERVER:/tmp/restore-var-lib-caddy/ +rsync -a deploy/backup/archive/YYYY-MM-DD/etc-system/turnserver.conf player@NEW_SERVER:/tmp/turnserver.conf +rsync -a deploy/backup/archive/YYYY-MM-DD/etc-system/*.service player@NEW_SERVER:/tmp/ ``` Далее на новом сервере: diff --git a/deploy/backup/scheme/shineup.me/scripts/backup_full.sh b/deploy/backup/scheme/shineup.me/scripts/backup_full.sh new file mode 100755 index 00000000..4e2913e6 --- /dev/null +++ b/deploy/backup/scheme/shineup.me/scripts/backup_full.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +TODAY="$(date +%F)" +DEST_NAME="${DEST_NAME:-${TODAY}}" +DEST_DIR="${ROOT_DIR}/archive/${DEST_NAME}" +RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync") + +mkdir -p "${DEST_DIR}" + +remote_exists() { + ssh "${REMOTE_HOST}" "test -e '$1'" +} + +copy_optional_dir() { + local remote_path="$1" + local local_path="$2" + if remote_exists "$remote_path"; then + rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}/" "${local_path}/" + else + mkdir -p "${local_path}" + echo "missing: ${remote_path}" > "${local_path}/MISSING.txt" + fi +} + +copy_optional_file() { + local remote_path="$1" + local local_path="$2" + if remote_exists "$remote_path"; then + rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}" + else + mkdir -p "$(dirname "${local_path}")" + echo "missing: ${remote_path}" > "${local_path}.MISSING.txt" + fi +} + +echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}" +mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images" + +echo "[2/4] Копирую основные данные /home/player" +rsync -aH --delete \ + "${RSYNC_REMOTE_SUDO[@]}" \ + --exclude='SHiNE/shine-server/logs/' \ + --exclude='SHiNE/**/.elaira_logs/' \ + --exclude='**/*.log' \ + "${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/" + +copy_optional_dir "/home/player/sites" "${DEST_DIR}/home-player/sites" +copy_optional_dir "/home/player/gitea" "${DEST_DIR}/home-player/gitea" +copy_optional_dir "/home/player/agent-memory" "${DEST_DIR}/home-player/agent-memory" + +echo "[3/4] Копирую системные конфиги" +copy_optional_dir "/etc/caddy" "${DEST_DIR}/etc-system/caddy" +copy_optional_dir "/var/lib/caddy" "${DEST_DIR}/var-lib/caddy" +copy_optional_file "/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf" +copy_optional_file "/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/shine-server.service" +copy_optional_file "/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/agent-memory.service" +if ssh "${REMOTE_HOST}" 'command -v docker >/dev/null 2>&1 && sudo -n docker image inspect gitea/gitea:1.22.6 >/dev/null 2>&1'; then + ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar" +else + echo "missing: docker image gitea/gitea:1.22.6" > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar.MISSING.txt" +fi + +echo "[4/4] Создаю манифест" +{ + echo "backup_date=${TODAY}" + echo "remote_host=${REMOTE_HOST}" + echo "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo + echo "sizes:" + du -sh "${DEST_DIR}/home-player/SHiNE" || true + du -sh "${DEST_DIR}/home-player/sites" || true + du -sh "${DEST_DIR}/home-player/gitea" || true + du -sh "${DEST_DIR}/home-player/agent-memory" || true + du -sh "${DEST_DIR}/etc-system" || true + du -sh "${DEST_DIR}/var-lib/caddy" || true + du -sh "${DEST_DIR}/var-lib/docker-images" || true + echo + echo "total:" + du -sh "${DEST_DIR}" || true +} > "${DEST_DIR}/MANIFEST.txt" + +echo "Готово: ${DEST_DIR}" diff --git a/server-backup/scheme/shineup.me/scripts/refresh_scheme.sh b/deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh similarity index 100% rename from server-backup/scheme/shineup.me/scripts/refresh_scheme.sh rename to deploy/backup/scheme/shineup.me/scripts/refresh_scheme.sh diff --git a/deploy/scripts/deploy_server.sh b/deploy/scripts/deploy_server.sh new file mode 100755 index 00000000..e3647e03 --- /dev/null +++ b/deploy/scripts/deploy_server.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +REMOTE_HOST="${REMOTE_HOST:?REMOTE_HOST is required, example: player@shineup.me}" +TARGET_DOMAIN="${TARGET_DOMAIN:?TARGET_DOMAIN is required, example: shineup.me}" +REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:?REMOTE_SERVER_DIR is required}" +REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" +REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" +REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:?REMOTE_SERVICE_NAME is required}" +SERVER_PORT="${SERVER_PORT:?SERVER_PORT is required}" +LOCAL_JAR="${LOCAL_JAR:-$ROOT_DIR/SHiNE-server/build/libs/shine-server.jar}" +BUILD_JAR="${BUILD_JAR:-1}" +REQUIRE_BACKUP="${REQUIRE_BACKUP:-0}" +BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/backup/archive}" + +if [[ "$REQUIRE_BACKUP" == "1" ]]; then + if ! find "$BACKUP_DIR" -mindepth 2 -maxdepth 2 -name MANIFEST.txt -print -quit 2>/dev/null | grep -q .; then + echo "ERROR: для production deploy сначала положите свежий бэкап в $BACKUP_DIR//MANIFEST.txt" >&2 + exit 1 + fi +fi + +if [[ "$BUILD_JAR" == "1" ]]; then + echo "==> Building server jar" + (cd "$ROOT_DIR" && ./gradlew shadowJar) +fi + +if [[ ! -f "$LOCAL_JAR" ]]; then + echo "ERROR: локальный jar не найден: $LOCAL_JAR" >&2 + exit 1 +fi + +echo "==> Deploy server to $TARGET_DOMAIN ($REMOTE_HOST)" +ssh -o BatchMode=yes -o ConnectTimeout=20 "$REMOTE_HOST" "echo SSH OK" >/dev/null +ssh "$REMOTE_HOST" "sudo -n true" +ssh "$REMOTE_HOST" "java -version >/dev/null 2>&1" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat >"$TMP_DIR/$REMOTE_SERVICE_NAME.service" </dev/null" + +echo "Всё хорошо: сервер $TARGET_DOMAIN обновлён и сервис $REMOTE_SERVICE_NAME перезапущен" diff --git a/deploy_shine-PWA.sh b/deploy/scripts/deploy_ui.sh similarity index 81% rename from deploy_shine-PWA.sh rename to deploy/scripts/deploy_ui.sh index c63dd683..f1924068 100755 --- a/deploy_shine-PWA.sh +++ b/deploy/scripts/deploy_ui.sh @@ -1,16 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -SRC_DIR="shine-UI" -REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}" -REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" -EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +SRC_DIR="$ROOT_DIR/shine-UI" +REMOTE_HOST="${REMOTE_HOST:?REMOTE_HOST is required, example: player@shineup.me}" +REMOTE_UI_DIR="${REMOTE_UI_DIR:?REMOTE_UI_DIR is required}" +EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-$REMOTE_UI_DIR}" ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}" -EXPECTED_CADDY_SITE="${EXPECTED_CADDY_SITE:-shineup.me}" +EXPECTED_CADDY_SITE="${EXPECTED_CADDY_SITE:?EXPECTED_CADDY_SITE is required}" +TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}" +DEPLOY_SERVER_LOGIN="${DEPLOY_SERVER_LOGIN:-}" +DEPLOY_SERVER_ADDRESS="${DEPLOY_SERVER_ADDRESS:-}" +DEPLOY_SOLANA_CLUSTER="${DEPLOY_SOLANA_CLUSTER:-}" +DEPLOY_SOLANA_ENDPOINT="${DEPLOY_SOLANA_ENDPOINT:-}" +REQUIRE_BACKUP="${REQUIRE_BACKUP:-0}" +BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/backup/archive}" +VERSION_FILE="$ROOT_DIR/VERSION.properties" BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)" -VERSION_FILE="VERSION.properties" export BUILD_VERSION -TMP_DIR="$(mktemp -d)" + +if [[ "$REQUIRE_BACKUP" == "1" ]]; then + if ! find "$BACKUP_DIR" -mindepth 2 -maxdepth 2 -name MANIFEST.txt -print -quit 2>/dev/null | grep -q .; then + echo "ERROR: для production deploy сначала положите свежий бэкап в $BACKUP_DIR//MANIFEST.txt" >&2 + exit 1 + fi +fi if [[ ! -f "$VERSION_FILE" ]]; then echo "ERROR: version file not found: $VERSION_FILE" >&2 @@ -24,18 +40,12 @@ if [[ -z "$CLIENT_VERSION" ]]; then fi export CLIENT_VERSION -TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}" -REMOTE_DIR="${REMOTE_UI_DIR}" -DEPLOY_SERVER_LOGIN="${DEPLOY_SERVER_LOGIN:-}" -DEPLOY_SERVER_ADDRESS="${DEPLOY_SERVER_ADDRESS:-}" -DEPLOY_SOLANA_CLUSTER="${DEPLOY_SOLANA_CLUSTER:-}" -DEPLOY_SOLANA_ENDPOINT="${DEPLOY_SOLANA_ENDPOINT:-}" DEPLOY_SOLANA_CLUSTER_NORMALIZED="$DEPLOY_SOLANA_CLUSTER" - if [[ "$DEPLOY_SOLANA_CLUSTER_NORMALIZED" == "mainnet" ]]; then DEPLOY_SOLANA_CLUSTER_NORMALIZED="mainnet-beta" fi +TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR" } @@ -48,7 +58,7 @@ fi echo "==> Preparing staged UI copy with build version: $BUILD_VERSION" echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION" -echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)" +echo "==> Deploy target: $TARGET_URL ($REMOTE_UI_DIR)" rsync -a "$SRC_DIR"/ "$TMP_DIR"/ DEPLOY_CONFIG_FILE="$TMP_DIR/js/deploy-config.js" @@ -156,12 +166,14 @@ elif [[ "$ROOT_CHECK_OUTPUT" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1" fi -echo "==> Preparing remote directory: $REMOTE_DIR" -ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_DIR'" +echo "==> Preparing remote directory: $REMOTE_UI_DIR" +ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_UI_DIR'" -echo "==> Syncing staged files to $REMOTE_DIR" +echo "==> Syncing staged files to $REMOTE_UI_DIR" rsync -rlvz --delete --omit-dir-times --no-perms --no-owner --no-group \ --rsync-path="sudo rsync" \ - "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/ + "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_UI_DIR"/ + +ssh "$REMOTE_HOST" "sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +" echo "Всё хорошо: $TARGET_URL" diff --git a/deploy/scripts/production_server2_server.sh b/deploy/scripts/production_server2_server.sh new file mode 100755 index 00000000..c3645c47 --- /dev/null +++ b/deploy/scripts/production_server2_server.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@server2.shineup.me" \ +TARGET_DOMAIN="server2.shineup.me" \ +REMOTE_SERVER_DIR="/home/player/SHiNE/shine-server" \ +REMOTE_SERVICE_NAME="shine-server" \ +SERVER_PORT="7070" \ +REQUIRE_BACKUP="1" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy/scripts/production_server2_ui.sh b/deploy/scripts/production_server2_ui.sh new file mode 100755 index 00000000..aa69af7f --- /dev/null +++ b/deploy/scripts/production_server2_ui.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@server2.shineup.me" \ +REMOTE_UI_DIR="/home/player/SHiNE/shine-ui" \ +EXPECTED_CADDY_UI_ROOT="/home/player/SHiNE/shine-ui" \ +EXPECTED_CADDY_SITE="server2.shineup.me" \ +DEPLOY_SERVER_LOGIN="server2shineupme" \ +DEPLOY_SERVER_ADDRESS="server2.shineup.me" \ +DEPLOY_SOLANA_CLUSTER="mainnet" \ +DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \ +TARGET_URL="https://server2.shineup.me" \ +REQUIRE_BACKUP="1" \ +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/production_shineupme_server.sh b/deploy/scripts/production_shineupme_server.sh new file mode 100755 index 00000000..5b7c554b --- /dev/null +++ b/deploy/scripts/production_shineupme_server.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@shineup.me" \ +TARGET_DOMAIN="shineup.me" \ +REMOTE_SERVER_DIR="/home/player/SHiNE/shine-server" \ +REMOTE_SERVICE_NAME="shine-server" \ +SERVER_PORT="7070" \ +REQUIRE_BACKUP="1" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy_shine-ui_production_shineupme.sh b/deploy/scripts/production_shineupme_ui.sh old mode 100644 new mode 100755 similarity index 87% rename from deploy_shine-ui_production_shineupme.sh rename to deploy/scripts/production_shineupme_ui.sh index e61a55c7..37fc0474 --- a/deploy_shine-ui_production_shineupme.sh +++ b/deploy/scripts/production_shineupme_ui.sh @@ -10,4 +10,5 @@ DEPLOY_SERVER_ADDRESS="shineup.me" \ DEPLOY_SOLANA_CLUSTER="mainnet" \ DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \ TARGET_URL="https://shineup.me" \ -bash "$(dirname "$0")/deploy_shine-PWA.sh" +REQUIRE_BACKUP="1" \ +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/scripts/setup_turn_coturn.sh b/deploy/scripts/setup_turn_coturn.sh similarity index 81% rename from scripts/setup_turn_coturn.sh rename to deploy/scripts/setup_turn_coturn.sh index 5a98944e..20494924 100755 --- a/scripts/setup_turn_coturn.sh +++ b/deploy/scripts/setup_turn_coturn.sh @@ -5,10 +5,10 @@ set -euo pipefail # Запускать НА TURN-сервере под root. # # Пример: -# sudo bash scripts/setup_turn_coturn.sh --secret "CHANGE_ME_LONG_SECRET" --realm "shineup.me" +# sudo bash deploy/scripts/setup_turn_coturn.sh --secret "CHANGE_ME_LONG_SECRET" --realm "turn1.shineup.me" SECRET="" -REALM="shineup.me" +REALM="turn1.shineup.me" MIN_PORT="49160" MAX_PORT="49200" @@ -46,9 +46,12 @@ export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y coturn -PUBLIC_IP="$(hostname -I | awk '{print $1}')" +PUBLIC_IP="$(getent ahostsv4 "$REALM" | awk '{print $1; exit}')" if [[ -z "${PUBLIC_IP}" ]]; then - echo "Не удалось определить public ip автоматически, укажите вручную в /etc/turnserver.conf" >&2 + PUBLIC_IP="$(hostname -I | awk '{print $1}')" +fi +if [[ -z "${PUBLIC_IP}" ]]; then + echo "Не удалось определить public ip автоматически по домену ${REALM}, укажите вручную в /etc/turnserver.conf" >&2 PUBLIC_IP="0.0.0.0" fi diff --git a/deploy/scripts/test_t1_server.sh b/deploy/scripts/test_t1_server.sh new file mode 100755 index 00000000..f38fbdf8 --- /dev/null +++ b/deploy/scripts/test_t1_server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@t1.shineup.me" \ +TARGET_DOMAIN="t1.shineup.me" \ +REMOTE_SERVER_DIR="/home/player/t1/server" \ +REMOTE_SERVICE_NAME="shine-t1" \ +SERVER_PORT="7101" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy_shine-ui_t1.sh b/deploy/scripts/test_t1_ui.sh old mode 100644 new mode 100755 similarity index 81% rename from deploy_shine-ui_t1.sh rename to deploy/scripts/test_t1_ui.sh index bd09bb5d..c097b1fc --- a/deploy_shine-ui_t1.sh +++ b/deploy/scripts/test_t1_ui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -REMOTE_HOST="player@178.208.90.249" \ +REMOTE_HOST="player@t1.shineup.me" \ REMOTE_UI_DIR="/home/player/t1/UI" \ EXPECTED_CADDY_UI_ROOT="/home/player/t1/UI" \ EXPECTED_CADDY_SITE="t1.shineup.me" \ @@ -10,4 +10,4 @@ DEPLOY_SERVER_ADDRESS="t1.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ TARGET_URL="https://t1.shineup.me" \ -bash "$(dirname "$0")/deploy_shine-PWA.sh" +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t2_server.sh b/deploy/scripts/test_t2_server.sh new file mode 100755 index 00000000..1a34c459 --- /dev/null +++ b/deploy/scripts/test_t2_server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@t2.shineup.me" \ +TARGET_DOMAIN="t2.shineup.me" \ +REMOTE_SERVER_DIR="/home/player/t2/server" \ +REMOTE_SERVICE_NAME="shine-t2" \ +SERVER_PORT="7102" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy_shine-ui_t2.sh b/deploy/scripts/test_t2_ui.sh old mode 100644 new mode 100755 similarity index 81% rename from deploy_shine-ui_t2.sh rename to deploy/scripts/test_t2_ui.sh index 443ce206..fbeeaf61 --- a/deploy_shine-ui_t2.sh +++ b/deploy/scripts/test_t2_ui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -REMOTE_HOST="player@178.208.90.249" \ +REMOTE_HOST="player@t2.shineup.me" \ REMOTE_UI_DIR="/home/player/t2/UI" \ EXPECTED_CADDY_UI_ROOT="/home/player/t2/UI" \ EXPECTED_CADDY_SITE="t2.shineup.me" \ @@ -10,4 +10,4 @@ DEPLOY_SERVER_ADDRESS="t2.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ TARGET_URL="https://t2.shineup.me" \ -bash "$(dirname "$0")/deploy_shine-PWA.sh" +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t3_server.sh b/deploy/scripts/test_t3_server.sh new file mode 100755 index 00000000..68a609df --- /dev/null +++ b/deploy/scripts/test_t3_server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@t3.shineup.me" \ +TARGET_DOMAIN="t3.shineup.me" \ +REMOTE_SERVER_DIR="/home/player/t3/server" \ +REMOTE_SERVICE_NAME="shine-t3" \ +SERVER_PORT="7103" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy_shine-ui_t3.sh b/deploy/scripts/test_t3_ui.sh old mode 100644 new mode 100755 similarity index 81% rename from deploy_shine-ui_t3.sh rename to deploy/scripts/test_t3_ui.sh index ff9d48dd..fd76ef8e --- a/deploy_shine-ui_t3.sh +++ b/deploy/scripts/test_t3_ui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -REMOTE_HOST="player@178.208.90.249" \ +REMOTE_HOST="player@t3.shineup.me" \ REMOTE_UI_DIR="/home/player/t3/UI" \ EXPECTED_CADDY_UI_ROOT="/home/player/t3/UI" \ EXPECTED_CADDY_SITE="t3.shineup.me" \ @@ -10,4 +10,4 @@ DEPLOY_SERVER_ADDRESS="t3.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ TARGET_URL="https://t3.shineup.me" \ -bash "$(dirname "$0")/deploy_shine-PWA.sh" +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t4_server.sh b/deploy/scripts/test_t4_server.sh new file mode 100755 index 00000000..5a4c6ba9 --- /dev/null +++ b/deploy/scripts/test_t4_server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +REMOTE_HOST="player@t4.shineup.me" \ +TARGET_DOMAIN="t4.shineup.me" \ +REMOTE_SERVER_DIR="/home/player/t4/server" \ +REMOTE_SERVICE_NAME="shine-t4" \ +SERVER_PORT="7104" \ +bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy_shine-ui_t4.sh b/deploy/scripts/test_t4_ui.sh old mode 100644 new mode 100755 similarity index 81% rename from deploy_shine-ui_t4.sh rename to deploy/scripts/test_t4_ui.sh index 151d1971..415f1124 --- a/deploy_shine-ui_t4.sh +++ b/deploy/scripts/test_t4_ui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -REMOTE_HOST="player@178.208.90.249" \ +REMOTE_HOST="player@t4.shineup.me" \ REMOTE_UI_DIR="/home/player/t4/UI" \ EXPECTED_CADDY_UI_ROOT="/home/player/t4/UI" \ EXPECTED_CADDY_SITE="t4.shineup.me" \ @@ -10,4 +10,4 @@ DEPLOY_SERVER_ADDRESS="t4.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ TARGET_URL="https://t4.shineup.me" \ -bash "$(dirname "$0")/deploy_shine-PWA.sh" +bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy_shine-server_test2.sh b/deploy_shine-server_test2.sh deleted file mode 100644 index b846ef2d..00000000 --- a/deploy_shine-server_test2.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" -TARGET_DOMAIN="${TARGET_DOMAIN:-server2.shineup.me}" -REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}" -REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}" -REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" -REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" -REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}" -REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}" -LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}" - -TMP_DIR="$(mktemp -d)" -cleanup() { - rm -rf "$TMP_DIR" -} -trap cleanup EXIT - -if [[ ! -f "$LOCAL_JAR" ]]; then - echo "ERROR: локальный jar не найден: $LOCAL_JAR" >&2 - exit 1 -fi - -ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null -ssh "$TARGET_HOST" "sudo -n true" -ssh "$TARGET_HOST" "java -version >/dev/null 2>&1" - -cat >"$TMP_DIR/shine-server.service" < main, либо удаляет мусор; - если marker нет, временные артефакты считаются мусором и удаляются. -- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`. +- В `docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`. ## 2026-05-24 11:40:00 +0300 - Базовый коммит-ориентир: `abdce05`. - `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`. - В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации. -- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`. +- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`. ## 2026-05-21 19:05:00 +0300 - Базовый коммит-ориентир: `5344c42`. - Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`: - обновлён перечень типов в `11_TEXT_Blocks.md`; - обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`. -- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. -- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`. +- Уточнено API-описание поддержанных подтипов в `docs/API/04_Add_Block_to_Blockchain_API.md`. +- В документе `docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`. ## 2026-05-20 11:34:17 +0300 - Базовый коммит-ориентир: `a53444b`. @@ -82,7 +82,7 @@ - `60/61` — `known_person / unknown_person` (знаю этого человека); - `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий); - `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим). -- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. +- Обновлён список CONNECTION-подтипов в `docs/API/04_Add_Block_to_Blockchain_API.md`. ## 2026-05-19 20:30:21 +0300 - Базовый коммит-ориентир: `7986184`. @@ -107,4 +107,4 @@ - Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует). - Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении. - Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками). -- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`. +- В `AGENTS.md` добавлено обязательное правило актуализации документации в `docs/Blockchain/`. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index ea6d1161..d4413ed8 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -193,7 +193,7 @@ Full resync запускается только тогда, когда: ### 5.4 Разрешение конфликтов - Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. - Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`). + Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `docs/Blockchain/README.md`). - DM: конфликтов нет, `message_key` уникален. ## 6. Маршрутизация DM между серверами diff --git a/docs/Figma/TRANSFER_UI_SCREENS.md b/docs/Figma/TRANSFER_UI_SCREENS.md index 5e4bdb4d..59aca872 100644 --- a/docs/Figma/TRANSFER_UI_SCREENS.md +++ b/docs/Figma/TRANSFER_UI_SCREENS.md @@ -189,7 +189,7 @@ 3. Переносить эти изменения назад в код минимально необходимыми правками. 4. Если из Figma следует уже не только визуальная, но и UX-логическая правка, отдельно проверить, что она согласована пользователем. -## Когда нужно добавить заметку в Pending_Features +## Когда нужно отдельно согласовать ручную проверку Если после изменения по Figma: - поменялась логика flow; @@ -197,7 +197,7 @@ - нужен реальный прогон на test2; - затронута интеграция с Solana; -тогда нужно добавить файл в `Dev_Docs/Pending_Features/`. +тогда нужно отдельно согласовать ручную проверку с пользователем. ## Что пока не оформлено для Miro diff --git a/docs/Keys/DERIVATION.md b/docs/Keys/DERIVATION.md index 77a864e0..e15b14c9 100644 --- a/docs/Keys/DERIVATION.md +++ b/docs/Keys/DERIVATION.md @@ -6,7 +6,7 @@ > Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула > ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь > dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**. -> Роли и назначение ключей описаны отдельно в `Dev_Docs/Keys/README.md` (архитектура). +> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура). > Здесь — только механика. Документ намеренно краткий. --- @@ -59,7 +59,7 @@ seed(32) = SHA-256(material) | device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | -Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`. +Полные роли каждого ключа — в `docs/Keys/README.md`. --- @@ -77,7 +77,7 @@ seed(32) = SHA-256(material) Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью (`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый -кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`. +кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`. --- diff --git a/docs/Keys/README.md b/docs/Keys/README.md index 61785257..14fcc1bd 100644 --- a/docs/Keys/README.md +++ b/docs/Keys/README.md @@ -30,7 +30,7 @@ `root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. -Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. +Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3. ## `blockchain key` @@ -65,7 +65,7 @@ Arweave-кошелёк должен выводиться из `client key` по протоколу: -- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` +- `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. @@ -158,13 +158,13 @@ Self-message - это сообщение пользователя самому ## Связанные документы -- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. -- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. -- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. -- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. -- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. -- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`. +- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). +- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. +- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. +- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. +- `docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. +- `docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. +- `docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`. ## Что нужно уточнить перед реализацией diff --git a/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md b/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md deleted file mode 100644 index bbe28be3..00000000 --- a/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md +++ /dev/null @@ -1,38 +0,0 @@ -# Поддержать проект Сияние - -Статус: `pending` - -## Кратко - -В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами: - -1. купить билет; -2. посмотреть билет по номеру; -3. сгенерировать новую пару ключей. - -## Что проверить - -1. Открыть `Кошелёк`. -2. Перейти в `Поддержать проект Сияние`. -3. Проверить экран покупки: - - виден коэффициент; - - виден остаток лимита очереди 1; - - виден расчет в SOL; - - кнопка `Справка` открывает отдельный экран; - - покупка блокируется, если сумма больше остатка лимита. -4. Проверить экран просмотра: - - `12` ищется как билет очереди 1; - - `2-5` и `3 8` ищутся как билеты очередей 2 и 3; - - показываются статус, количество билетов до него и уже выплаченные значения. -5. Проверить генератор ключей: - - генерируется новая пара ключей; - - публичный и секретный ключи показываются; - - можно скопировать и скачать результат; - - дополнительный текст в поле необязателен. - -## Ожидаемый результат - -- Экран раздела поддержки открывается из `wallet-view`. -- Покупка билета выполняется по текущему курсу и с допуском 3%. -- По номеру билета показывается понятная сводка по очереди. -- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа. diff --git a/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md b/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md deleted file mode 100644 index b60b0908..00000000 --- a/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md +++ /dev/null @@ -1,20 +0,0 @@ -# Promo-логины через продавцов в Solana - -- краткое описание: - - в `shine_users` добавлена поддержка PDA продавцов красивых логинов, promo-подписи `shine_promo_v1:` и отдельная автономная страница `shine-UI/promo-code-generator.html` для генерации promo-кода через браузерный кошелёк или через ручной ввод Base58 seed 32 bytes; -- что проверять: - - admin-транзакцией создать или обновить `promo_seller_pda`; - - сгенерировать promo-код на странице `promo-code-generator.html` в режиме wallet extension; - - сгенерировать promo-код на той же странице в режиме ручного Base58 seed; - - открыть HTML локально как отдельный файл без сервера и убедиться, что генерация в режиме Base58 seed продолжает работать; - - зарегистрировать короткий/premium логин по promo-коду; - - убедиться, что без promo-кода тот же логин не проходит `shine_login_guard`; - - убедиться, что после успешной регистрации `remaining_sales` уменьшается на 1; - - проверить отказ при исчерпанной квоте, неверной подписи и логине короче `min_login_length`; -- ожидаемый результат: - - promo-регистрация проходит только при валидной подписи продавца и соблюдении лимитов; - - обычная регистрация без promo-кода продолжает работать как раньше; - - страница генерации выдаёт строку формата `1seller-signatureBase58` в обоих режимах; - - single-file HTML работает локально оффлайн минимум в режиме ручного Base58 seed; -- статус: - - `pending` diff --git a/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md b/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md deleted file mode 100644 index d309652f..00000000 --- a/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md +++ /dev/null @@ -1,51 +0,0 @@ -# Синхронизация mainnet-адресов Solana по UI/серверу/ESP32 - -- статус: `pending` - -## Кратко - -Во все основные клиентские и серверные точки проекта протянуты актуальные mainnet-адреса программ: - -- `shine_login_guard`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` -- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` -- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` - -Также по умолчанию переключены основные RPC defaults на `https://api.mainnet-beta.solana.com` там, где это нужно для рабочего mainnet-сценария. - -## Что проверять - -1. Основной UI: - - отображение и использование новых program id; - - регистрация/чтение Solana PDA в mainnet; - - экран кошелька и покупка лимита. - -2. Server UI: - - создание server PDA в mainnet; - - чтение PDA; - - обновление PDA; - - корректная блокировка devnet-автопополнения при mainnet endpoint. - -3. Browser plugin wallet: - - резолв `user_pda` и `server PDA` через mainnet RPC. - -4. Web UI `shine_payments`: - - чтение очередей; - - покупка билета; - - admin/manager/dao tools открываются с новыми program id и mainnet RPC. - -5. ESP32 homeserver UI: - - отображаются новые program id; - - дефолтный Solana RPC теперь mainnet. - -6. Временный режим регистрации: - - в основном UI логины длиной 5-7 символов без временного кода не проходят; - - в основном UI логины длиной 5-7 символов с временным кодом проходят локальную проверку; - - в основном UI любой другой временный код отклоняется сразу; - - ESP32 и прямой on-chain вызов `shine_users` полагаются на текущий временный `shine_login_guard`, то есть валидные логины длиной от 5 символов допускаются без словарной классификации. - -## Ожидаемый результат - -- Во всех перечисленных поверхностях используются новые mainnet program id. -- Запросы по умолчанию идут в mainnet, кроме явно devnet-утилит. -- Нет обращений к старым devnet program id. -- Временный UI-барьер для коротких логинов работает так, как задумано, и не ломает обычную регистрацию для логинов от 8 символов. diff --git a/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md b/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md deleted file mode 100644 index 71e5a523..00000000 --- a/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md +++ /dev/null @@ -1,42 +0,0 @@ -# Временный режим коротких логинов - -- статус: `pending` - -## Кратко - -Временно упрощён `shine_login_guard`: - -- on-chain допускаются любые валидные логины длиной от `5` символов; -- словарная premium/trademark-классификация временно отключена. - -Поверх этого основной UI вводит временное ограничение: - -- логины длиной `8+` проходят обычную проверку; -- логины длиной `5..7` допускаются только при вводе временного кода; -- любой другой код отклоняется сразу. - -ESP32 в этом временном режиме не ограничивается UI-кодом и использует только on-chain правила. - -## Что проверять - -1. Основной UI: - - логин длиной `4` символа отклоняется; - - логин длиной `5..7` без временного кода отклоняется; - - логин длиной `5..7` с корректным временным кодом проходит локальную проверку; - - логин длиной `5..7` с любым другим кодом отклоняется; - - логин длиной `8+` проходит без временного кода. - -2. Solana on-chain: - - `shine_login_guard` возвращает `CLASS_FREE` для валидных логинов длиной от `5`; - - `shine_login_guard` возвращает `CLASS_PREMIUM` для логинов короче `5` или с невалидными символами. - -3. ESP32 / прямой вызов: - - валидный логин длиной `5+` регистрируется без временного кода; - - логин короче `5` не регистрируется. - -## Ожидаемый результат - -- Основной UI удерживает обычных пользователей от коротких логинов без временного кода. -- Свои пользователи могут временно регистрировать короткие логины через UI-код. -- ESP32 продолжает работать без отдельной промо-логики. -- On-chain логика остаётся простой и компактной. diff --git a/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md b/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md deleted file mode 100644 index 623ef4cc..00000000 --- a/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md +++ /dev/null @@ -1,39 +0,0 @@ -# 4 тестовых SHiNE-сервера на одном VPS (`178.208.90.249`) - -- статус: `pending` - -## Кратко - -Поднят отдельный тестовый стенд: -- `t1.shineup.me` -- `t2.shineup.me` -- `t3.shineup.me` -- `t4.shineup.me` - -Каждый домен ведёт на свой SHiNE-инстанс под пользователем `player`, все инстансы работают через Solana `devnet`. - -## Что проверять - -- UI каждого домена открывается без ошибок: - - `https://t1.shineup.me` - - `https://t2.shineup.me` - - `https://t3.shineup.me` - - `https://t4.shineup.me` -- регистрация/логин клиента может работать через каждый из этих серверов -- WebSocket-подключение реально идёт на свой инстанс, а не в соседний -- чтение PDA и server-ui формы по умолчанию используют `devnet` -- межсерверный sync после ручных тестов действительно использует `access_servers/sync_servers` -- не происходит путаницы данных между `t1/t2/t3/t4` - -## Ожидаемый результат - -- каждый домен работает независимо -- у каждого инстанса своя БД и свои локальные данные -- `Caddy` корректно проксирует `/ws` на `7101..7104` -- серверы не падают после старта -- если devnet RPC временно даёт `429`, последовательный рестарт по одному инстансу позволяет подтянуть `sync_servers` - -## Примечание - -Подробная схема размещения описана в: -- [docs/deploy/test-server/178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/docs/deploy/test-server/178.208.90.249_quad_devnet.md) diff --git a/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md b/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md deleted file mode 100644 index 2db1f749..00000000 --- a/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md +++ /dev/null @@ -1,17 +0,0 @@ -# Итог звонка как DM от инициатора - -- краткое описание фичи: - Вместо локальных `call-tech` записей итог звонка теперь отправляется как обычное DM-сообщение только от стороны, которая инициировала звонок. - -- что именно проверять: - 1. Успешный звонок: после завершения инициатор отправляет в чат DM вида `Звонок: 37с` или `Звонок: 2м 14с`. - 2. Неуспешный звонок без ответа: инициатор отправляет `Звонил, но недозвонился: нет ответа`. - 3. Неуспешный звонок, когда у адресата нет доставленных сессий: инициатор отправляет `Звонил, но недозвонился: абонент не в сети`. - 4. Неуспешный звонок при проблеме соединения: инициатор отправляет `Звонил, но недозвонился: не удалось установить соединение`. - 5. Старые локальные `call-tech` bubble про итог звонка больше не появляются ни у инициатора, ни у принимающей стороны. - -- ожидаемый результат: - Итог звонка виден обеим сторонам как обычное DM-сообщение от инициатора, а локальные служебные записи про итог звонка больше не используются. - -- статус: - pending diff --git a/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md b/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md deleted file mode 100644 index 173118a0..00000000 --- a/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md +++ /dev/null @@ -1,23 +0,0 @@ -## Краткое описание - -Сервер перестал валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`. -UI теперь не отбрасывает такие сообщения: если сообщение не удалось расшифровать, вместо текста показывается заглушка `Неудалось расшифровать сообщение`. - -## Что проверять - -- Отправка и приём обычных DM между штатными клиентами продолжают работать как раньше. -- Сообщение с незнакомым форматом `body` у `type=1/2` принимается сервером и доходит до клиента. -- В UI такое сообщение отображается в чате одной из ожидаемых заглушек: - - `Формат сообщения не поддерживается` - - `Не удалось расшифровать сообщение` - - `Сообщение повреждено` -- После выхода из аккаунта и повторного входа backlog с такими сообщениями тоже отображается с той же заглушкой. -- ACK доставки по сессии для такого сообщения не зацикливается и сообщение не приходит бесконечно повторно. - -## Ожидаемый результат - -Сервер выступает транспортом для opaque DM-body, а клиент при неудачной расшифровке показывает безопасный fallback вместо полного пропуска сообщения с различением основных причин. - -## Статус - -pending diff --git a/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md b/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md deleted file mode 100644 index 98d897c2..00000000 --- a/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md +++ /dev/null @@ -1,28 +0,0 @@ -## Краткое описание - -В DM добавлен клиентский формат технических вставок в начале plaintext: - -- `` -- `` - -UI скрывает такие вставки из текста сообщения, а call-вставки отображает специальным человекочитаемым видом. -Также добавлена защита от пользовательского текста, начинающегося с `` показывается в чате как специальное call-сообщение, а не как сырой техтекст. -- При исходящем звонке короче `5` секунд call-summary не отправляется вообще. -- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка. -- DM с `Текст ответа` скрывает техблок и показывает только `Текст ответа`. -- В меню любого DM первым пунктом есть `Ответить`, и отправка из этого режима реально добавляет `` в начало plaintext. -- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа. -- Текст вида `` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов. - -## Ожидаемый результат - -Технические SHiNE-вставки работают только как управляющие метаданные UI, а отображаемый пользователю текст и превью остаются безопасными и не рендерят HTML. - -## Статус - -pending diff --git a/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md b/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md deleted file mode 100644 index d81fe498..00000000 --- a/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md +++ /dev/null @@ -1,30 +0,0 @@ -# Исправление завершения звонка и таймаутов ACK - -## Краткое описание - -Исправлена клиентская логика звонков: -- входящий экран теперь должен закрываться сразу при `remote_hangup`; -- после нажатия `Ответить` добавлен таймаут ожидания стадии `connecting`, чтобы окно не висело бесконечно; -- исходящий таймаут ожидания ACK больше не обрывает звонок слишком рано, если invite уже был доставлен. -- в локальный app log клиента добавлен точный timeline по этапам звонка: invite, выбор remoteSessionId, accept/offer/answer, ICE, смены `RTCPeerConnection` и финализация. -- добавлена явная диагностика `SESSION_NOT_FOUND` для звонковых сигналов: отдельный timeline-этап в клиенте и отдельный серверный warn-лог. - -## Что проверять - -- Если исходящая сторона завершила звонок, входящее окно у второй стороны закрывается сразу. -- Если принять входящий звонок, а соединение/offer не приходит, экран не висит бесконечно и сам завершается по таймауту. -- Если invite был доставлен, исходящий звонок не падает через 10 секунд раньше времени только из-за медленного ACK. -- В app log по звонку видна последовательность этапов с локальными `+N ms`, по которой можно понять, где именно образуется задержка или гонка. -- Если сигнал звонка не дошёл до целевой сессии, это явно видно и в клиенте, и в серверном логе. - -## Ожидаемый результат - -- Нет бесконечно висящего окна `Соединяем…` на принимающей стороне. -- В логах вместо преждевременного `no_ack_10s` для уже доставленного invite появляется более поздний таймаут. -- При `remote_hangup` звонок визуально завершается сразу. -- По каждому проблемному звонку есть локальный timeline и тот же краткий timeline попадает в `CallDeliveryReport`. -- В серверных логах есть строка `CALL_SIGNAL_SESSION_NOT_FOUND ...` с `callId`, `targetSessionId` и количеством активных сессий адресата. - -## Статус - -`pending` diff --git a/docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md b/docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md deleted file mode 100644 index 2bf2f6f8..00000000 --- a/docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md +++ /dev/null @@ -1,16 +0,0 @@ -# Адаптация верхней панели экрана «Связи» - -## Краткое описание -- Экран графа связей больше не расширяется за границы контейнера. Верхняя панель ограничена границами экрана, чтобы кнопки возврата, поиска и справки не обрезались на узких устройствах. - -## Что проверить -- Открыть экран «Связи» на ширине около 320, 390 и 794 px. -- Убедиться, что кнопки возврата, «Найти» и «?» полностью видны и нажимаются. -- Убедиться, что фильтры и граф связей остаются доступными. - -## Ожидаемый результат -- Верхние действия не выходят за левую или правую границу приложения. -- Заголовок при нехватке места сокращается, а не сдвигает кнопки за край. - -## Статус -- `pending` — локальная проверка на ширине 320 и 794 px пройдена; нужна ручная проверка после публикации на тестовом или production-стенде. diff --git a/docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md b/docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md deleted file mode 100644 index b96e6788..00000000 --- a/docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md +++ /dev/null @@ -1,16 +0,0 @@ -# Кнопка поиска каналов - -## Краткое описание -- Emoji-иконка поиска в верхней панели «Каналов» заменена на контурную кнопку-лупу в стиле интерфейса. - -## Что проверить -- Открыть список каналов. -- Убедиться, что кнопка поиска видна рядом с кнопкой создания канала и имеет подсказку «Найти канал». -- Нажать кнопку и убедиться, что открывается прежнее окно поиска каналов. - -## Ожидаемый результат -- Кнопка выглядит как часть общей тёмной панели и не использует emoji. -- Поиск каналов работает без изменений. - -## Статус -- `pending` — локальная визуальная проверка и открытие окна поиска пройдены; нужна ручная проверка после публикации на тестовом или production-стенде. diff --git a/docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md b/docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md deleted file mode 100644 index 6a61be39..00000000 --- a/docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md +++ /dev/null @@ -1,20 +0,0 @@ -# Локальный тестовый вход - -## Краткое описание -- На `localhost`, `127.0.0.1` и `::1` доступна кнопка «Локальный тестовый вход». -- Она создаёт только локальный демо-сеанс `local-tester`; пароль, серверная сессия и production не используются. -- Экран личных сообщений и список каналов открывают автономные локальные состояния без запроса к серверу. -- Профиль заполняется демонстрационными данными, чтобы интерфейс можно было проверить без серверного пользователя. - -## Что проверить -- Открыть локальный UI и нажать «Локальный тестовый вход». -- Проверить переход к списку сообщений и доступ к экранам профиля, каналов, связей и настроек. -- Обновить страницу и убедиться, что локальный сеанс сохраняется. -- Выйти из сеанса и убедиться, что снова открывается экран старта. - -## Ожидаемый результат -- Локальный режим даёт возможность изучить интерфейс без реальной авторизации. -- На `shineup.me` и тестовых доменах кнопка отсутствует. - -## Статус -- `pending` — локальный вход, переход к профилю и демонстрационные каналы проверены; нужна ручная проверка выхода из сеанса после публикации на тестовом или production-стенде. diff --git a/docs/Pending_Features/README.md b/docs/Pending_Features/README.md deleted file mode 100644 index 071183a8..00000000 --- a/docs/Pending_Features/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Недопроверенные фичи - -Эта папка хранит список доработок, которые уже реализованы, но ещё не подтверждены ручной проверкой. - -## Как использовать - -1. При каждом коммите с новыми пользовательскими фичами (если нужна ручная проверка) добавить новый файл: - - формат: `YYYY-MM-DD_HHMM_.md` - - название `` и текст файла по возможности писать на русском языке -2. В файле указать: - - что сделано; - - как проверять; - - ожидаемый результат; - - текущий статус (`pending` / `in_progress` / `done`). -3. После подтверждения работоспособности — удалить файл фичи из этой папки. - -## Важно - -- `README.md` не удаляется. -- Количество недопроверенных фич = число файлов `*.md` в этой папке, кроме `README.md`. diff --git a/docs/Pending_Features/вроде сделанное/2026-06-20_1839_registration_faq_and_12_words.md b/docs/Pending_Features/вроде сделанное/2026-06-20_1839_registration_faq_and_12_words.md deleted file mode 100644 index 64ade24f..00000000 --- a/docs/Pending_Features/вроде сделанное/2026-06-20_1839_registration_faq_and_12_words.md +++ /dev/null @@ -1,25 +0,0 @@ -# Регистрация: FAQ и режим пароля из 12 слов - -- краткое описание: - - на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки; - - добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API; - - такой же режим добавлен и на экран входа по логину и паролю. - -- что проверять: - - на стартовом экране открыть `Зарегистрироваться`; - - убедиться, что внизу экрана есть кнопки FAQ; - - открыть несколько вопросов и проверить возврат обратно на регистрацию; - - включить галочку `Представить пароль в виде 12 слов`; - - убедиться, что появляется сетка с нумерованными полями в 3 колонки; - - ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают; - - выключить галочку и проверить, что пароль остаётся собранным в одном поле; - - открыть экран входа по паролю и повторить те же проверки для режима `12 слов`; - - пройти регистрацию до шага оплаты без ошибок интерфейса. - -- ожидаемый результат: - - FAQ открывается отдельным экраном и содержит понятные ответы; - - режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль; - - пароль не отправляется в новом формате, а продолжает использоваться как одна строка. - -- статус: - - pending diff --git a/docs/Pending_Features/вроде сделанное/2026-06-20_2350_test_free_avatar_upload.md b/docs/Pending_Features/вроде сделанное/2026-06-20_2350_test_free_avatar_upload.md deleted file mode 100644 index 39646d4c..00000000 --- a/docs/Pending_Features/вроде сделанное/2026-06-20_2350_test_free_avatar_upload.md +++ /dev/null @@ -1,25 +0,0 @@ -# Временная бесплатная загрузка аватара в Arweave - -- краткое описание фичи: - Добавлены два временных `Test...` API для бесплатной загрузки маленьких аватаров в Arweave через серверный кошелёк с лимитом `3` загрузки на пользователя. В UI мастера смены аватара добавлен пункт `Залить аватар бесплатно`. - -- что именно проверять: - 1. Пользователь с активной сессией открывает редактирование профиля. - 2. По нажатию на аватар открывается мастер `Сменить аватар`. - 3. В мастере есть пункт `Залить аватар бесплатно`. - 4. До первой загрузки UI показывает остаток `3 из 3`. - 5. Маленький JPEG/PNG/WebP после уменьшения до файла <= `128 KB` успешно уходит через `TestUploadFreeAvatar`. - 6. После загрузки приходит `txId`, и аватар сохраняется в профиль как `avatar.ar`. - 7. Остаток уменьшается: `2`, `1`, `0`. - 8. На четвёртой попытке сервер отвечает понятной ошибкой про исчерпанный бесплатный лимит. - 9. Если итоговый уменьшенный файл всё ещё > `128 KB`, UI не отправляет его и показывает понятную ошибку. - 10. Если серверный Arweave JWK/path не настроен, UI получает понятную ошибку временной функции. - -- ожидаемый результат: - - первые 3 маленькие аватарки загружаются через серверный Arweave-кошелёк; - - после каждой успешной загрузки `ava` в профиле указывает на новый `txId`; - - после исчерпания лимита дальнейшая бесплатная загрузка блокируется без записи в профиль; - - обычная загрузка через свой Arweave-кошелёк продолжает работать отдельно. - -- статус: - pending diff --git a/docs/Pending_Features/вроде сделанное/2026-06-24_1825_unified_channels_list_without_stories.md b/docs/Pending_Features/вроде сделанное/2026-06-24_1825_unified_channels_list_without_stories.md deleted file mode 100644 index 7e4eac91..00000000 --- a/docs/Pending_Features/вроде сделанное/2026-06-24_1825_unified_channels_list_without_stories.md +++ /dev/null @@ -1,28 +0,0 @@ -# Общий список каналов без stories - -- Краткое описание: - вкладка `Каналы` переведена на единый список без разделения на "мои" и "подписки". - Название канала в списке теперь показывается как `login_владельца/название_канала`. - Служебный канал `stories` скрыт из списка каналов, поиска, подписки и связанных UI-сценариев. - -- Что проверять: - 1. Открыть вкладку `Каналы`. - 2. Убедиться, что сразу показывается один общий список. - 3. Проверить, что свои и чужие каналы отображаются вместе. - 4. Проверить формат названий: `ownerLogin/channelName`. - 5. Открыть свой канал и убедиться, что внутри сохраняется UI владельца. - 6. Открыть чужой канал и убедиться, что внутри сохраняется UI подписчика. - 7. Проверить, что `stories` не отображается: - - в общем списке; - - в поиске каналов; - - в подписке на канал; - - в списках выбора канала для репоста. - -- Ожидаемый результат: - - вкладка `Каналы` больше не делится на два режима; - - все видимые каналы идут единым списком; - - `stories` нигде не виден и не предлагается пользователю; - - переход в канал сохраняет корректный UI в зависимости от владельца. - -- Статус: - `pending` diff --git a/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md b/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md deleted file mode 100644 index de2f63ef..00000000 --- a/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md +++ /dev/null @@ -1,47 +0,0 @@ -# Crash-safe запись обычного `AddBlock` через `tmp_bch` - -## Кратко - -Обычный `AddBlock` переведён на схему: - -1. сборка `.tmp_bch`; -2. запись sidecar `.write_check` с `blockNumber` и `blockHash`; -3. создание пустого marker `.write_pending`; -4. SQL-транзакция; -5. атомарная подмена `tmp -> main`; -6. удаление временных файлов. - -## Что проверить - -1. Обычный `AddBlock` на свежей цепочке. -2. Падение до SQL-commit: - - должны остаться только временные файлы; - - на старте они должны быть удалены. -3. Падение после SQL-commit, но до `atomicReplaceBlockchainFile(...)`: - - на старте recovery должен довести swap до конца. -4. Падение после `atomicReplaceBlockchainFile(...)`, но до удаления marker/sidecar: - - на старте recovery должен просто подчистить хвост. -5. Сценарий без marker: - - `tmp_bch` / `write_check` считаются мусором и удаляются. - -## Ожидаемый результат - -- БД и файловая версия цепочки остаются согласованными. -- Повторный старт сервера не ломает chain и не требует ручной правки файлов. -- `BlockchainTmpRecoveryOnStartup` корректно обрабатывает и живые остатки, и мусор. - -## Статус - -`pending` - -## Что уже сделано - -- В коде есть `tmp_bch`, `write_check` и `write_pending`. -- `BlockchainWriter` пишет обычный `AddBlock` через временные артефакты. -- `BlockchainTmpRecoveryOnStartup` умеет добивать или чистить незавершённую запись. - -## Что ещё перепроверить - -- ручной crash-test на тестовом сервере; -- совместимость с уже существующими `resync_pending` marker-файлами; -- отсутствие ложных срабатываний на старых временных файлах. diff --git a/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md b/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md deleted file mode 100644 index d6dbab0d..00000000 --- a/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md +++ /dev/null @@ -1,29 +0,0 @@ -# Проверка аварийных остановок на разных этапах - -## Кратко - -Нужно отдельно проверить, как сервер восстанавливается после внезапной остановки: - -1. во время обычного `AddBlock` / `tmp_bch`-pipeline; -2. во время `full resync` цепочки; -3. во время startup recovery, если остановка произошла на предыдущем запуске; -4. при обычном апгрейде сервиса без явного crash-сценария. - -## Что проверять - -1. Остановка сервиса до `commit` БД. -2. Остановка сервиса после `commit`, но до замены `main.bch`. -3. Остановка сервиса во время `BlockchainResyncCleanupDAO`. -4. Остановка сервиса во время повторной загрузки цепочки по `GetBlockchainBlock`. -5. Поведение при обычном `systemctl restart`, когда сервер сам должен добить recovery. - -## Ожидаемый результат - -- после старта сервер либо дочищает временные артефакты, либо завершает незаконченный `resync`; -- не остаётся битых `.tmp_bch`, `.write_check`, `.write_pending`, `.resync_pending`; -- БД и файлы цепочки остаются согласованными; -- обычная работа сервера не стартует поверх незавершённого recovery. - -## Статус - -`pending` diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 587d94fb..2ec4490a 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -4,13 +4,13 @@ Точка входа: -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` -- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` -- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` +- `docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` +- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки Исторический устаревший документ сохранён отдельно: -- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` +- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` Правило сопровождения: diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 5466c953..84328880 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -20,15 +20,15 @@ Точный байтовый формат контейнера вынесен отдельно: -- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` +- `docs/Personal_Messages/Формат_DM_v1.md` Формат клиентских технических вставок внутри plaintext вынесен отдельно: -- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` +- `docs/Personal_Messages/Технические_вставки_DM_v1.md` Устаревшая предыдущая версия сохранена отдельно: -- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` +- `docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` ## 1. Основная модель @@ -61,7 +61,7 @@ Подробности стандартного преобразования: -- `Dev_Docs/Протоколы/Преобразование_ED25519_в_X25519.md` +- `docs/Протоколы/Преобразование_ED25519_в_X25519.md` ### 2.2. Правило шифрования копий @@ -509,6 +509,35 @@ UI-следствие для клиента: - после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории; - отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`. +### 10.5. `GetDirectMessages` + +Назначение: + +- постранично отдавать историю одного личного диалога; +- не вываливать весь старый backlog автоматически в момент логина; +- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков. + +Правила: + +- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа; +- клиент сам запрашивает первую страницу истории у нужного собеседника; +- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`; +- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`. + +Содержимое страницы: + +- сервер возвращает только контентные DM типов `1/2`; +- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются; +- сообщения идут страницами от новых к старым; +- лимит страницы считается по самим chat-сообщениям диалога. + +Поле прочтения: + +- для каждого контентного сообщения сервер может вернуть `readAtMs`; +- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу; +- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени; +- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается. + ## 11. Межсерверная доставка ### 11.1. Клиентская сторона @@ -547,9 +576,12 @@ UI-следствие для клиента: В ней должны сохраняться: - обычные контентные DM; +- read-receipt DM; - tombstone одного сообщения; - tombstone удаления переписки. +Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие. + Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы: - защищать от повторного приёма старых версий; diff --git a/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md b/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md index 15cd509f..de98104f 100644 --- a/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md +++ b/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md @@ -6,7 +6,7 @@ Aктуальная целевая спецификация: -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` +- `docs/Personal_Messages/Протокол_DM_v1.md` Что в этом документе считать устаревшим: @@ -35,7 +35,7 @@ Aктуальная целевая спецификация: Черновик будущих вложений вынесен отдельно: -- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md` +- `docs/Personal_Messages/Черновик_будущих_DM_вложений.md` ## Общая схема diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 2132bdcd..538cb19f 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -12,7 +12,7 @@ Логика протокола, API и поведение сервера описаны отдельно: -- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` +- `docs/Personal_Messages/Протокол_DM_v1.md` ## 1. Общие правила diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index ea1dc0a2..32440859 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -2,7 +2,7 @@ Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`. -Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`. +Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `docs/Blockchain/`. Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля. diff --git a/docs/Solana_Architecture/README.md b/docs/Solana_Architecture/README.md index ebacfe67..55d37f97 100644 --- a/docs/Solana_Architecture/README.md +++ b/docs/Solana_Architecture/README.md @@ -8,7 +8,7 @@ Связанные документы: -- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей. +- `docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей. - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — точный формат `user_pda` для `shine_users`. - `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля. @@ -55,7 +55,7 @@ DAO в текущем виде не является отдельной Anchor- 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` +4. UI/серверные константы, перечисленные в `docs/Инициализация_Solana_регистрации/README.md` ## Ключи и authority @@ -105,7 +105,7 @@ DAO в текущем виде не является отдельной Anchor- ## Правило разделения с основным сервером -Solana-модуль лежит в основном репозитории как отдельная папка `shine-solana/shine/`, но не подключается автоматически к сборке или деплою основного сервера SHiNE. Команды `deployServer` и `deployUI` не должны деплоить Anchor-программы. Solana build/deploy выполняется отдельно из папки `shine-solana/shine/` по локальным правилам модуля. +Solana-модуль лежит в основном репозитории как отдельная папка `shine-solana/shine/`, но не подключается автоматически к сборке или деплою основного сервера SHiNE. Скрипты основного server/UI deploy из `deploy/scripts/` не должны деплоить Anchor-программы. Solana build/deploy выполняется отдельно из папки `shine-solana/shine/` по локальным правилам модуля. ## Движение денег diff --git a/docs/deploy/README.md b/docs/deploy/README.md deleted file mode 100644 index 0986ae42..00000000 --- a/docs/deploy/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Деплой SHiNE (шаблон) - -Этот раздел хранит актуальные инструкции по деплою. - -## Базовый сервер - -- SSH: `player@shineup.me` -- Домен: `shineup.me` -- Базовый путь: `/home/player` - -Для всех рабочих инструкций и скриптов использовать доменное имя `shineup.me`, а не фиксированный IP: - -- актуальный IP должен браться через DNS-резолв на момент подключения; -- ручное дублирование IP в документации и deploy-скриптах не поддерживать. - -## Контуры деплоя - -- Production: - - SSH: `player@shineup.me` - - Домен: `shineup.me` - - IP: `178.208.64.62` -- Second production: - - SSH: `player@193.8.215.70` - - Домен: `server2.shineup.me` - - IP: `193.8.215.70` -## Локальные команды - -- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2` -- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2` -- Production server deploy: `./gradlew deployServerProduction` -- Production UI deploy: `./gradlew deployUIProduction` -- Локальный запуск: `./gradlew startLocal` - -## Отдельные тестовые VPS - -- Отдельный стенд с 4 devnet-серверами на одном VPS описан в: - - `docs/deploy/test-server/README.md` - -## Политика подтверждений - -- `shineup.me` и `server2.shineup.me` считать production-контурами. -- Любые изменения на production-контурах, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя. -## Second production deploy (`server2.shineup.me`) - -- Это второй production-сервер SHiNE. -- Исторические задачи `deployServer`, `deployUI`, `deployServerTest2`, `deployUITest2` по-прежнему могут указывать сюда. -- Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере. -- `deployServer` / `deployServerTest2` делают: - - сборку fat-jar локально; - - синхронизацию `data/` и `shine.sqlite` с production `shineup.me`; - - перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`; - - установку `systemd` unit на `193.8.215.70`; - - перезапуск `shine-server.service`; - - установку/проверку Caddy для `server2.shineup.me`. -- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`. -- Важно: историческое имя `deployUITest2` вводит в заблуждение. По умолчанию эта задача деплоит на `server2.shineup.me`, а не на `t2.shineup.me`. - -## UI-деплой и Caddy (обязательно) - -- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`. -- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`. -- В `deploy_shine-PWA.sh` добавлена проверка: скрипт ищет блок `shineup.me { ... }` (или значение `EXPECTED_CADDY_SITE`) и проверяет `root` внутри этого блока. -- Если `root` внутри целевого блока не совпадает, деплой прерывается с ошибкой. -- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`. -- При необходимости можно явно переопределить путь деплоя: - - `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI` - - `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI` - - `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI` - -## Временные тестовые сайты Solana tickets - -- Для HTML UI программы `shine_payments` используется отдельный временный тестовый сайт. -- Основной каталог публикации: - - `/home/player/sites/test-solana-tickets.shineup.me` -- Рабочие домены: - - `https://test-solana-tickets.shineup.me` - - `https://test-solana-tickets.shiningpeople.ru` -- Назначение: - - ручная проверка сценариев покупки билетов; - - проверка DAO-инструментов и лимитов менеджеров; - - проверка ручного добавления билетов и `step_payout`. -- Эти сайты не считать основным UI SHiNE; это отдельная тестовая публикация под Solana-часть. - -### Важно для локального UI (history-router / Ctrl+F5) - -- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`. -- Эта задача запускает `scripts/local_spa_server.py`, который делает SPA fallback: любой неизвестный путь (`/m/...`, `/channel/...`) возвращает `index.html`. -- Это обязательно для корректной работы `Ctrl+F5` на внутренних роутов без `404`. -- Рабочий URL выводится задачей в консоль в формате: `http://localhost:/?localWsPort=`. - -## Обязательные правила - -1. Перед серверным деплоем проверить локально. -2. При нестандартном деплое (другой хост, другая структура, ручные шаги) обязательно уточнить у пользователя, нужно ли обновить этот шаблон. -3. Если деплой-процесс изменился, этот файл и файлы в `servers/` обновлять в том же коммите. diff --git a/docs/deploy/servers/193.8.215.70_test2_main.md b/docs/deploy/servers/193.8.215.70_test2_main.md deleted file mode 100644 index 3bab829d..00000000 --- a/docs/deploy/servers/193.8.215.70_test2_main.md +++ /dev/null @@ -1,43 +0,0 @@ -# Сервер `193.8.215.70` — второй production (`server2.shineup.me`) - -- Пользователь: `player` -- Домен: `server2.shineup.me` -- Логин сервера: `server2shineupme` -- Каталог SHiNE: `/home/player/SHiNE` -- UI публикация для Caddy: `/home/player/SHiNE/shine-ui` -- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` -- Данные: `/home/player/SHiNE/shine-server/data/` - - `shine.sqlite` - - `*.bch` -- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log` - -## Сервисы - -- `shine-server.service` (systemd) -- `caddy.service` (systemd) - -## Статус - -- Это второй production-сервер SHiNE. -- Исторические имена deploy-задач могут по-прежнему ссылаться на него как на `test2`. -- В документации и в списке окружений считать его production-контуром. - -## Caddy - -- Конфиг: `/etc/caddy/Caddyfile` -- Сайты: - - `server2.shineup.me` - - `agent.shiningpeople.ru` -- Для `server2.shineup.me`: - - `root * /home/player/SHiNE/shine-ui` - - `try_files {path} /index.html` - - `reverse_proxy /ws* -> 127.0.0.1:7070` - -## Deploy - -- Default server deploy: - - `./gradlew deployServer` - - `./gradlew deployServerTest2` -- Default UI deploy: - - `./gradlew deployUI` - - `./gradlew deployUITest2` diff --git a/docs/deploy/servers/shineup.me_main.md b/docs/deploy/servers/shineup.me_main.md deleted file mode 100644 index 3e947382..00000000 --- a/docs/deploy/servers/shineup.me_main.md +++ /dev/null @@ -1,48 +0,0 @@ -# Сервер `shineup.me` — основной - -- SSH: `player@shineup.me` -- Определение IP: через DNS-резолв домена `shineup.me` на момент подключения -- Текущий IP на момент последнего обновления документа: `178.208.64.62` -- Пользователь: `player` -- Базовый путь: `/home/player` -- Каталог SHiNE: `/home/player/SHiNE` -- UI публикация: `/home/player/SHiNE/shine-ui` -- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` -- Данные: `/home/player/SHiNE/shine-server/data/` -- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log` - -## Сервисы - -- `shine-server.service` (systemd) -- `caddy.service` (systemd) - -## Caddy - -- Активный конфиг (через systemd `ExecStart`): `/etc/caddy/Caddyfile` -- Для UI: - - `root * /home/player/SHiNE/shine-ui` - - `try_files {path} /index.html` (SPA fallback) - - no-cache заголовки - - `reverse_proxy /ws* -> 127.0.0.1:7070` - -## Дополнительно - -- Для отдельной админки `shine_payments` используется каталог: - - `/home/player/sites/test-solana-tickets.shineup.me` -- Эта публикация используется как временный тестовый сайт для сценариев покупки билетов и выплат `shine_payments`. -- Домены этой публикации: - - `https://test-solana-tickets.shineup.me` - - `https://test-solana-tickets.shiningpeople.ru` -- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP. - -## Правило изменений - -- `shineup.me` — production. -- Любые изменения на этом сервере делать только после отдельного явного подтверждения пользователя. - -## Deploy - -- Production deploy-задачи: - - `./gradlew deployServerProduction` - - `./gradlew deployUIProduction` -- Default deploy-задачи `./gradlew deployServer` и `./gradlew deployUI` сюда больше не относятся. diff --git a/docs/deploy/test-server/178.208.90.249_quad_devnet.md b/docs/deploy/test-server/178.208.90.249_quad_devnet.md deleted file mode 100644 index adda81e1..00000000 --- a/docs/deploy/test-server/178.208.90.249_quad_devnet.md +++ /dev/null @@ -1,173 +0,0 @@ -# VPS `178.208.90.249` (`player`) — 4 тестовых SHiNE-сервера на devnet - -## Назначение - -Этот VPS используется как отдельный тестовый стенд для одновременного запуска 4 SHiNE-серверов: -- `server_t1` → `https://t1.shineup.me` -- `server_t2` → `https://t2.shineup.me` -- `server_t3` → `https://t3.shineup.me` -- `server_t4` → `https://t4.shineup.me` - -Все 4 инстанса работают через Solana `devnet`. - -## Каталоги - -Структура в домашней папке пользователя `player`: -- `/home/player/t1/server` -- `/home/player/t1/UI` -- `/home/player/t2/server` -- `/home/player/t2/UI` -- `/home/player/t3/server` -- `/home/player/t3/UI` -- `/home/player/t4/server` -- `/home/player/t4/UI` - -Дополнительно: -- подробная памятка на самом сервере: `/home/player/Agents.md` - -## Порты и systemd - -Соответствие инстансов: -- `t1` → localhost `7101` → `shine-t1.service` -- `t2` → localhost `7102` → `shine-t2.service` -- `t3` → localhost `7103` → `shine-t3.service` -- `t4` → localhost `7104` → `shine-t4.service` - -Каждый unit запускает один и тот же jar: -- `/home/player/tX/server/shine-server.jar` - -Рабочая директория каждого unit: -- `/home/player/tX/server` - -Это важно, потому что сервер читает внешний `application.properties` именно из текущей рабочей директории. - -## Конфиги сервера - -У каждого инстанса свой файл: -- `/home/player/tX/server/application.properties` - -Ключевые параметры в нём: -- `server.port=710X` -- `server.SHiNE.login=server_tX` -- `db.path=data/shine.sqlite` -- `solana.cluster=devnet` -- `solana.rpcUrl=https://api.devnet.solana.com` -- `server.ui.indexPath=/home/player/tX/UI/index.html` -- `server.info.url=https://tX.shineup.me` -- `server.info.origin=devnet` - -Важно: -- Program ID SHiNE одинаковые для mainnet и devnet. -- Поэтому для перевода инстанса на devnet достаточно правильных `solana.cluster` и `solana.rpcUrl`. - -## Конфиги UI - -У каждого домена лежит отдельная копия `shine-UI`. - -В каждой копии меняется `js/deploy-config.js`: -- `defaultServerLogin=server_tX` -- `defaultServerAddress=tX.shineup.me` -- `defaultSolanaCluster=devnet` -- `defaultSolanaEndpoint=https://api.devnet.solana.com` - -За счёт этого: -- UI по умолчанию открывает именно свой сервер -- `server-ui` формы по умолчанию используют именно `devnet` - -## Caddy - -Конфиг: -- `/etc/caddy/Caddyfile` - -Логика по каждому сайту одинаковая: -- статические файлы берутся из `/home/player/tX/UI` -- путь `/ws` проксируется на `127.0.0.1:710X` - -Права доступа: -- каталог `/home/player` и `/home/player/tX` должен быть доступен на `+x` для чтения через Caddy -- все каталоги и файлы в `/home/player/tX/UI` должны быть читаемы пользователем `caddy` - -## Что уже проверено - -После настройки было подтверждено: -- `https://t1.shineup.me` отвечает `HTTP 200` -- `https://t2.shineup.me` отвечает `HTTP 200` -- `https://t3.shineup.me` отвечает `HTTP 200` -- `https://t4.shineup.me` отвечает `HTTP 200` -- `Caddy` успешно выпустил сертификаты Let's Encrypt на все 4 домена -- все 4 процесса Java слушают свои порты `7101..7104` -- в логах есть строка `WS сервер запущен на ws://localhost:710X/ws` - -## Важный operational-нюанс - -Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов на старте. - -На практике это уже проявилось: -- `t3` и `t4` подтянули `sync_servers` сразу -- `t1` и `t2` на первом одновременном старте словили `429` -- после последовательного рестарта `shine-t1` и `shine-t2` они тоже успешно сохранили `sync_servers` - -Если после очередного деплоя какой-то инстанс не подтянул `sync_servers`, сначала делать не массовый рестарт, а по одному: - -```bash -sudo systemctl restart shine-t1 -sleep 8 -sudo systemctl restart shine-t2 -sleep 8 -sudo systemctl restart shine-t3 -sleep 8 -sudo systemctl restart shine-t4 -``` - -## Как обновлять сервер - -1. Локально собрать jar: - -```bash -./gradlew shadowJar -``` - -2. Залить `SHiNE-server/build/libs/shine-server.jar` в каждый нужный каталог: -- `/home/player/t1/server/shine-server.jar` -- `/home/player/t2/server/shine-server.jar` -- `/home/player/t3/server/shine-server.jar` -- `/home/player/t4/server/shine-server.jar` - -3. Если менялся runtime-конфиг, обновить соответствующий `application.properties`. - -4. Перезапускать сервисы лучше по одному, с паузой, чтобы снизить шанс `429` от devnet RPC. - -## Как обновлять UI - -1. Взять свежую локальную папку `shine-UI/`. -2. Для каждой копии подставить свой `deploy-config.js`. -3. Скопировать файлы в `/home/player/tX/UI/`. -4. Проверить права чтения для Caddy. - -## Полезные команды на VPS - -Проверка сервисов: - -```bash -sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 -``` - -Перезапуск одного инстанса: - -```bash -sudo systemctl restart shine-t1 -``` - -Логи одного инстанса: - -```bash -tail -n 200 /home/player/t1/server/logs/app.log -sudo journalctl -u shine-t1 -n 200 --no-pager -``` - -Проверка Caddy: - -```bash -sudo caddy validate --config /etc/caddy/Caddyfile -curl -I https://t1.shineup.me -``` diff --git a/docs/deploy/test-server/README.md b/docs/deploy/test-server/README.md deleted file mode 100644 index 33f51f3b..00000000 --- a/docs/deploy/test-server/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Тестовый VPS с 4 SHiNE-серверами - -В этой папке описан отдельный тестовый VPS `178.208.90.249`, на котором подняты 4 независимых SHiNE-инстанса под доменами `t1.shineup.me` ... `t4.shineup.me`. - -Текущая основная схема: -- пользователь на VPS: `player` -- все 4 инстанса работают через `devnet` -- каждый инстанс имеет свой каталог `server`, свой каталог `UI`, свой `systemd` unit и свой localhost-порт -- внешний HTTPS и маршрутизацию `/ws` обслуживает один общий `Caddy` - -Детальное описание: -- [178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/docs/deploy/test-server/178.208.90.249_quad_devnet.md) diff --git a/docs/instructions/coturn-install.md b/docs/instructions/coturn-install.md deleted file mode 100644 index a714882a..00000000 --- a/docs/instructions/coturn-install.md +++ /dev/null @@ -1,75 +0,0 @@ -# Установка TURN сервера (coturn) - -## 1. Что нужно -- Сервер с публичным IP (в примере: `37.214.58.208`). -- Доступ `root` по SSH. -- Открытые порты в firewall: - - `3478/tcp` - - `3478/udp` - - диапазон relay-портов, например `49160-49200/udp` - -## 2. Быстрая установка (рекомендуется) -В проекте уже есть готовый скрипт: - -```bash -sudo bash scripts/setup_turn_coturn.sh \ - --secret "CHANGE_ME_LONG_RANDOM_SECRET" \ - --realm "shineup.me" -``` - -Что делает скрипт: -- ставит `coturn`; -- включает режим `use-auth-secret` (временные логин/пароль); -- пишет `/etc/turnserver.conf`; -- включает и перезапускает сервис `coturn`. - -## 3. Проверка -Проверить статус: - -```bash -sudo systemctl status coturn --no-pager -``` - -Проверить, что порт слушается: - -```bash -sudo ss -lntup | grep 3478 -``` - -## 4. Ручная установка (если без скрипта) -```bash -sudo apt-get update -sudo apt-get install -y coturn -``` - -Пример `/etc/turnserver.conf`: - -```conf -listening-port=3478 -fingerprint -lt-cred-mech -use-auth-secret -static-auth-secret=CHANGE_ME_LONG_RANDOM_SECRET -realm=shineup.me -external-ip=37.214.58.208 -listening-ip=0.0.0.0 -relay-ip=37.214.58.208 -min-port=49160 -max-port=49200 -no-cli -simple-log -``` - -В `/etc/default/coturn`: - -```conf -TURNSERVER_ENABLED=1 -``` - -Запуск: - -```bash -sudo systemctl enable coturn -sudo systemctl restart coturn -``` - diff --git a/docs/instructions/turn-connect-to-shine-server.md b/docs/instructions/turn-connect-to-shine-server.md deleted file mode 100644 index ababbc6b..00000000 --- a/docs/instructions/turn-connect-to-shine-server.md +++ /dev/null @@ -1,48 +0,0 @@ -# Подключение TURN к SHiNE-серверу - -Начиная с текущей версии, клиент звонков запрашивает ICE-конфиг у backend через WS-операцию `GetCallIceConfig` и использует её для `RTCPeerConnection`. - -## 1. Настройки backend -Файл: `src/main/resources/application.properties` - -Ключи: - -```properties -call.ice.stun.urls=stun:stun.l.google.com:19302 -call.ice.turn.urls=turn:37.214.58.208:3478?transport=udp,turn:37.214.58.208:3478?transport=tcp -call.ice.turn.ttlSec=600 -call.ice.turn.userPrefix=shine -call.ice.turn.sharedSecret=CHANGE_ME_LONG_RANDOM_SECRET - -# fallback (если не используете shared-secret) -call.ice.turn.username= -call.ice.turn.password= -``` - -## 2. Рекомендуемый режим (временные credentials) -- На coturn и на SHiNE-сервере должен быть **одинаковый** secret: - - coturn: `static-auth-secret=...` - - SHiNE: `call.ice.turn.sharedSecret=...` -- Тогда SHiNE выдаёт короткоживущие `turnUsername/turnPassword` (TTL). - -## 3. Fallback режим (статический логин/пароль) -Если временные credentials не используются: - -```properties -call.ice.turn.sharedSecret= -call.ice.turn.username=turn_user -call.ice.turn.password=turn_password -``` - -## 4. Деплой после изменения -```bash -./gradlew deployServerNoCleanNoTests -./gradlew deployWEB -``` - -## 5. Проверка -1. Авторизоваться двумя клиентами. -2. Запустить звонок. -3. Проверить, что звонок устанавливается даже в сети, где прямой P2P затруднён. -4. Если TURN недоступен, клиент автоматически откатится к STUN-конфигу по умолчанию. - diff --git a/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 64b77dbe..9c79f934 100644 --- a/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -115,4 +115,4 @@ sha256$ Для отдельного RPC-взаимодействия между браузерным wallet-расширением и ESP32 см. документ: -- [Формат_взаимодействия_внешнего_кошелька_и_ESP32.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md) +- [Формат_взаимодействия_внешнего_кошелька_и_ESP32.md](/home/ai/work/SHiNE/SHiNE-server-sha256/docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md) diff --git a/scripts/install_test2_caddyfile.sh b/scripts/install_test2_caddyfile.sh deleted file mode 100644 index 4bcb6c4c..00000000 --- a/scripts/install_test2_caddyfile.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" -TARGET_DOMAIN="${TARGET_DOMAIN:-server2.shineup.me}" -REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" -REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}" - -TMP_DIR="$(mktemp -d)" -cleanup() { - rm -rf "$TMP_DIR" -} -trap cleanup EXIT - -cat >"$TMP_DIR/Caddyfile" </dev/null -ssh "$TARGET_HOST" "sudo -n true" -rsync -az "$TMP_DIR/Caddyfile" "$TARGET_HOST:/tmp/caddy-test2.new" -ssh "$TARGET_HOST" "set -euo pipefail; \ - sudo mv -f /tmp/caddy-test2.new '$REMOTE_CADDYFILE'; \ - sudo chown root:root '$REMOTE_CADDYFILE'; \ - sudo caddy validate --config '$REMOTE_CADDYFILE'; \ - sudo systemctl restart caddy" diff --git a/server-backup/README.md b/server-backup/README.md deleted file mode 100644 index d40e59fe..00000000 --- a/server-backup/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# server-backup - -- `archive/` — локальные полные бэкапы по датам (не коммитятся). -- `scheme/` — лёгкая схема восстановления (коммитится). -- `backup-version.properties` — версии схемы и полного бэкапа. - -Текущий сервер-источник: `shineup.me`. diff --git a/server-backup/archive/.gitkeep b/server-backup/archive/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/server-backup/archive/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server-backup/scheme/shineup.me/scripts/backup_full.sh b/server-backup/scheme/shineup.me/scripts/backup_full.sh deleted file mode 100755 index ee236052..00000000 --- a/server-backup/scheme/shineup.me/scripts/backup_full.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}" -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -TODAY="$(date +%F)" -DEST_NAME="${DEST_NAME:-${TODAY}}" -DEST_DIR="${ROOT_DIR}/archive/${DEST_NAME}" -RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync") - -mkdir -p "${DEST_DIR}" - -echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}" -mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images" - -echo "[2/4] Копирую основные данные /home/player" -rsync -aH --delete \ - "${RSYNC_REMOTE_SUDO[@]}" \ - --exclude='SHiNE/shine-server/logs/' \ - --exclude='SHiNE/**/.elaira_logs/' \ - --exclude='**/*.log' \ - "${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/" - -rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/sites/" "${DEST_DIR}/home-player/sites/" -rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/" -rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/agent-memory/" "${DEST_DIR}/home-player/agent-memory/" - -echo "[3/4] Копирую системные конфиги" -rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/" -rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/" -rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf" -rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/" -rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/" -ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar" - -echo "[4/4] Создаю манифест" -{ - echo "backup_date=${TODAY}" - echo "remote_host=${REMOTE_HOST}" - echo "created_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo - echo "sizes:" - du -sh "${DEST_DIR}/home-player/SHiNE" || true - du -sh "${DEST_DIR}/home-player/sites" || true - du -sh "${DEST_DIR}/home-player/gitea" || true - du -sh "${DEST_DIR}/home-player/agent-memory" || true - du -sh "${DEST_DIR}/etc-system" || true - du -sh "${DEST_DIR}/var-lib/caddy" || true - du -sh "${DEST_DIR}/var-lib/docker-images" || true - echo - echo "total:" - du -sh "${DEST_DIR}" || true -} > "${DEST_DIR}/MANIFEST.txt" - -echo "Готово: ${DEST_DIR}" diff --git a/shine-TURN-server/README.md b/shine-TURN-server/README.md deleted file mode 100644 index 6580f104..00000000 --- a/shine-TURN-server/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# SHiNE TURN Server - -This directory stores TURN setup scripts and operational instructions. - -## Purpose -- Install and configure coturn for SHiNE calls. -- Keep repeatable setup scripts for new TURN nodes. -- Keep TURN-related config templates. - -## Current production model -- Multiple TURN servers are supported by backend config section: - - `call.ice.turn.servers.1.*` - - `call.ice.turn.servers.2.*` - - ... -- Each server can use: - - REST auth (`sharedSecret`) for temporary credentials, or - - static `username`/`password` (fallback). diff --git a/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log b/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log deleted file mode 100644 index fbef2a89..00000000 --- a/shine-UI/.elaira_logs/codex_profile_toggle_20260324_194129.log +++ /dev/null @@ -1,1509 +0,0 @@ -OpenAI Codex v0.116.0 (research preview) --------- -workdir: /home/player/docker/shine-UI -model: gpt-5.4 -provider: openai -approval: never -sandbox: danger-full-access -reasoning effort: none -reasoning summaries: none -session id: 019d20b9-0f17-71d1-b203-e6ee9d881c58 --------- -user -Project: static UI site for Shine, located at /home/player/docker/shine-UI. Task: modify the existing profile page. There are already 2 existing UI elements related to account status/features; do not create new duplicated elements if suitable ones already exist. Update their behavior so that when the user clicks each of these two existing elements, a menu, popup, modal, or dropdown appears that clearly explains that these controls are used to enable and disable the corresponding functions. The wording should feel natural in Russian UI. Keep the implementation simple and consistent with the current project style. If there is no existing modal system, create a lightweight one in plain HTML/CSS/JS appropriate for a static site. Make sure the interaction works in browser without backend. Update only what is necessary. At the end, print a short summary of changed files. -mcp startup: no servers -codex -Изменю профильную страницу точечно: сначала найду эти два существующих элемента статуса/функций и проверю, есть ли в проекте уже готовый механизм модалок или всплывающих меню, чтобы не дублировать UI. -exec -/bin/bash -lc 'rg --files js/pages js/components styles' in /home/player/docker/shine-UI succeeded in 0ms: -styles/main.css -styles/components.css -styles/layout.css -js/components/header.js -js/components/page-label.js -js/components/toolbar.js -js/pages/channel-view.js -js/pages/wallet-view.js -js/pages/messages-list.js -js/pages/settings-view.js -js/pages/chat-view.js -js/pages/notifications-view.js -js/pages/profile-view.js -js/pages/network-view.js -js/pages/channels-list.js -exec -/bin/bash -lc 'rg -n "профил|статус|функц|toggle|switch|premium|verified|online|visibility|notifications|privacy|feature|status" js/pages js/components styles index.html' in /home/player/docker/shine-UI succeeded in 0ms: -js/components/toolbar.js:7: { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, -js/pages/wallet-view.js:9: let statusText = 'Данные демонстрационные'; -js/pages/wallet-view.js:11: const status = document.createElement('p'); -js/pages/wallet-view.js:12: status.className = 'meta-muted'; -js/pages/wallet-view.js:15: statusText = text; -js/pages/wallet-view.js:16: status.textContent = statusText; -js/pages/wallet-view.js:67: updateStatus('Демо-функция: перевод будет добавлен позже'); -js/pages/wallet-view.js:71: updateStatus('Демо-функция: пополнение будет добавлено позже'); -js/pages/wallet-view.js:74: updateStatus(statusText); -js/pages/wallet-view.js:76: screen.append(card, actions, status); -js/pages/notifications-view.js:2:import { notifications } from '../mock-data.js'; -js/pages/notifications-view.js:5:export const pageMeta = { id: 'notifications-view', title: 'Уведомления' }; -js/pages/notifications-view.js:8: const active = state.notificationsTab; -js/pages/notifications-view.js:9: const items = notifications[active] || []; -js/pages/notifications-view.js:29: -js/pages/notifications-view.js:30: -js/pages/notifications-view.js:39: state.notificationsTab = btn.dataset.tab; -codex -Нашёл профильный экран. Сейчас открою его и связанные стили, чтобы определить, какие именно два элемента уже используются и можно ли встроить простую подсказку без отдельной тяжёлой инфраструктуры. -exec -/bin/bash -lc "sed -n '1,240p' js/pages/profile-view.js" in /home/player/docker/shine-UI succeeded in 0ms: -import { renderHeader } from '../components/header.js'; -import { profile } from '../mock-data.js'; - -export const pageMeta = { id: 'profile-view', title: 'Профиль' }; - -export function render({ navigate }) { - const screen = document.createElement('section'); - screen.className = 'stack'; - - screen.append( - renderHeader({ - title: 'Профиль', - rightActions: [ - { label: 'Кошелёк', onClick: () => navigate('wallet-view') }, - { label: 'Настройки', onClick: () => navigate('settings-view') }, - ], - }) - ); - - const card = document.createElement('div'); -exec -/bin/bash -lc "sed -n '1,260p' styles/components.css" in /home/player/docker/shine-UI succeeded in 0ms: -.page-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 14px; - gap: 8px; -} - -.page-title { - font-size: 22px; - font-weight: 700; - letter-spacing: 0.2px; -} - -.header-actions, -.header-left { - display: flex; - align-items: center; - gap: 8px; - min-width: 74px; -exec -/bin/bash -lc "sed -n '1,260p' styles/main.css" in /home/player/docker/shine-UI succeeded in 0ms: -:root { - --bg-0: #080b12; - --bg-1: #101624; - --bg-2: #171f32; - --card: #1a2436; - --card-soft: #202d45; - --line: #2a3854; - --text: #ebf1ff; - --text-muted: #99a8cb; - --accent: #53d8fb; - --accent-soft: rgba(83, 216, 251, 0.17); - --danger: #ff718f; - --ok: #84f4a1; - --radius-lg: 18px; - --radius-md: 12px; - --radius-sm: 9px; - --shadow: 0 20px 40px rgba(0, 0, 0, 0.35); - --font-main: "Manrope", "IBM Plex Sans", "Segoe UI", sans-serif; -} - -codex -В профиле нашлись два уже существующих элемента: два бейджа в правом верхнем блоке карточки. Глобальной системы модалок нет, поэтому добавлю лёгкий локальный попап для этой страницы и привяжу его именно к этим двум существующим бейджам без новых дублирующих контролов. -exec -/bin/bash -lc "sed -n '1,220p' js/mock-data.js" in /home/player/docker/shine-UI succeeded in 0ms: -export const profile = { - login: '@shine.alex', - name: 'Алексей Смирнов', - avatarInitials: 'АС', - phone: '+7 (916) 221-45-88', - address: 'Москва, Пресненская наб., 12', - email: 'alex.shine@demo.local', - socials: '@alexshine / t.me/alexshine', - badges: ['Официальный аккаунт', 'Сияющий'], -}; - -export const wallet = { - balanceSOL: '182.4571', - publicAddress: '9sVAXJ2CqP3BrtC6AFeQHhcuWjN1kUyhY7L8pkQJxMZe', - updatedAt: 'сегодня, 14:42', -}; - -export const directMessages = [ - { - id: 'u1', -exec -/bin/bash -lc "sed -n '1,220p' js/app.js" in /home/player/docker/shine-UI succeeded in 0ms: -import { navigate, getRoute } from './router.js'; -import { renderToolbar } from './components/toolbar.js'; -import { renderPageLabel } from './components/page-label.js'; - -import * as profileView from './pages/profile-view.js'; -import * as walletView from './pages/wallet-view.js'; -import * as settingsView from './pages/settings-view.js'; -import * as messagesList from './pages/messages-list.js'; -import * as chatView from './pages/chat-view.js'; -import * as channelsList from './pages/channels-list.js'; -import * as channelView from './pages/channel-view.js'; -import * as networkView from './pages/network-view.js'; -import * as notificationsView from './pages/notifications-view.js'; - -const routes = { - 'profile-view': profileView, - 'wallet-view': walletView, - 'settings-view': settingsView, - 'messages-list': messagesList, - 'chat-view': chatView, -exec -/bin/bash -lc "sed -n '1,220p' js/router.js" in /home/player/docker/shine-UI succeeded in 0ms: -const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view']; - -export function getRoute() { - const raw = window.location.hash.replace(/^#\/?/, ''); - if (!raw) { - return { pageId: 'profile-view', params: {} }; - } - - const [pageId, dynamicId] = raw.split('/'); - - if (pageId === 'chat-view') { - return { pageId, params: { chatId: dynamicId || '' } }; - } - - if (pageId === 'channel-view') { - return { pageId, params: { channelId: dynamicId || '' } }; - } - - return { pageId, params: {} }; -} -codex -Вношу минимальные правки в профиль: превращу оба существующих бейджа в кликабельные триггеры и добавлю один переиспользуемый попап с русскими пояснениями для каждого из них. -file update -M /home/player/docker/shine-UI/js/pages/profile-view.js -@@ -6,2 +6,13 @@ - export function render({ navigate }) { -+ const badgeHelp = { -+ official: { -+ title: 'Официальный аккаунт', -+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', -+ }, -+ shine: { -+ title: 'Сияющий', -+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', -+ }, -+ }; -+ - const screen = document.createElement('section'); -@@ -25,4 +36,4 @@ -
--
✔ ${profile.badges[0]}
--
✨ ${profile.badges[1]}
-+ -+ -
-@@ -41,3 +52,53 @@ - -- screen.append(card); -+ const modal = document.createElement('div'); -+ modal.className = 'profile-help-modal'; -+ modal.hidden = true; -+ modal.innerHTML = ` -+
-+ -+ `; -+ -+ const titleEl = modal.querySelector('#profile-help-title'); -+ const textEl = modal.querySelector('.profile-help-text'); -+ -+ function closeModal() { -+ modal.hidden = true; -+ } -+ -+ function openModal(type) { -+ const content = badgeHelp[type]; -+ if (!content) return; -+ -+ titleEl.textContent = content.title; -+ textEl.textContent = content.text; -+ modal.hidden = false; -+ } -+ -+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => { -+ button.addEventListener('click', () => openModal(button.dataset.badge)); -+ }); -+ -+ modal.addEventListener('click', (event) => { -+ const target = event.target; -+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) { -+ closeModal(); -+ } -+ }); -+ -+ modal.addEventListener('keydown', (event) => { -+ if (event.key === 'Escape') { -+ closeModal(); -+ } -+ }); -+ -+ screen.append(card, modal); - return screen; -apply_patch(auto_approved=true) exited 0 in 33ms: -Success. Updated the following files: -M /home/player/docker/shine-UI/js/pages/profile-view.js -file update: -diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js -index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 ---- a//home/player/docker/shine-UI/js/pages/profile-view.js -+++ b//home/player/docker/shine-UI/js/pages/profile-view.js -@@ -4,6 +4,17 @@ - export const pageMeta = { id: 'profile-view', title: 'Профиль' }; - - export function render({ navigate }) { -+ const badgeHelp = { -+ official: { -+ title: 'Официальный аккаунт', -+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', -+ }, -+ shine: { -+ title: 'Сияющий', -+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', -+ }, -+ }; -+ - const screen = document.createElement('section'); - screen.className = 'stack'; - -@@ -23,8 +34,8 @@ -
-
${profile.avatarInitials}
-
--
✔ ${profile.badges[0]}
--
✨ ${profile.badges[1]}
-+ -+ -
-
-
-@@ -39,6 +50,56 @@ -
- `; - -- screen.append(card); -+ const modal = document.createElement('div'); -+ modal.className = 'profile-help-modal'; -+ modal.hidden = true; -+ modal.innerHTML = ` -+
-+ -+ `; -+ -+ const titleEl = modal.querySelector('#profile-help-title'); -+ const textEl = modal.querySelector('.profile-help-text'); -+ -+ function closeModal() { -+ modal.hidden = true; -+ } -+ -+ function openModal(type) { -+ const content = badgeHelp[type]; -+ if (!content) return; -+ -+ titleEl.textContent = content.title; -+ textEl.textContent = content.text; -+ modal.hidden = false; -+ } -+ -+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => { -+ button.addEventListener('click', () => openModal(button.dataset.badge)); -+ }); -+ -+ modal.addEventListener('click', (event) => { -+ const target = event.target; -+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) { -+ closeModal(); -+ } -+ }); -+ -+ modal.addEventListener('keydown', (event) => { -+ if (event.key === 'Escape') { -+ closeModal(); -+ } -+ }); -+ -+ screen.append(card, modal); - return screen; - } - -file update: -diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js -index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 ---- a//home/player/docker/shine-UI/js/pages/profile-view.js -+++ b//home/player/docker/shine-UI/js/pages/profile-view.js -@@ -4,6 +4,17 @@ - export const pageMeta = { id: 'profile-view', title: 'Профиль' }; - - export function render({ navigate }) { -+ const badgeHelp = { -+ official: { -+ title: 'Официальный аккаунт', -+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', -+ }, -+ shine: { -+ title: 'Сияющий', -+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', -+ }, -+ }; -+ - const screen = document.createElement('section'); - screen.className = 'stack'; - -@@ -23,8 +34,8 @@ -
-
${profile.avatarInitials}
-
--
✔ ${profile.badges[0]}
--
✨ ${profile.badges[1]}
-+ -+ -
-
-
-@@ -39,6 +50,56 @@ -
- `; - -- screen.append(card); -+ const modal = document.createElement('div'); -+ modal.className = 'profile-help-modal'; -+ modal.hidden = true; -+ modal.innerHTML = ` -+
-+ -+ `; -+ -+ const titleEl = modal.querySelector('#profile-help-title'); -+ const textEl = modal.querySelector('.profile-help-text'); -+ -+ function closeModal() { -+ modal.hidden = true; -+ } -+ -+ function openModal(type) { -+ const content = badgeHelp[type]; -+ if (!content) return; -+ -+ titleEl.textContent = content.title; -+ textEl.textContent = content.text; -+ modal.hidden = false; -+ } -+ -+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => { -+ button.addEventListener('click', () => openModal(button.dataset.badge)); -+ }); -+ -+ modal.addEventListener('click', (event) => { -+ const target = event.target; -+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) { -+ closeModal(); -+ } -+ }); -+ -+ modal.addEventListener('keydown', (event) => { -+ if (event.key === 'Escape') { -+ closeModal(); -+ } -+ }); -+ -+ screen.append(card, modal); - return screen; - } - -file update -M /home/player/docker/shine-UI/styles/components.css -@@ -89,2 +89,6 @@ - -+.badge.profile-badge-trigger { -+ cursor: pointer; -+} -+ - .badge.alt { -@@ -95,2 +99,36 @@ - -+.profile-help-modal[hidden] { -+ display: none; -+} -+ -+.profile-help-modal { -+ position: fixed; -+ inset: 0; -+ z-index: 20; -+} -+ -+.profile-help-backdrop { -+ position: absolute; -+ inset: 0; -+ background: rgba(5, 9, 16, 0.72); -+ backdrop-filter: blur(4px); -+} -+ -+.profile-help-dialog { -+ position: absolute; -+ left: 16px; -+ right: 16px; -+ bottom: 24px; -+ display: grid; -+ gap: 12px; -+ padding: 16px; -+ box-shadow: var(--shadow); -+} -+ -+.profile-help-text { -+ color: #d8e3ff; -+ line-height: 1.45; -+ font-size: 14px; -+} -+ - .list-item { -apply_patch(auto_approved=true) exited 0 in 32ms: -Success. Updated the following files: -M /home/player/docker/shine-UI/styles/components.css -file update: -diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js -index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 ---- a//home/player/docker/shine-UI/js/pages/profile-view.js -+++ b//home/player/docker/shine-UI/js/pages/profile-view.js -@@ -4,6 +4,17 @@ - export const pageMeta = { id: 'profile-view', title: 'Профиль' }; - - export function render({ navigate }) { -+ const badgeHelp = { -+ official: { -+ title: 'Официальный аккаунт', -+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', -+ }, -+ shine: { -+ title: 'Сияющий', -+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', -+ }, -+ }; -+ - const screen = document.createElement('section'); - screen.className = 'stack'; - -@@ -23,8 +34,8 @@ -
-
${profile.avatarInitials}
-
--
✔ ${profile.badges[0]}
--
✨ ${profile.badges[1]}
-+ -+ -
-
-
-@@ -39,6 +50,56 @@ -
- `; - -- screen.append(card); -+ const modal = document.createElement('div'); -+ modal.className = 'profile-help-modal'; -+ modal.hidden = true; -+ modal.innerHTML = ` -+
-+ -+ `; -+ -+ const titleEl = modal.querySelector('#profile-help-title'); -+ const textEl = modal.querySelector('.profile-help-text'); -+ -+ function closeModal() { -+ modal.hidden = true; -+ } -+ -+ function openModal(type) { -+ const content = badgeHelp[type]; -+ if (!content) return; -+ -+ titleEl.textContent = content.title; -+ textEl.textContent = content.text; -+ modal.hidden = false; -+ } -+ -+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => { -+ button.addEventListener('click', () => openModal(button.dataset.badge)); -+ }); -+ -+ modal.addEventListener('click', (event) => { -+ const target = event.target; -+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) { -+ closeModal(); -+ } -+ }); -+ -+ modal.addEventListener('keydown', (event) => { -+ if (event.key === 'Escape') { -+ closeModal(); -+ } -+ }); -+ -+ screen.append(card, modal); - return screen; - } -diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css -index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f ---- a//home/player/docker/shine-UI/styles/components.css -+++ b//home/player/docker/shine-UI/styles/components.css -@@ -87,12 +87,50 @@ - font-weight: 600; - } - -+.badge.profile-badge-trigger { -+ cursor: pointer; -+} -+ - .badge.alt { - border-color: rgba(83, 216, 251, 0.35); - color: #dff8ff; - background: rgba(83, 216, 251, 0.11); - } - -+.profile-help-modal[hidden] { -+ display: none; -+} -+ -+.profile-help-modal { -+ position: fixed; -+ inset: 0; -+ z-index: 20; -+} -+ -+.profile-help-backdrop { -+ position: absolute; -+ inset: 0; -+ background: rgba(5, 9, 16, 0.72); -+ backdrop-filter: blur(4px); -+} -+ -+.profile-help-dialog { -+ position: absolute; -+ left: 16px; -+ right: 16px; -+ bottom: 24px; -+ display: grid; -+ gap: 12px; -+ padding: 16px; -+ box-shadow: var(--shadow); -+} -+ -+.profile-help-text { -+ color: #d8e3ff; -+ line-height: 1.45; -+ font-size: 14px; -+} -+ - .list-item { - display: grid; - grid-template-columns: 44px 1fr auto; - -file update: -diff --git a//home/player/docker/shine-UI/js/pages/profile-view.js b//home/player/docker/shine-UI/js/pages/profile-view.js -index 7007e0833f41d9b318fbedabb84ee7d44a42a97e..0ca25dc4432aa0f6a17686bf267fd89bdc32fa91 ---- a//home/player/docker/shine-UI/js/pages/profile-view.js -+++ b//home/player/docker/shine-UI/js/pages/profile-view.js -@@ -4,6 +4,17 @@ - export const pageMeta = { id: 'profile-view', title: 'Профиль' }; - - export function render({ navigate }) { -+ const badgeHelp = { -+ official: { -+ title: 'Официальный аккаунт', -+ text: 'Эта настройка включает или отключает отметку официального аккаунта в профиле. Используйте её, когда нужно показать или скрыть подтверждённый статус.', -+ }, -+ shine: { -+ title: 'Сияющий', -+ text: 'Этот переключатель включает или отключает режим «Сияющий». Он управляет отображением дополнительного визуального акцента для профиля.', -+ }, -+ }; -+ - const screen = document.createElement('section'); - screen.className = 'stack'; - -@@ -23,8 +34,8 @@ -
-
${profile.avatarInitials}
-
--
✔ ${profile.badges[0]}
--
✨ ${profile.badges[1]}
-+ -+ -
-
-
-@@ -39,6 +50,56 @@ -
- `; - -- screen.append(card); -+ const modal = document.createElement('div'); -+ modal.className = 'profile-help-modal'; -+ modal.hidden = true; -+ modal.innerHTML = ` -+
-+ -+ `; -+ -+ const titleEl = modal.querySelector('#profile-help-title'); -+ const textEl = modal.querySelector('.profile-help-text'); -+ -+ function closeModal() { -+ modal.hidden = true; -+ } -+ -+ function openModal(type) { -+ const content = badgeHelp[type]; -+ if (!content) return; -+ -+ titleEl.textContent = content.title; -+ textEl.textContent = content.text; -+ modal.hidden = false; -+ } -+ -+ card.querySelectorAll('.profile-badge-trigger').forEach((button) => { -+ button.addEventListener('click', () => openModal(button.dataset.badge)); -+ }); -+ -+ modal.addEventListener('click', (event) => { -+ const target = event.target; -+ if (target instanceof HTMLElement && (target.dataset.close === 'true' || target.classList.contains('profile-help-close'))) { -+ closeModal(); -+ } -+ }); -+ -+ modal.addEventListener('keydown', (event) => { -+ if (event.key === 'Escape') { -+ closeModal(); -+ } -+ }); -+ -+ screen.append(card, modal); - return screen; - } -diff --git a//home/player/docker/shine-UI/styles/components.css b//home/player/docker/shine-UI/styles/components.css -index faa813fb5d384f1a4cfafd860398c8843a4b8841..1c33cf1eb9f441e3974dc9d2d44f5d4ee61b260f ---- a//home/player/docker/shine-UI/styles/components.css -+++ b//home/player/docker/shine-UI/styles/components.css -@@ -87,12 +87,50 @@ - font-weight: 600; - } - -+.badge.profile-badge-trigger { -+ cursor: pointer; -+} -+ - .badge.alt { - border-color: rgba(83, 216, 251, 0.35); - color: #dff8ff; - background: rgba(83, 216, 251, 0.11); - } - -+.profile-help-modal[hidden] { -+ display: none; -+} -+ -+.profile-help-modal { -+ position: fixed; -+ inset: 0; -+ z-index: 20; -+} -+ -+.profile-help-backdrop { -+ position: absolute; -+ inset: 0; -+ background: rgba(5, 9, 16, 0.72); -+ backdrop-filter: blur(4px); -+} -+ -+.profile-help-dialog { -+ position: absolute; -+ left: 16px; -+ right: 16px; -+ bottom: 24px; -+ display: grid; -+ gap: 12px; -+ padding: 16px; -+ box-shadow: var(--shadow); -+} -+ -+.profile-help-text { -+ color: #d8e3ff; -+ line-height: 1.45; -+ font-size: 14px; -+} -+ - .list-item { - display: grid; - grid-template-columns: 44px 1fr auto; - -codex -Добавлю ещё одну небольшую доработку для надёжности: попап должен закрываться по `Esc`, поэтому дам диалогу фокус при открытии, без глобальных обработчиков и лишней инфраструктуры. -file update -M /home/player/docker/shine-UI/js/pages/profile-view.js -@@ -57,3 +57,3 @@ -
--