diff --git a/.gitignore b/.gitignore index 962572ac..29bcddec 100644 --- a/.gitignore +++ b/.gitignore @@ -106,7 +106,9 @@ ESP32/**/*.a deploy/backup/archive/** !deploy/backup/archive/.gitkeep -# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим +# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим +.agents/ +.codex/ .claude/ # Рабочие бэкапы/превью-ассеты UI — не для репозитория *.bak.png diff --git a/AGENTS.md b/AGENTS.md index 21aa62f4..251b22aa 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 @@ -44,6 +45,11 @@ - `docs/Solana_Architecture/README.md` - Документ формата пользовательской PDA-записи `shine_users` находится в: - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` +- Актуальная документация по серверному модулю синхронизации Solana users находится в: + - `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md` +- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять: + - `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md` + - `deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md` ## Документация блокчейна - Актуальная документация по форматам блокчейна находится в `docs/Blockchain/README.md`. @@ -78,12 +84,8 @@ - Для экранов регистрации, входа и других чувствительных 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 - Все документы, инструкции, backup-правила и скрипты деплоя хранить в папке `deploy/`. @@ -92,7 +94,6 @@ - Тестовые/devnet серверы SHiNE: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. - В deploy-документах и скриптах использовать домены, а не IP. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. -- Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`. - Любые изменения и любой деплой на production (`shineup.me` и `server2.shineup.me`) выполнять только после отдельного явного подтверждения пользователя. - Перед production deploy обязательно проверить/обновить бэкап в `deploy/backup/archive/`. - Если пользователь пишет просто `задеплой` без уточнения production/test, уточнить целевой контур; не выбирать production автоматически. 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/Players/blackbyrd1/files/.gitkeep b/Players/blackbyrd1/files/.gitkeep deleted file mode 100644 index 4c0d52d1..00000000 --- a/Players/blackbyrd1/files/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - 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 cfe05a4f..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-сервер; -- документация `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 97eb8577..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/` не начинать реализовывать без явной команды пользователя. -- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `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 573f3bea..2cf116e6 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -9,7 +9,7 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web - `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера - `shine-server-net-protocol/` — обработчики операций (RPC и события WS) -- `shine-server-db/` — DAO, SQL-схема, SQLite +- `shine-server-db/` — DAO, SQL-схема, PostgreSQL runtime - `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна - `shine-server-crypto/` — криптографические утилиты - `shine-server-config/` — конфигурация сервера diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index c01390c1..21103a16 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -36,7 +36,8 @@ public final class BodyRecordParser { case TextBody.KEY -> { if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + || st == (MsgSubType.TEXT_REPOST & 0xFFFF) + || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { yield new TextLineBody(subType, version, bodyBytes); } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 3464d6f6..f5b72745 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -70,6 +70,12 @@ public final class MsgSubType { */ public static final short TEXT_REPOST = 30; + /** + * CHANNEL_META — скрытый технический снимок профиля канала. + * Имеет hasLine, использует body как POST: line-поля + UTF-8 текст. + */ + public static final short TEXT_CHANNEL_META = 70; + /* ===================== REACTION (msg_type=2) ===================== */ /** Лайк (LIKE). */ diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java index 3bae2410..2ba5af48 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java @@ -19,7 +19,7 @@ import java.util.Objects; * [1] channelNameLen * [N] channelName UTF-8 * [2] channelDescriptionLen - * [M] channelDescription UTF-8 (0..200 bytes) + * [M] channelDescription UTF-8 (0..2048 bytes) * [2] channelTypeCode (uint16) * [2] channelTypeVersion (uint16) */ @@ -38,7 +38,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { private static final byte[] ZERO32 = new byte[32]; private static final int MAX_NAME_LENGTH = 32; - private static final int MAX_DESCRIPTION_UTF8_LEN = 200; + public static final int MAX_DESCRIPTION_UTF8_LEN = 2048; public final short subType; public final short version; @@ -88,7 +88,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { int descriptionLen = Short.toUnsignedInt(bb.getShort()); if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) { - throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); + throw new IllegalArgumentException("channelDescription utf8 len must be <=2048"); } if (bb.remaining() != descriptionLen + 4) { throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen); @@ -145,7 +145,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { String normalizedDescription = normalizeDescription(channelDescription); byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8); if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { - throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); + throw new IllegalArgumentException("channelDescription utf8 len must be <=2048"); } int typeCode = Short.toUnsignedInt(channelTypeCode); @@ -166,7 +166,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { private static String normalizeDescription(String value) { if (value == null) return ""; - return value.trim().replaceAll("\\s+", " "); + return value.trim().replace("\r\n", "\n").replace('\r', '\n'); } @Override @@ -177,7 +177,7 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { } byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8); if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { - throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); + throw new IllegalArgumentException("channelDescription utf8 len must be <=2048"); } int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4; @@ -204,4 +204,3 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { @Override public int lineSeq() { return thisLineNumber; } } - diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 748a7467..2e392f12 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -17,10 +17,11 @@ import java.util.Objects; * - POST (10) * - EDIT_POST (11) * - REPOST (30) + * - CHANNEL_META (70) * * Формат bodyBytes (BigEndian): * - * POST: + * POST / CHANNEL_META: * [4] lineCode * [4] prevLineNumber * [32] prevLineHash32 @@ -89,8 +90,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge int st = this.subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st); + && st != (MsgSubType.TEXT_REPOST & 0xFFFF) + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); @@ -133,7 +135,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge this.toBlockHash32 = null; } - this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)); + this.message = readStrictUtf8Len16(bb, "TextLineBody text", + st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)); ensureNoTail(bb, "TextLineBody"); } @@ -155,8 +158,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST"); + && st != (MsgSubType.TEXT_REPOST & 0xFFFF) + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META"); } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -206,7 +210,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge int st = subType & 0xFFFF; if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) + && st != (MsgSubType.TEXT_REPOST & 0xFFFF) + && st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) throw new IllegalArgumentException("Bad TextLineBody subType: " + st); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -231,10 +236,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 invalid"); } else { - if (message == null || message.isBlank()) + if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { + if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null"); + } else if (message == null || message.isBlank()) { throw new IllegalArgumentException("Text message is blank"); + } if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) - throw new IllegalArgumentException("POST must not contain target fields"); + throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields"); } return this; @@ -251,7 +259,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } int cap; - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { + if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // EDIT_POST diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 0d53fe4c..027779b0 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -59,6 +59,8 @@ public final class AppConfig { public String getParam(String name) { String fromSystem = System.getProperty(name); if (fromSystem != null) return fromSystem; + String fromEnv = System.getenv(toEnvName(name)); + if (fromEnv != null && !fromEnv.isBlank()) return fromEnv.trim(); return properties.getProperty(name); } @@ -78,4 +80,11 @@ public final class AppConfig { String v = properties.getProperty(name); return v == null ? defaultValue : Boolean.parseBoolean(v); } + + private static String toEnvName(String name) { + return name + .replace('.', '_') + .replace('-', '_') + .toUpperCase(); + } } diff --git a/SHiNE-server/shine-server-db/build.gradle b/SHiNE-server/shine-server-db/build.gradle index cf446c18..5be85995 100644 --- a/SHiNE-server/shine-server-db/build.gradle +++ b/SHiNE-server/shine-server-db/build.gradle @@ -16,7 +16,7 @@ repositories { } dependencies { - implementation 'org.xerial:sqlite-jdbc:3.47.0.0' // sqlite + implementation 'org.postgresql:postgresql:42.7.7' implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера 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..7bccfda8 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 @@ -1,49 +1,43 @@ package shine.db; -import utils.config.AppConfig; - -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.*; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; /** - * DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE. - * - * В этой версии: - * - создаём ТОЛЬКО таблицы/индексы - * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) - * - * v2 (sessions): - * - active_sessions.session_pwd удалён - * - active_sessions.session_key хранит публичный ключ сессии целиком одной строкой + * PostgreSQL runtime schema bootstrapper for SHiNE server. */ public final class DatabaseInitializer { public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version"; public static final int SCHEMA_VERSION_1 = 1; + public static final int SCHEMA_VERSION_2 = 2; + public static final int SCHEMA_VERSION_3 = 3; + public static final int SCHEMA_VERSION_4 = 4; + public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; + public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; + public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; + public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql"; private DatabaseInitializer() {} - /* ===================== TEXT (msg_type=1) ===================== */ - public static final short TEXT_POST = 10; public static final short TEXT_EDIT_POST = 11; public static final short TEXT_REPLY = 20; public static final short TEXT_EDIT_REPLY = 21; public static final short TEXT_REPOST = 30; - - /* ===================== REACTION (msg_type=2) ===================== */ + public static final short TEXT_CHANNEL_META = 70; public static final short REACTION_LIKE = 1; public static final short REACTION_UNLIKE = 2; - /* ===================== CONNECTION (msg_type=3) ===================== */ - // Близкий друг (close friend). Исторически в коде использовалось имя FRIEND. public static final short CONNECTION_FRIEND = 10; public static final short CONNECTION_UNFRIEND = 11; public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND; @@ -76,635 +70,225 @@ public final class DatabaseInitializer { public static final short CONNECTION_SHINE_SEEN = 74; public static final short CONNECTION_SHINE_UNSEEN = 75; - public static void createNewDB(String[] args) { - AppConfig config = AppConfig.getInstance(); - String dbPath = config.getParam("db.path"); - - if (dbPath == null || dbPath.isBlank()) { - System.err.println("Параметр db.path не задан в application.properties"); - return; - } - - Path dbFile = Paths.get(dbPath); + public static void ensurePostgresSchemaInitialized(String jdbcUrl, + String user, + String password) throws SQLException { try { - Path parent = dbFile.getParent(); - if (parent != null && !Files.exists(parent)) { - Files.createDirectories(parent); - } - - if (Files.exists(dbFile)) { - System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath()); - System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: "); - - BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); - String answer = reader.readLine(); - if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) { - System.out.println("Операция отменена. БД не изменена."); - return; - } - - Files.delete(dbFile); - System.out.println("Старый файл БД удалён."); - } - - createSchema("jdbc:sqlite:" + dbPath); - System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath()); - - } catch (IOException e) { - System.err.println("Ошибка работы с файлом БД: " + e.getMessage()); - } catch (SQLException e) { - System.err.println("Ошибка создания схемы БД: " + e.getMessage()); - } - } - - public static void ensureSchemaV1Structure(String jdbcUrl) throws SQLException { - createSchema(jdbcUrl, false); - } - - private static void createSchema(String jdbcUrl) throws SQLException { - createSchema(jdbcUrl, true); - } - - private static void createSchema(String jdbcUrl, boolean initializeVersionRow) throws SQLException { - try { - Class.forName("org.sqlite.JDBC"); + Class.forName("org.postgresql.Driver"); } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); + throw new RuntimeException("PostgreSQL JDBC driver not found", e); } - try (Connection conn = DriverManager.getConnection(jdbcUrl); - Statement st = conn.createStatement()) { - - st.execute("PRAGMA foreign_keys = ON"); - - // 1. solana_users - // ВАЖНО: - // - Все требуемые поля теперь лежат в solana_users: - // login, blockchain_name, solana_key, blockchain_key, client_key - // - Поиск по login в DAO сделан case-insensitive. - // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS solana_users ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - blockchain_name TEXT NOT NULL, - solana_key TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - client_key TEXT NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name - ON solana_users (blockchain_name); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_solana_users_login - ON solana_users (login); - """); - - // 2. active_sessions (v2) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS active_sessions ( - session_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - session_key TEXT NOT NULL, - storage_pwd TEXT NOT NULL, - session_created_at_ms INTEGER NOT NULL, - last_authirificated_at_ms INTEGER NOT NULL, - push_endpoint TEXT, - push_p256dh_key TEXT, - push_auth_key TEXT, - client_ip TEXT, - client_info_from_client TEXT, - client_info_from_request TEXT, - session_type INTEGER NOT NULL DEFAULT 1, - client_platform TEXT NOT NULL DEFAULT '', - user_language TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_active_sessions_login - ON active_sessions (login); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_settings ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - enabled INTEGER NOT NULL DEFAULT 0, - password_hash TEXT NOT NULL DEFAULT '', - ttl_seconds INTEGER NOT NULL DEFAULT 300, - failed_attempts INTEGER NOT NULL DEFAULT 0, - first_failed_at_ms INTEGER NOT NULL DEFAULT 0, - blocked_until_ms INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_requests ( - pairing_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - requester_session_key TEXT NOT NULL, - requester_session_type INTEGER NOT NULL DEFAULT 1, - requester_client_platform TEXT NOT NULL DEFAULT '', - payload_type INTEGER NOT NULL, - status TEXT NOT NULL, - short_code TEXT NOT NULL, - fingerprint_b58 TEXT NOT NULL, - encrypted_payload TEXT, - reject_reason TEXT, - approved_by_session_id TEXT, - created_at_ms INTEGER NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status - ON esp_pairing_requests (login, status, expires_at_ms); - """); - - // 3. users_params - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS users_params ( - login TEXT NOT NULL, - param TEXT NOT NULL, - time_ms INTEGER NOT NULL, - value TEXT NOT NULL, - client_key TEXT, - signature TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, param) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_users_params_login - ON users_params (login); - """); - - // 4. ip_geo_cache - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS ip_geo_cache ( - ip TEXT NOT NULL PRIMARY KEY, - geo TEXT, - updated_at_ms INTEGER NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at - ON ip_geo_cache (updated_at_ms); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - used_count INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - last_tx_id TEXT NOT NULL DEFAULT '', - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated - ON test_free_avatar_uploads (updated_at_ms); - """); - - // 5. blockchain_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blockchain_state ( - blockchain_name TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - - size_limit INTEGER NOT NULL, - file_size_bytes INTEGER NOT NULL, - - last_block_number INTEGER NOT NULL, - last_block_hash BLOB, - - updated_at_ms INTEGER NOT NULL, - - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_login - ON blockchain_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at - ON blockchain_state (updated_at_ms); - """); - - // 6. blocks (+ line_code) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blocks ( - login TEXT NOT NULL, - bch_name TEXT NOT NULL, - block_number INTEGER NOT NULL CHECK(block_number >= 0), - - msg_type INTEGER NOT NULL, - msg_sub_type INTEGER NOT NULL, - - block_bytes BLOB NOT NULL, - - -- target (reply/like/edit и т.д.) - to_login TEXT, - to_bch_name TEXT, - to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0), - to_block_hash BLOB, - - -- собственные данные - block_hash BLOB NOT NULL, - block_signature BLOB NOT NULL, - - -- если этот блок был изменён последним edit'ом - edited_by_block_number INTEGER CHECK(edited_by_block_number IS NULL OR edited_by_block_number >= 0), - - -- линейность (опционально) - line_code INTEGER CHECK(line_code IS NULL OR line_code >= 0), - prev_line_number INTEGER CHECK(prev_line_number IS NULL OR prev_line_number >= 0), - prev_line_hash BLOB, - this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0), - - FOREIGN KEY (login) REFERENCES solana_users(login), - FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name), - - UNIQUE (bch_name, block_number) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number - ON blocks (bch_name, block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_to_target - ON blocks (to_login, to_bch_name, to_block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_line - ON blocks (bch_name, line_code, this_line_number); - """); - - // 7) connections_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - - FOREIGN KEY (login) REFERENCES solana_users(login), - - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_login - ON connections_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_to_login - ON connections_state (to_login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_pair - ON connections_state (login, to_login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_target - ON connections_state (login, rel_type, to_bch_name, to_block_number); - """); - - // 8) message_stats - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS message_stats ( - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - - likes_count INTEGER NOT NULL DEFAULT 0, - replies_count INTEGER NOT NULL DEFAULT 0, - edits_count INTEGER NOT NULL DEFAULT 0, - - UNIQUE ( - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_target - ON message_stats (to_bch_name, to_block_number, to_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_login - ON message_stats (to_login); - """); - - // 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS reactions_state ( - from_login TEXT NOT NULL, - from_bch_name TEXT NOT NULL, - reaction_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - last_sub_type INTEGER NOT NULL, - - UNIQUE ( - from_login, - from_bch_name, - reaction_type, - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_target - ON reactions_state (to_bch_name, to_block_number, to_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_actor - ON reactions_state (from_login, from_bch_name, reaction_type); - """); - - // 9) channel_names_state (global normalized channel names) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL, - display_name TEXT NOT NULL, - channel_description TEXT NOT NULL DEFAULT '', - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_type_code INTEGER NOT NULL DEFAULT 1, - channel_type_version INTEGER NOT NULL DEFAULT 1, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - created_at_ms INTEGER NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug - ON channel_names_state (owner_bch_name, channel_type_code, slug); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target - ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner - ON channel_names_state (owner_login, owner_bch_name); - """); - - // 9.1) chat200_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_state ( - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - channel_name TEXT NOT NULL, - channel_type_version INTEGER NOT NULL, - chat_title TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL, - PRIMARY KEY (owner_bch_name, channel_root_block_number) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_state_owner - ON chat200_state (owner_login, owner_bch_name); - """); - - // 9.2) chat200_members_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_members_state ( - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - member_login TEXT NOT NULL, - member_channel_name TEXT NOT NULL, - is_active INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - updated_by_block_number INTEGER NOT NULL, - PRIMARY KEY ( - owner_bch_name, - channel_root_block_number, - member_login, - member_channel_name - ) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_members_owner - ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); - """); - - // 10) direct_messages - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS direct_messages ( - message_id TEXT NOT NULL PRIMARY KEY, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - text TEXT NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login - ON direct_messages (to_login, created_at_ms); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login - ON direct_messages (from_login, created_at_ms); - """); - - // 11) user_push_tokens - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS user_push_tokens ( - token_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - session_id TEXT NOT NULL, - provider TEXT NOT NULL, - token TEXT NOT NULL, - platform TEXT, - user_agent TEXT, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login - ON user_push_tokens (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session - ON user_push_tokens (login, session_id); - """); - - // 11) signed_direct_message_replay (anti-replay window) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_direct_message_replay ( - from_login TEXT NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - created_at_ms INTEGER NOT NULL, - UNIQUE (from_login, time_ms, nonce) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created - ON signed_direct_message_replay (created_at_ms); - """); - - // 12) signed_direct_messages_history (сырой бинарный пакет + мета) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_direct_messages_history ( - message_id TEXT NOT NULL PRIMARY KEY, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - target_mode INTEGER NOT NULL, - target_session_id TEXT, - message_type INTEGER NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - raw_packet BLOB NOT NULL, - created_at_ms INTEGER NOT NULL, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to - ON signed_direct_messages_history (to_login, created_at_ms); - """); - - // 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_messages_v2 ( - message_key TEXT NOT NULL PRIMARY KEY, - base_key TEXT NOT NULL, - target_login TEXT NOT NULL, - from_login TEXT NOT NULL, - to_login TEXT NOT NULL, - time_ms INTEGER NOT NULL, - nonce INTEGER NOT NULL, - message_type INTEGER NOT NULL, - revision_time_ms INTEGER NOT NULL DEFAULT 0, - reencrypted_at_ms INTEGER NOT NULL DEFAULT 0, - raw_block BLOB NOT NULL, - created_at_ms INTEGER NOT NULL, - source_api TEXT NOT NULL, - origin_session_id TEXT, - receipt_ref_base_key TEXT, - receipt_ref_type INTEGER, - FOREIGN KEY (from_login) REFERENCES solana_users(login), - FOREIGN KEY (to_login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_target - ON signed_messages_v2 (target_login, time_ms, created_at_ms); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_messages_v2_base - ON signed_messages_v2 (base_key, message_type); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; - """); - - // 14) signed_message_session_delivery (доставка по сессиям) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( - message_key TEXT NOT NULL, - session_id TEXT NOT NULL, - delivered INTEGER NOT NULL DEFAULT 0, - delivered_at_ms INTEGER, - created_at_ms INTEGER NOT NULL, - PRIMARY KEY (message_key, session_id), - FOREIGN KEY (message_key) REFERENCES signed_messages_v2(message_key) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session - ON signed_message_session_delivery (session_id, delivered); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS db_schema_version ( - id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), - schema_version INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL - ); - """); - - if (initializeVersionRow) { - st.executeUpdate(""" - INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 1, CAST(strftime('%s','now') AS INTEGER) * 1000) - ON CONFLICT(id) DO UPDATE SET - schema_version = excluded.schema_version, - updated_at_ms = excluded.updated_at_ms; - """); + try (Connection conn = openConnection(jdbcUrl, user, password)) { + if (!postgresSchemaVersionTableExists(conn)) { + runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE); + return; + } + int currentVersion = readCurrentSchemaVersion(conn); + if (currentVersion < SCHEMA_VERSION_2) { + runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE); + currentVersion = SCHEMA_VERSION_2; + } + if (currentVersion < SCHEMA_VERSION_3) { + runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE); + currentVersion = SCHEMA_VERSION_3; + } + if (currentVersion < SCHEMA_VERSION_4) { + runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE); + } + } + } + + private static Connection openConnection(String jdbcUrl, String user, String password) throws SQLException { + if (user == null || user.isBlank()) { + return DriverManager.getConnection(jdbcUrl); + } + return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password); + } + + private static boolean postgresSchemaVersionTableExists(Connection conn) throws SQLException { + String sql = """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = ? + ) + """; + try (var ps = conn.prepareStatement(sql)) { + ps.setString(1, DB_SCHEMA_VERSION_TABLE); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() && rs.getBoolean(1); + } + } + } + + private static int readCurrentSchemaVersion(Connection conn) throws SQLException { + String sql = """ + SELECT schema_version + FROM db_schema_version + WHERE id = 1 + """; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + if (!rs.next()) { + return SCHEMA_VERSION_1; + } + return rs.getInt(1); + } + } + + private static void runSqlScript(Connection conn, String resourcePath) throws SQLException { + String sqlScript = loadClasspathResource(resourcePath); + List statements = splitSqlStatements(sqlScript); + + boolean previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(true); + try (Statement st = conn.createStatement()) { + for (String statement : statements) { + String trimmed = statement.trim(); + if (!trimmed.isEmpty()) { + st.execute(trimmed); + } + } + } finally { + conn.setAutoCommit(previousAutoCommit); + } + } + + private static String loadClasspathResource(String resourcePath) { + try (InputStream in = DatabaseInitializer.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (in == null) { + throw new RuntimeException("Resource not found: " + resourcePath); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to read resource: " + resourcePath, e); + } + } + + static List splitSqlStatements(String sqlScript) { + List statements = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inSingleQuote = false; + boolean inLineComment = false; + boolean inBlockComment = false; + String dollarQuoteTag = null; + + for (int i = 0; i < sqlScript.length(); i++) { + char ch = sqlScript.charAt(i); + + if (inLineComment) { + current.append(ch); + if (ch == '\n') { + inLineComment = false; + } + continue; } - DatabaseTriggersInstaller.createAllTriggers(st); + if (inBlockComment) { + current.append(ch); + if (ch == '*' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '/') { + current.append('/'); + i += 1; + inBlockComment = false; + } + continue; + } + + if (dollarQuoteTag != null) { + if (startsWithAt(sqlScript, i, dollarQuoteTag)) { + current.append(dollarQuoteTag); + i += dollarQuoteTag.length() - 1; + dollarQuoteTag = null; + } else { + current.append(ch); + } + continue; + } + + if (inSingleQuote) { + current.append(ch); + if (ch == '\'') { + if (i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '\'') { + current.append('\''); + i += 1; + } else { + inSingleQuote = false; + } + } + continue; + } + + if (ch == '-' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '-') { + current.append("--"); + i += 1; + inLineComment = true; + continue; + } + + if (ch == '/' && i + 1 < sqlScript.length() && sqlScript.charAt(i + 1) == '*') { + current.append("/*"); + i += 1; + inBlockComment = true; + continue; + } + + if (ch == '\'') { + inSingleQuote = true; + current.append(ch); + continue; + } + + String tag = readDollarQuoteTag(sqlScript, i); + if (tag != null) { + current.append(tag); + i += tag.length() - 1; + dollarQuoteTag = tag; + continue; + } + + if (ch == ';') { + String statement = current.toString().trim(); + if (!statement.isEmpty()) { + statements.add(current.toString()); + } + current.setLength(0); + continue; + } + + current.append(ch); } + + if (!current.isEmpty()) { + String statement = current.toString().trim(); + if (!statement.isEmpty()) { + statements.add(current.toString()); + } + } + return statements; + } + + private static boolean startsWithAt(String sqlScript, int index, String token) { + return sqlScript.regionMatches(index, token, 0, token.length()); + } + + private static String readDollarQuoteTag(String sqlScript, int index) { + if (sqlScript.charAt(index) != '$') { + return null; + } + + int end = index + 1; + while (end < sqlScript.length()) { + char current = sqlScript.charAt(end); + if (current == '$') { + return sqlScript.substring(index, end + 1); + } + if (!(Character.isLetterOrDigit(current) || current == '_')) { + return null; + } + end++; + } + return null; } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java deleted file mode 100644 index cb430a35..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java +++ /dev/null @@ -1,569 +0,0 @@ -package shine.db; - -import java.sql.SQLException; -import java.sql.ResultSet; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.List; - -/** - * DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД. - * - * РњС‹ специально сделали триггеры максимально "совместимыми": - * - НЕТ динамических сообщений РІ RAISE(...): только фиксированные строки. - * (Некоторые SQLite-СЃР±РѕСЂРєРё / просмотрщики падают РЅР° "||" внутри RAISE.) - * - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него: - * INSERT OR IGNORE + UPDATE - * (Старые SQLite РЅРµ знают UPSERT.) - * - * ============================================================================= - * ОПИСАНИЕ ТРИГГЕРОВ - * ============================================================================= - * - * [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) - * Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number). - * - * Зачем это РЅСѓР¶РЅРѕ: - * - Р’ каналах/ветках/действиях ты хочешь иметь "линейную" последовательность, - * РіРґРµ каждый следующий блок СЏРІРЅРѕ ссылается РЅР° предыдущий блок линии - * Рё подтверждает, что ссылка РЅРµ подменена. - * - * РљРѕРіРґР° срабатывает: - * - ТОЛЬКО если РїСЂРё вставке передано РҐРћРўРЇ БЫ ОДНО РёР· line-полей. - * - Если line-поля РЅРµ переданы — триггер вообще РЅРµ работает (это важно). - * - * Что проверяет: - * A) line-поля допускаются только для msg_type: - * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM) - * B) Если пришло хоть РѕРґРЅРѕ line-поле — обязаны прийти Р’РЎР• 4 (никаких "частичных") - * C) prev-блок линии существует РІ той Р¶Рµ цепочке bch_name - * D) prev_hash совпадает СЃ block_hash найденного prev-блока - * E) line_code корректный: - * - либо первый шаг после root: prev_line_number == line_code - * - либо prev СѓР¶Рµ принадлежит этой линии: p.line_code == NEW.line_code - * F) this_line_number: - * - первый шаг после root: - * TEXT: this_line_number = 0 - * TECH/CONNECTION/USER_PARAM: this_line_number = 1 - * - обычный шаг: - * TEXT: допускаем same или +1 (чтобы "edit" РјРѕРі РЅРµ двигать шаг) - * TECH/CONNECTION/USER_PARAM: строго prev.this + 1 - * - * Какие ошибки кидает: - * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE - * - LINE_ERR_PARTIAL_FIELDS - * - LINE_ERR_NO_PREV - * - LINE_ERR_PREV_HASH_MISMATCH - * - LINE_ERR_LINE_CODE_MISMATCH - * - LINE_ERR_FIRST_STEP_BAD_THIS - * - LINE_ERR_THIS_LINE_BAD_STEP - * - * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) - * Поддерживает таблицу connections_state как "текущее состояние" отношений: - * - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние - * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние - * - * [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) - * Поддерживает likes_count РІ message_stats для цели (to_*). - * - * [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY) - * Поддерживает replies_count РІ message_stats. - * - * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT) - * Логика edit: - * - помечает исходный блок edited_by_block_number = NEW.block_number - * - увеличивает edits_count РІ message_stats - */ -public final class DatabaseTriggersInstaller { - - private DatabaseTriggersInstaller() {} - - public static void createAllTriggers(Statement st) throws SQLException { - dropTriggersByPrefix(st, "trg_blocks_"); - - // РќР° РІСЃСЏРєРёР№ случай убираем старые "РєСЂРёРІРѕ названные" триггеры, - // если РѕРЅРё РєРѕРіРґР°-то попадали РІ БД. - st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); - - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;"); - - createLineIntegrityTrigger(st); - createConnectionStateTrigger(st); - createMessageStatsLikeTrigger(st); - createMessageStatsReplyTrigger(st); - createEditApplyTrigger(st); - } - - private static void dropTriggersByPrefix(Statement st, String prefix) throws SQLException { - List triggerNames = new ArrayList<>(); - String sql = "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE '" + prefix + "%'"; - try (ResultSet rs = st.executeQuery(sql)) { - while (rs.next()) { - String name = rs.getString("name"); - if (name != null && !name.isBlank()) { - triggerNames.add(name); - } - } - } - - for (String name : triggerNames) { - String safeName = name.replace("\"", "\"\""); - st.executeUpdate("DROP TRIGGER IF EXISTS \"" + safeName + "\""); - } - } - - private static void createLineIntegrityTrigger(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi - BEFORE INSERT ON blocks - WHEN - NEW.line_code IS NOT NULL - OR NEW.prev_line_number IS NOT NULL - OR NEW.prev_line_hash IS NOT NULL - OR NEW.this_line_number IS NOT NULL - BEGIN - SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE') - WHERE NOT (NEW.msg_type IN (0, 1, 3, 4)); - - SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS') - WHERE NEW.line_code IS NULL - OR NEW.prev_line_number IS NULL - OR NEW.prev_line_hash IS NULL - OR NEW.this_line_number IS NULL; - - SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.block_hash = NEW.prev_line_hash - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.line_code = NEW.line_code - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS') - WHERE NEW.prev_line_number = NEW.line_code - AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END); - - SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.this_line_number IS NOT NULL - AND ( - (NEW.msg_type = 1 AND - (NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1) - ) - OR - (NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1) - ) - LIMIT 1 - ); - END; - """); - } - - private static void createConnectionStateTrigger(Statement st) throws SQLException { - int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; - int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; - int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; - int SPOUSE = (int) DatabaseInitializer.CONNECTION_SPOUSE; - int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; - int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; - int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; - int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON; - int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED; - int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN; - - int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; - int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; - int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; - int UNSPOUSE = (int) DatabaseInitializer.CONNECTION_UNSPOUSE; - int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; - int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; - int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; - int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON; - int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED; - int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 3 - BEGIN - -- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*: - -- 1) если записи нет — создаём - INSERT OR IGNORE INTO connections_state ( - login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash - ) - SELECT - NEW.login, - NEW.msg_sub_type, - COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ), - NEW.to_bch_name, - NEW.to_block_number, - NEW.to_block_hash - WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- 2) если запись есть — обновляем актуальные to_* - UPDATE connections_state - SET - to_bch_name = NEW.to_bch_name, - to_block_number = NEW.to_block_number, - to_block_hash = NEW.to_block_hash - WHERE login = NEW.login - AND rel_type = NEW.msg_sub_type - AND to_login = COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) - AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*: - -- удаляем соответствующее "позитивное" состояние - DELETE FROM connections_state - WHERE login = NEW.login - AND to_login = COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) - AND rel_type = CASE NEW.msg_sub_type - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - ELSE rel_type - END - AND COALESCE( - NEW.to_login, - ( - SELECT su.login - FROM solana_users su - WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE - LIMIT 1 - ), - CASE - WHEN NEW.to_bch_name IS NOT NULL - AND length(NEW.to_bch_name) > 4 - AND substr(NEW.to_bch_name, length(NEW.to_bch_name) - 3, 1) = '-' - THEN substr(NEW.to_bch_name, 1, length(NEW.to_bch_name) - 4) - ELSE NULL - END - ) IS NOT NULL - AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d); - END; - """.formatted( - FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, - FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, - - UNFRIEND, FRIEND, - UNCONTACT, CONTACT, - UNFOLLOW, FOLLOW, - UNSPOUSE, SPOUSE, - UNPARENT, PARENT, - UNCHILD, CHILD, - UNSIBLING, SIBLING, - UNKNOWN, KNOWN, - SHINE_UNCONF, SHINE_CONF, - SHINE_UNSEEN, SHINE_SEEN, - - UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN - )); - } - - private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { - int LIKE = (int) DatabaseInitializer.REACTION_LIKE; - int UNLIKE = (int) DatabaseInitializer.REACTION_UNLIKE; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 2 AND NEW.msg_sub_type IN (%d, %d) - BEGIN - -- ensure target stats row exists - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- apply delta by state transition (none/unlike->like = +1, like->unlike = -1) - UPDATE message_stats - SET likes_count = MAX( - 0, - likes_count + ( - CASE - WHEN NEW.msg_sub_type = %d - AND COALESCE(( - SELECT b.msg_sub_type - FROM blocks b - WHERE b.login = NEW.login - AND b.bch_name = NEW.bch_name - AND b.msg_type = 2 - AND b.to_login = NEW.to_login - AND b.to_bch_name = NEW.to_bch_name - AND b.to_block_number = NEW.to_block_number - AND b.to_block_hash = NEW.to_block_hash - AND b.block_number < NEW.block_number - ORDER BY b.block_number DESC - LIMIT 1 - ), -1) <> %d - THEN 1 - WHEN NEW.msg_sub_type = %d - AND COALESCE(( - SELECT b.msg_sub_type - FROM blocks b - WHERE b.login = NEW.login - AND b.bch_name = NEW.bch_name - AND b.msg_type = 2 - AND b.to_login = NEW.to_login - AND b.to_bch_name = NEW.to_bch_name - AND b.to_block_number = NEW.to_block_number - AND b.to_block_hash = NEW.to_block_hash - AND b.block_number < NEW.block_number - ORDER BY b.block_number DESC - LIMIT 1 - ), -1) = %d - THEN -1 - ELSE 0 - END - ) - ) - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- persist latest actor->target reaction state - INSERT OR IGNORE INTO reactions_state ( - from_login, from_bch_name, reaction_type, - to_login, to_bch_name, to_block_number, to_block_hash, - last_sub_type - ) - SELECT - NEW.login, NEW.bch_name, %d, - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - NEW.msg_sub_type - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - UPDATE reactions_state - SET last_sub_type = NEW.msg_sub_type - WHERE from_login = NEW.login - AND from_bch_name = NEW.bch_name - AND reaction_type = %d - AND to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted( - LIKE, UNLIKE, - LIKE, LIKE, - UNLIKE, LIKE, - LIKE, - LIKE - )); - } - - private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { - int REPLY = (int) DatabaseInitializer.TEXT_REPLY; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d - BEGIN - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - UPDATE message_stats - SET replies_count = replies_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(REPLY)); - } - - private static void createEditApplyTrigger(Statement st) throws SQLException { - int EDIT_POST = (int) DatabaseInitializer.TEXT_EDIT_POST; - int EDIT_REPLY = (int) DatabaseInitializer.TEXT_EDIT_REPLY; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type IN (%d, %d) - BEGIN - -- 1) помечаем исходный блок, что его "перекрыл" этот edit - UPDATE blocks - SET edited_by_block_number = NEW.block_number - WHERE login = NEW.login - AND bch_name = NEW.bch_name - AND block_number = NEW.to_block_number - AND NEW.to_block_number IS NOT NULL; - - -- 2) создаём stats-строку если её РЅРµ было - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- 3) +1 edit - UPDATE message_stats - SET edits_count = edits_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(EDIT_POST, EDIT_REPLY)); - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java new file mode 100644 index 00000000..72dec60c --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DbController.java @@ -0,0 +1,43 @@ +package shine.db; + +import shine.db.connection.DbProvider; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Нейтральная точка входа в runtime БД сервера. + * + * Runtime-сервер теперь поддерживает только PostgreSQL. + */ +public final class DbController implements DbProvider { + + private static volatile DbController instance; + + private final PostgresDbController delegate; + + private DbController() { + this.delegate = PostgresDbController.getInstance(); + } + + public static DbController getInstance() { + if (instance == null) { + synchronized (DbController.class) { + if (instance == null) { + instance = new DbController(); + } + } + } + return instance; + } + + @Override + public Connection getConnection() throws SQLException { + return delegate.getConnection(); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java new file mode 100644 index 00000000..867bd394 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java @@ -0,0 +1,115 @@ +package shine.db; + +import java.util.Base64; + +/** + * Нормализация ключей из разных storage-слоёв к каноническому Base64(32). + */ +public final class KeyEncodingUtil { + + private static final String BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + private static final int[] BASE58_INDEXES = new int[128]; + + static { + for (int i = 0; i < BASE58_INDEXES.length; i++) { + BASE58_INDEXES[i] = -1; + } + for (int i = 0; i < BASE58_ALPHABET.length(); i++) { + BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i; + } + } + + private KeyEncodingUtil() {} + + public static String normalizeKeyToBase64_32(String rawKey) { + if (rawKey == null) { + return null; + } + + String value = rawKey.trim(); + if (value.isEmpty()) { + return value; + } + + byte[] asBase64 = tryDecodeBase64_32(value); + if (asBase64 != null) { + return Base64.getEncoder().encodeToString(asBase64); + } + + byte[] asBase58 = tryDecodeBase58_32(value); + if (asBase58 != null) { + return Base64.getEncoder().encodeToString(asBase58); + } + + return value; + } + + private static byte[] tryDecodeBase64_32(String value) { + try { + byte[] decoded = Base64.getDecoder().decode(value); + return decoded.length == 32 ? decoded : null; + } catch (IllegalArgumentException ignore) { + return null; + } + } + + private static byte[] tryDecodeBase58_32(String value) { + byte[] decoded = decodeBase58(value); + return decoded.length == 32 ? decoded : null; + } + + private static byte[] decodeBase58(String input) { + if (input.isEmpty()) { + return new byte[0]; + } + + byte[] input58 = new byte[input.length()]; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c >= BASE58_INDEXES.length || BASE58_INDEXES[c] < 0) { + return new byte[0]; + } + input58[i] = (byte) BASE58_INDEXES[c]; + } + + int zeros = 0; + while (zeros < input58.length && input58[zeros] == 0) { + zeros++; + } + + byte[] decoded = new byte[input.length()]; + int outputStart = decoded.length; + int inputStart = zeros; + while (inputStart < input58.length) { + int mod = divmod256(input58, inputStart); + if (input58[inputStart] == 0) { + inputStart++; + } + decoded[--outputStart] = (byte) mod; + } + + while (outputStart < decoded.length && decoded[outputStart] == 0) { + outputStart++; + } + + byte[] result = new byte[decoded.length - outputStart + zeros]; + for (int i = 0; i < zeros; i++) { + result[i] = 0; + } + System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart); + return result; + } + + private static int divmod256(byte[] number58, int startAt) { + int remainder = 0; + for (int i = startAt; i < number58.length; i++) { + int digit58 = number58[i] & 0xFF; + int temp = remainder * 58 + digit58; + number58[i] = (byte) (temp / 256); + remainder = temp % 256; + } + return remainder; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 07c727cf..6952cd20 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -33,6 +33,9 @@ public final class MsgSubType { /** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */ public static final short TEXT_REPOST = 30; + /** CHANNEL_META — скрытый технический снимок профиля канала. */ + public static final short TEXT_CHANNEL_META = 70; + /* ===================== REACTION (msg_type=2) ===================== */ /** Лайк (LIKE). */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java new file mode 100644 index 00000000..a3b77fcc --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/PostgresDbController.java @@ -0,0 +1,63 @@ +package shine.db; + +import shine.db.connection.DriverManagerDbProvider; +import utils.config.AppConfig; + +import java.sql.Connection; +import java.sql.SQLException; + +public final class PostgresDbController { + + private static volatile PostgresDbController instance; + + private final DriverManagerDbProvider delegate; + + private PostgresDbController() { + AppConfig config = AppConfig.getInstance(); + String jdbcUrl = trimToNull(config.getParam("db.url")); + String dbUser = trimToNull(config.getParam("db.user")); + String dbPassword = trimToNull(config.getParam("db.password")); + + if (jdbcUrl == null) { + throw new IllegalStateException("Config param 'db.url' is required and must point to PostgreSQL"); + } + if (!jdbcUrl.startsWith("jdbc:postgresql:")) { + throw new IllegalStateException("Only PostgreSQL runtime is supported. Unsupported db.url=" + jdbcUrl); + } + + try { + DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword); + } catch (SQLException e) { + throw new RuntimeException("PostgreSQL schema auto-init failed", e); + } + + this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> { + connection.setAutoCommit(true); + }); + } + + public static PostgresDbController getInstance() { + if (instance == null) { + synchronized (PostgresDbController.class) { + if (instance == null) { + instance = new PostgresDbController(); + } + } + } + return instance; + } + + public Connection getConnection() throws SQLException { + return delegate.getConnection(); + } + + public void close() { + // no-op + } + + private static String trimToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} 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 deleted file mode 100644 index 6f39a7ba..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ /dev/null @@ -1,798 +0,0 @@ -package shine.db; - -import utils.config.AppConfig; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; - -public final class SqliteDbController { - - private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 11; - - private final String jdbcUrl; - - private SqliteDbController() { - try { - Class.forName("org.sqlite.JDBC"); - } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); - } - - String dbPath = AppConfig.getInstance().getParam("db.path"); - if (dbPath == null || dbPath.isBlank()) { - throw new RuntimeException("Config param 'db.path' is not set in application.properties"); - } - - Path dbFile = Paths.get(dbPath); - - if (!Files.exists(dbFile)) { - System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath()); - System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer..."); - DatabaseInitializer.createNewDB(new String[0]); - } - - this.jdbcUrl = "jdbc:sqlite:" + dbPath; - ensureSchemaMigrations(); - } - - public static SqliteDbController getInstance() { - if (instance == null) { - synchronized (SqliteDbController.class) { - if (instance == null) { - instance = new SqliteDbController(); - } - } - } - return instance; - } - - public Connection getConnection() throws SQLException { - Connection conn = DriverManager.getConnection(jdbcUrl); - conn.setAutoCommit(true); - - try (Statement st = conn.createStatement()) { - st.execute("PRAGMA foreign_keys = ON"); - st.execute("PRAGMA journal_mode = WAL"); - st.execute("PRAGMA synchronous = NORMAL"); - st.execute("PRAGMA busy_timeout = 5000"); - } - - return conn; - } - - public void close() { - // no-op - } - - private void ensureSchemaMigrations() { - int currentVersion = getCurrentSchemaVersion(); - - while (currentVersion < LATEST_SCHEMA_VERSION) { - int nextVersion = currentVersion + 1; - applyMigration(nextVersion); - currentVersion = nextVersion; - } - } - - private void applyMigration(int targetVersion) { - switch (targetVersion) { - case 1 -> migrateToV1(); - case 2 -> migrateToV2(); - case 3 -> migrateToV3(); - case 4 -> migrateToV4(); - case 5 -> migrateToV5(); - case 6 -> migrateToV6(); - case 7 -> migrateToV7(); - case 8 -> migrateToV8(); - case 9 -> migrateToV9(); - case 10 -> migrateToV10(); - case 11 -> migrateToV11(); - default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); - } - } - - private void migrateToV1() { - try { - DatabaseInitializer.ensureSchemaV1Structure(jdbcUrl); - } catch (SQLException e) { - throw new RuntimeException("DB migration to v1 failed (base schema)", e); - } - - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - st.execute("PRAGMA foreign_keys = OFF"); - - if (tableExists(c, "connections_state") && needsConnectionsStateUpgrade(c)) { - rebuildConnectionsStateTable(st); - } - - ensureChannelNamesDescriptionColumn(c, st); - ensureChannelNamesTypeColumns(c, st); - ensureSignedMessageReceiptUniq(c, st); - DatabaseTriggersInstaller.createAllTriggers(st); - setSchemaVersion(c, 1); - - st.execute("PRAGMA foreign_keys = ON"); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v1 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v1 failed", e); - } - } - - private void migrateToV2() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - st.execute("PRAGMA foreign_keys = OFF"); - st.executeUpdate("DROP TABLE IF EXISTS message_views_state"); - st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target"); - st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel"); - setSchemaVersion(c, 2); - st.execute("PRAGMA foreign_keys = ON"); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v2 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v2 failed", e); - } - } - - private void migrateToV3() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureChat200StateTables(st); - setSchemaVersion(c, 3); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v3 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v3 failed", e); - } - } - - private void migrateToV4() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureActiveSessionsSessionTypeColumn(c, st); - ensureActiveSessionsClientPlatformColumn(c, st); - setSchemaVersion(c, 4); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v4 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v4 failed", e); - } - } - - private void migrateToV5() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureEspPairingTables(st); - setSchemaVersion(c, 5); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v5 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v5 failed", e); - } - } - - private void migrateToV6() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSignedMessagesRevisionColumn(c, st); - setSchemaVersion(c, 6); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v6 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v6 failed", e); - } - } - - private void migrateToV7() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - dropDmFileTables(st); - setSchemaVersion(c, 7); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v7 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v7 failed", e); - } - } - - private void migrateToV8() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureTestFreeAvatarUploadsTable(st); - setSchemaVersion(c, 8); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v8 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v8 failed", e); - } - } - - private void migrateToV9() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSyncServersTable(st); - setSchemaVersion(c, 9); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v9 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v9 failed", e); - } - } - - private void migrateToV10() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - ensureSignedMessagesReencryptedColumn(c, st); - setSchemaVersion(c, 10); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v10 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v10 failed", e); - } - } - - private void migrateToV11() { - try (Connection c = DriverManager.getConnection(jdbcUrl); - Statement st = c.createStatement()) { - c.setAutoCommit(false); - try { - clearLegacySignedMessagesForDmV11(c, st); - setSchemaVersion(c, 11); - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - throw new RuntimeException("DB migration to v11 failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } catch (SQLException e) { - throw new RuntimeException("DB migration to v11 failed", e); - } - } - - private static void ensureChat200StateTables(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_state ( - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - channel_name TEXT NOT NULL, - channel_type_version INTEGER NOT NULL, - chat_title TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL, - PRIMARY KEY (owner_bch_name, channel_root_block_number) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS chat200_members_state ( - owner_bch_name TEXT NOT NULL, - channel_root_block_number INTEGER NOT NULL, - member_login TEXT NOT NULL, - member_channel_name TEXT NOT NULL, - is_active INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - updated_by_block_number INTEGER NOT NULL, - PRIMARY KEY ( - owner_bch_name, - channel_root_block_number, - member_login, - member_channel_name - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_state_owner - ON chat200_state (owner_login, owner_bch_name); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_chat200_members_owner - ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); - """); - } - - private int getCurrentSchemaVersion() { - try (Connection c = DriverManager.getConnection(jdbcUrl)) { - if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) { - return 0; - } - - try (var ps = c.prepareStatement(""" - SELECT schema_version - FROM db_schema_version - WHERE id = 1 - LIMIT 1 - """); - ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - return rs.getInt(1); - } - } - return 0; - } catch (SQLException e) { - throw new RuntimeException("Cannot read DB schema version", e); - } - } - - private static void ensureActiveSessionsSessionTypeColumn(Connection c, Statement st) throws SQLException { - if (columnExists(c, "active_sessions", "session_type")) return; - st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN session_type INTEGER NOT NULL DEFAULT 1"); - } - - private static void ensureActiveSessionsClientPlatformColumn(Connection c, Statement st) throws SQLException { - if (columnExists(c, "active_sessions", "client_platform")) return; - st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); - } - - private static void ensureEspPairingTables(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_settings ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - enabled INTEGER NOT NULL DEFAULT 0, - password_hash TEXT NOT NULL DEFAULT '', - ttl_seconds INTEGER NOT NULL DEFAULT 300, - failed_attempts INTEGER NOT NULL DEFAULT 0, - first_failed_at_ms INTEGER NOT NULL DEFAULT 0, - blocked_until_ms INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS esp_pairing_requests ( - pairing_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - requester_session_key TEXT NOT NULL, - requester_session_type INTEGER NOT NULL DEFAULT 1, - requester_client_platform TEXT NOT NULL DEFAULT '', - payload_type INTEGER NOT NULL, - status TEXT NOT NULL, - short_code TEXT NOT NULL, - fingerprint_b58 TEXT NOT NULL, - encrypted_payload TEXT, - reject_reason TEXT, - approved_by_session_id TEXT, - created_at_ms INTEGER NOT NULL, - expires_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status - ON esp_pairing_requests (login, status, expires_at_ms); - """); - } - - private static void ensureSignedMessagesRevisionColumn(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - if (!columnExists(c, "signed_messages_v2", "revision_time_ms")) { - st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN revision_time_ms INTEGER NOT NULL DEFAULT 0"); - } - } - - private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) { - st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0"); - } - } - - /** - * Временная одноразовая миграция на переходе к SHiNE_DM v1: - * старые строки signed_messages_v2 больше не гарантированно совместимы - * с новым бинарным форматом и правилами tombstone/reencrypt. - * Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM. - */ - private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException { - if (tableExists(c, "signed_message_session_delivery")) { - st.executeUpdate("DELETE FROM signed_message_session_delivery"); - } - if (tableExists(c, "signed_messages_v2")) { - st.executeUpdate("DELETE FROM signed_messages_v2"); - } - } - - private static void dropDmFileTables(Statement st) throws SQLException { - st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); - st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); - st.executeUpdate("DROP TABLE IF EXISTS dm_message_file_links"); - st.executeUpdate("DROP TABLE IF EXISTS dm_files"); - } - - private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { - while (rs.next()) { - if (columnName.equalsIgnoreCase(rs.getString("name"))) { - return true; - } - } - return false; - } - } - - private static void setSchemaVersion(Connection c, int version) throws SQLException { - try (var ps = c.prepareStatement(""" - INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, ?, CAST(strftime('%s','now') AS INTEGER) * 1000) - ON CONFLICT(id) DO UPDATE SET - schema_version = excluded.schema_version, - updated_at_ms = excluded.updated_at_ms - """)) { - ps.setInt(1, version); - ps.executeUpdate(); - } - } - - private static void ensureReactionsStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS reactions_state ( - from_login TEXT NOT NULL, - from_bch_name TEXT NOT NULL, - reaction_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - last_sub_type INTEGER NOT NULL, - UNIQUE ( - from_login, - from_bch_name, - reaction_type, - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - } - - private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - used_count INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - last_tx_id TEXT NOT NULL DEFAULT '', - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated - ON test_free_avatar_uploads (updated_at_ms); - """); - } - - private static void ensureSyncServersTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS sync_servers ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - server_address TEXT NOT NULL DEFAULT '', - updated_at_ms INTEGER NOT NULL - ); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_sync_servers_updated - ON sync_servers (updated_at_ms); - """); - } - - - private static void createConnectionsStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - } - - private static void ensureConnectionsIndexes(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_login - ON connections_state (login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_to_login - ON connections_state (to_login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_pair - ON connections_state (login, to_login); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_target - ON connections_state (login, rel_type, to_bch_name, to_block_number); - """); - } - - private static void ensureReactionsIndexes(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_target - ON reactions_state (to_bch_name, to_block_number, to_block_hash); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_reactions_state_actor - ON reactions_state (from_login, from_bch_name, reaction_type); - """); - } - - private static void ensureChannelNamesStateTable(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL, - display_name TEXT NOT NULL, - channel_description TEXT NOT NULL DEFAULT '', - owner_login TEXT NOT NULL, - owner_bch_name TEXT NOT NULL, - channel_type_code INTEGER NOT NULL DEFAULT 1, - channel_type_version INTEGER NOT NULL DEFAULT 1, - channel_root_block_number INTEGER NOT NULL, - channel_root_block_hash BLOB NOT NULL, - created_at_ms INTEGER NOT NULL - ); - """); - } - - private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException { - boolean hasDescription = false; - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - if ("channel_description".equalsIgnoreCase(name)) { - hasDescription = true; - break; - } - } - } - - if (!hasDescription) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''"); - } - } - - private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException { - boolean hasTypeCode = false; - boolean hasTypeVersion = false; - - try (Statement probe = c.createStatement(); - ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true; - if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true; - } - } - - if (!hasTypeCode) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1"); - } - if (!hasTypeVersion) { - st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1"); - } - } - - private static void ensureChannelNamesIndexes(Statement st) throws SQLException { - st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug"); - st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug"); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug - ON channel_names_state (owner_bch_name, channel_type_code, slug); - """); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target - ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); - """); - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner - ON channel_names_state (owner_login, owner_bch_name); - """); - } - - private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException { - if (!tableExists(c, "signed_messages_v2")) return; - - if (tableExists(c, "signed_message_session_delivery")) { - st.executeUpdate(""" - DELETE FROM signed_message_session_delivery - WHERE message_key IN ( - SELECT message_key - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - AND rowid NOT IN ( - SELECT MIN(rowid) - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - GROUP BY target_login, message_type, receipt_ref_base_key - ) - ); - """); - } - - st.executeUpdate(""" - DELETE FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - AND rowid NOT IN ( - SELECT MIN(rowid) - FROM signed_messages_v2 - WHERE message_type IN (3, 4) - AND receipt_ref_base_key IS NOT NULL - GROUP BY target_login, message_type, receipt_ref_base_key - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; - """); - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing - ON signed_messages_v2 (target_login, receipt_ref_base_key) - WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; - """); - } - - private static void rebuildConnectionsStateTable(Statement st) throws SQLException { - st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2"); - st.executeUpdate(""" - CREATE TABLE connections_state_v2 ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - ); - """); - - st.executeUpdate(""" - INSERT OR IGNORE INTO connections_state_v2 - (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) - SELECT - login, - rel_type, - to_login, - to_bch_name, - COALESCE(to_block_number, 0), - COALESCE(to_block_hash, zeroblob(32)) - FROM connections_state - WHERE login IS NOT NULL - AND to_login IS NOT NULL - AND to_bch_name IS NOT NULL; - """); - - st.executeUpdate("DROP TABLE connections_state"); - st.executeUpdate("ALTER TABLE connections_state_v2 RENAME TO connections_state"); - } - - private static boolean tableExists(Connection c, String tableName) throws SQLException { - String sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1"; - try (var ps = c.prepareStatement(sql)) { - ps.setString(1, tableName); - try (ResultSet rs = ps.executeQuery()) { - return rs.next(); - } - } - } - - private static boolean needsConnectionsStateUpgrade(Connection c) throws SQLException { - boolean toBlockNumberNotNull = false; - boolean toBlockHashNotNull = false; - - try (Statement st = c.createStatement(); - ResultSet rs = st.executeQuery("PRAGMA table_info(connections_state)")) { - while (rs.next()) { - String name = rs.getString("name"); - int notNull = rs.getInt("notnull"); - if ("to_block_number".equalsIgnoreCase(name)) { - toBlockNumberNotNull = notNull == 1; - } - if ("to_block_hash".equalsIgnoreCase(name)) { - toBlockHashNotNull = notNull == 1; - } - } - } - - return !toBlockNumberNotNull || !toBlockHashNotNull; - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java index ee5aa2e9..84bc7807 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java @@ -8,6 +8,8 @@ public final class ChannelNameRules { private static final int MAX_DISPLAY_NAME_LENGTH = 32; private static final Pattern DISPLAY_ALLOWED_PATTERN = Pattern.compile("^[A-Za-z0-9_-]+$"); + private static final Pattern PUBLIC_CHANNEL_ALLOWED_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]+$"); private ChannelNameRules() {} @@ -34,6 +36,14 @@ public final class ChannelNameRules { return normalized; } + public static String requireValidPublicDisplayNameForCreate(String rawName) { + String normalized = requireValidDisplayNameForCreate(rawName); + if (!PUBLIC_CHANNEL_ALLOWED_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("channelName contains unsupported characters"); + } + return normalized; + } + public static String toCanonicalSlug(String rawName) { String normalized = normalizeDisplayName(rawName); if (normalized.isEmpty()) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java new file mode 100644 index 00000000..17a05cca --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/ConnectionFactory.java @@ -0,0 +1,9 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface ConnectionFactory { + Connection getConnection() throws SQLException; +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java new file mode 100644 index 00000000..c8a0f3e1 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DbProvider.java @@ -0,0 +1,21 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Нейтральная точка доступа к JDBC-соединениям без привязки к конкретной БД. + * + * На первом этапе переносов этот интерфейс нужен как новая опора для DAO, + * чтобы постепенно убрать прямую зависимость от DbController. + */ +public interface DbProvider extends ConnectionFactory, AutoCloseable { + + @Override + Connection getConnection() throws SQLException; + + @Override + default void close() throws Exception { + // По умолчанию ресурсов на уровне provider нет. + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java new file mode 100644 index 00000000..ec90a154 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/connection/DriverManagerDbProvider.java @@ -0,0 +1,58 @@ +package shine.db.connection; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * Базовый JDBC provider поверх DriverManager. + * + * Не знает ничего про конкретный доменный режим БД: + * конкретные параметры и post-connect инициализация задаются снаружи. + */ +public final class DriverManagerDbProvider implements DbProvider { + + private final String jdbcUrl; + private final String user; + private final String password; + private final ConnectionInitializer initializer; + + public DriverManagerDbProvider(String jdbcUrl, + String user, + String password, + ConnectionInitializer initializer) { + if (jdbcUrl == null || jdbcUrl.isBlank()) { + throw new IllegalArgumentException("jdbcUrl is blank"); + } + this.jdbcUrl = jdbcUrl; + this.user = blankToNull(user); + this.password = blankToNull(password); + this.initializer = initializer; + } + + @Override + public Connection getConnection() throws SQLException { + Connection connection; + if (user == null) { + connection = DriverManager.getConnection(jdbcUrl); + } else { + connection = DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password); + } + + if (initializer != null) { + initializer.initialize(connection); + } + return connection; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @FunctionalInterface + public interface ConnectionInitializer { + void initialize(Connection connection) throws SQLException; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java index 885dc8e5..cb2e0e88 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ActiveSessionsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.ActiveSessionEntry; import java.sql.*; @@ -17,7 +17,7 @@ import java.util.List; public final class ActiveSessionsDAO { private static volatile ActiveSessionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ActiveSessionsDAO() { } @@ -137,7 +137,7 @@ public final class ActiveSessionsDAO { client_platform, user_language FROM active_sessions - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """; List result = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 9fe04aee..e11a5fff 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -1,7 +1,7 @@ package shine.db.dao; import shine.db.DatabaseInitializer; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -24,12 +24,12 @@ import java.sql.SQLException; * - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам; * - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии; * - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию: - * SQLite не может атомарно закоммитить и SQL, и файловую систему сразу; + * SQL-транзакция и файловая система не коммитятся атомарно вместе; * поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом * отдельным recovery/resync-слоем после успешного commit. * * Важный смысл текущей реализации: - * - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы; + * - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы; * - мы очищаем только блокчейн пользователя и derived-state, который строится из неё; * - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы. */ @@ -39,7 +39,7 @@ public final class BlockchainResyncCleanupDAO { private static volatile BlockchainResyncCleanupDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private BlockchainResyncCleanupDAO() {} @@ -307,7 +307,7 @@ public final class BlockchainResyncCleanupDAO { private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException { return executeDelete(c, """ DELETE FROM connections_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """, login); } @@ -317,7 +317,7 @@ public final class BlockchainResyncCleanupDAO { private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException { return executeDelete(c, """ DELETE FROM users_params - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) """, login); } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java index 4087e5a7..e2ad176a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.BlockchainStateEntry; import java.sql.*; @@ -10,7 +10,7 @@ import java.util.List; public final class BlockchainStateDAO { private static volatile BlockchainStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private BlockchainStateDAO() {} @@ -75,7 +75,7 @@ public final class BlockchainStateDAO { last_block_hash, updated_at_ms FROM blockchain_state - ORDER BY blockchain_name COLLATE NOCASE + ORDER BY LOWER(blockchain_name) """; List result = new ArrayList<>(); @@ -142,7 +142,7 @@ public final class BlockchainStateDAO { * Строгая вставка state только если записи ещё нет. * * Нужна для recovery / resync: - * - identity пользователя уже может существовать в solana_users; + * - runtime-проекция пользователя уже может существовать в current users слое; * - в таком случае нам надо восстановить только blockchain_state; * - если запись уже есть, метод просто ничего не меняет. */ @@ -229,7 +229,7 @@ public final class BlockchainStateDAO { private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException { if (b != null) ps.setBytes(index, b); - else ps.setNull(index, Types.BLOB); + else ps.setNull(index, Types.BINARY); } private static String nn(String s) { return s == null ? "" : s; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 6291e4cc..2719c31c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -2,7 +2,7 @@ package shine.db.dao; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.BlockEntry; import java.sql.*; @@ -22,7 +22,7 @@ import java.util.List; public final class BlocksDAO { private static volatile BlocksDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); private BlocksDAO() { } @@ -90,7 +90,7 @@ public final class BlocksDAO { else ps.setNull(i++, Types.INTEGER); if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); - else ps.setNull(i++, Types.BLOB); + else ps.setNull(i++, Types.BINARY); ps.setBytes(i++, e.getBlockHash()); ps.setBytes(i++, e.getBlockSignature()); @@ -106,7 +106,7 @@ public final class BlocksDAO { else ps.setNull(i++, Types.INTEGER); if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); - else ps.setNull(i++, Types.BLOB); + else ps.setNull(i++, Types.BINARY); if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); else ps.setNull(i++, Types.INTEGER); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index a0b07278..762a4292 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.ChannelNameStateEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.util.List; public final class ChannelNameStateDAO { private static volatile ChannelNameStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ChannelNameStateDAO() {} @@ -65,8 +65,12 @@ public final class ChannelNameStateDAO { channel_type_version, channel_root_block_number, channel_root_block_hash, - created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at_ms, + ava_ar, + ava_sha256, + ava_size, + meta_updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, entry.getSlug()); @@ -79,6 +83,34 @@ public final class ChannelNameStateDAO { ps.setInt(8, entry.getChannelRootBlockNumber()); ps.setBytes(9, entry.getChannelRootBlockHash()); ps.setLong(10, entry.getCreatedAtMs()); + ps.setString(11, entry.getAvaAr() == null ? "" : entry.getAvaAr()); + ps.setString(12, entry.getAvaSha256() == null ? "" : entry.getAvaSha256()); + ps.setLong(13, Math.max(0L, entry.getAvaSize())); + ps.setLong(14, Math.max(0L, entry.getMetaUpdatedAtMs())); + ps.executeUpdate(); + } + } + + public void updateMeta(Connection c, ChannelNameStateEntry entry) throws SQLException { + String sql = """ + UPDATE channel_names_state + SET display_name = ?, + channel_description = ?, + ava_ar = ?, + ava_sha256 = ?, + ava_size = ?, + meta_updated_at_ms = ? + WHERE owner_bch_name = ? AND channel_root_block_number = ? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getDisplayName() == null ? "" : entry.getDisplayName()); + ps.setString(2, entry.getChannelDescription() == null ? "" : entry.getChannelDescription()); + ps.setString(3, entry.getAvaAr() == null ? "" : entry.getAvaAr()); + ps.setString(4, entry.getAvaSha256() == null ? "" : entry.getAvaSha256()); + ps.setLong(5, Math.max(0L, entry.getAvaSize())); + ps.setLong(6, Math.max(0L, entry.getMetaUpdatedAtMs())); + ps.setString(7, entry.getOwnerBlockchainName()); + ps.setInt(8, entry.getChannelRootBlockNumber()); ps.executeUpdate(); } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java index 3de5aa9b..e01fdf52 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java @@ -1,6 +1,7 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -13,16 +14,14 @@ import java.util.List; * ConnectionsStateDAO — чтение текущего состояния связей из connections_state. * * ВАЖНО: - * - login в запросах может быть в любом регистре, поэтому в WHERE используем COLLATE NOCASE - * - в ответах возвращаем логины в каноническом регистре через JOIN на solana_users - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй в SQL. + * - login в запросах может быть в любом регистре; + * - в ответах возвращаем логины в каноническом регистре через прямой lookup + * по актуальным PDA-данным и manual fallback. */ public final class ConnectionsStateDAO { private static volatile ConnectionsStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private ConnectionsStateDAO() {} @@ -42,14 +41,17 @@ public final class ConnectionsStateDAO { String sql = """ SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login FROM connections_state cs - LEFT JOIN solana_users u_login - ON u_login.login = cs.to_login COLLATE NOCASE - LEFT JOIN solana_users u_bch - ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE - WHERE cs.login = ? COLLATE NOCASE + LEFT JOIN %s + ON LOWER(u_login.login) = LOWER(cs.to_login) + LEFT JOIN %s + ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name) + WHERE LOWER(cs.login) = LOWER(?) AND cs.rel_type = ? ORDER BY friend_login - """; + """.formatted( + CurrentUsersSql.usersSubquery("u_login"), + CurrentUsersSql.usersSubquery("u_bch") + ); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -72,17 +74,20 @@ public final class ConnectionsStateDAO { String sql = """ SELECT COALESCE(u_actor.login, cs.login) AS friend_login FROM connections_state cs - LEFT JOIN solana_users u_actor - ON u_actor.login = cs.login COLLATE NOCASE - LEFT JOIN solana_users u_target - ON u_target.login = ? COLLATE NOCASE + LEFT JOIN %s + ON LOWER(u_actor.login) = LOWER(cs.login) + LEFT JOIN %s + ON LOWER(u_target.login) = LOWER(?) WHERE ( - cs.to_login = ? COLLATE NOCASE - OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE) + LOWER(cs.to_login) = LOWER(?) + OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name)) ) AND cs.rel_type = ? ORDER BY friend_login - """; + """.formatted( + CurrentUsersSql.usersSubquery("u_actor"), + CurrentUsersSql.usersSubquery("u_target") + ); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -106,19 +111,19 @@ public final class ConnectionsStateDAO { String sql = """ SELECT u.login AS friend_login FROM connections_state a - JOIN solana_users u - ON u.login = a.to_login COLLATE NOCASE - WHERE a.login = ? COLLATE NOCASE + JOIN %s + ON LOWER(u.login) = LOWER(a.to_login) + WHERE LOWER(a.login) = LOWER(?) AND a.rel_type = ? AND EXISTS ( SELECT 1 FROM connections_state b - WHERE b.login = a.to_login COLLATE NOCASE - AND b.to_login = a.login COLLATE NOCASE + WHERE LOWER(b.login) = LOWER(a.to_login) + AND LOWER(b.to_login) = LOWER(a.login) AND b.rel_type = a.rel_type ) ORDER BY u.login - """; + """.formatted(CurrentUsersSql.usersSubquery("u")); List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -141,10 +146,22 @@ public final class ConnectionsStateDAO { String toBchName, Integer toBlockNumber, byte[] toBlockHash) throws SQLException { + try (PreparedStatement deletePs = c.prepareStatement(""" + DELETE FROM connections_state + WHERE LOWER(login) = LOWER(?) + AND rel_type = ? + AND LOWER(to_login) = LOWER(?) + """)) { + deletePs.setString(1, login); + deletePs.setInt(2, relType); + deletePs.setString(3, toLogin); + deletePs.executeUpdate(); + } + String sql = """ INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(login, rel_type, to_login) DO UPDATE SET + ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET to_bch_name=excluded.to_bch_name, to_block_number=excluded.to_block_number, to_block_hash=excluded.to_block_hash diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java similarity index 63% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java index c9db7db7..ec61b084 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/CurrentUsersDAO.java @@ -1,19 +1,22 @@ package shine.db.dao; -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.CurrentUserEntry; +import shine.db.sql.CurrentUsersSql; import java.sql.*; import java.util.ArrayList; import java.util.List; /** - * SolanaUsersDAO — локальная таблица пользователей из Solana. + * CurrentUsersDAO — совместимый runtime-доступ к текущему срезу пользователей. * - * Таблица: solana_users + * Источник: + * - solana_user_pda_current * * Колонки: - * - login TEXT PRIMARY KEY (COLLATE NOCASE) + * - login TEXT PRIMARY KEY * - blockchain_name TEXT NOT NULL * - solana_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL @@ -23,59 +26,32 @@ import java.util.List; * - методы с Connection НЕ закрывают соединение * - методы без Connection сами открывают и закрывают соединение */ -public final class SolanaUsersDAO { +public final class CurrentUsersDAO { - private static volatile SolanaUsersDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private static volatile CurrentUsersDAO instance; + private final DbController db = DbController.getInstance(); - private SolanaUsersDAO() {} + private CurrentUsersDAO() {} - public static SolanaUsersDAO getInstance() { + public static CurrentUsersDAO getInstance() { if (instance == null) { - synchronized (SolanaUsersDAO.class) { - if (instance == null) instance = new SolanaUsersDAO(); + synchronized (CurrentUsersDAO.class) { + if (instance == null) instance = new CurrentUsersDAO(); } } return instance; } - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ - public void insert(Connection c, SolanaUserEntry user) throws SQLException { - String sql = """ - INSERT INTO solana_users ( - login, blockchain_name, solana_key, blockchain_key, client_key - ) VALUES (?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, user.getLogin()); - ps.setString(2, user.getBlockchainName()); - ps.setString(3, user.getSolanaKey()); - ps.setString(4, user.getBlockchainKey()); - ps.setString(5, user.getClientKey()); - ps.executeUpdate(); - } - } - - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(SolanaUserEntry user) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, user); - } - } - // -------------------- EXISTS -------------------- /** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ public boolean existsByLogin(Connection c, String login) throws SQLException { String sql = """ SELECT 1 - FROM solana_users + FROM %s WHERE LOWER(login) = LOWER(?) LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); @@ -96,10 +72,10 @@ public final class SolanaUsersDAO { public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT 1 - FROM solana_users + FROM %s WHERE blockchain_name = ? LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, blockchainName); @@ -119,7 +95,7 @@ public final class SolanaUsersDAO { // -------------------- SELECT -------------------- /** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException { + public CurrentUserEntry getByLogin(Connection c, String login) throws SQLException { String sql = """ SELECT login, @@ -127,9 +103,9 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE LOWER(login) = LOWER(?) - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); @@ -141,14 +117,14 @@ public final class SolanaUsersDAO { } /** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ - public SolanaUserEntry getByLogin(String login) throws SQLException { + public CurrentUserEntry getByLogin(String login) throws SQLException { try (Connection c = db.getConnection()) { return getByLogin(c, login); } } /** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + public CurrentUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { String sql = """ SELECT login, @@ -156,9 +132,9 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE blockchain_name = ? - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, blockchainName); @@ -170,14 +146,19 @@ public final class SolanaUsersDAO { } /** Получить по blockchain_name без внешнего соединения. */ - public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException { + public CurrentUserEntry getByBlockchainName(String blockchainName) throws SQLException { try (Connection c = db.getConnection()) { return getByBlockchainName(c, blockchainName); } } /** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */ - public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { + public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { + return searchByLoginPrefix(c, prefix, null); + } + + /** Поиск по префиксу с optional-фильтром server PDA. Соединение НЕ закрывает. */ + public List searchByLoginPrefix(Connection c, String prefix, Boolean isServer) throws SQLException { String sql = """ SELECT login, @@ -185,16 +166,24 @@ public final class SolanaUsersDAO { solana_key, blockchain_key, client_key - FROM solana_users + FROM %s WHERE LOWER(login) LIKE ? + AND (? IS NULL OR is_server = ?) ORDER BY login LIMIT 5 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); - List result = new ArrayList<>(); + List result = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, prefix.toLowerCase() + "%"); + if (isServer == null) { + ps.setNull(2, Types.BOOLEAN); + ps.setNull(3, Types.BOOLEAN); + } else { + ps.setBoolean(2, isServer); + ps.setBoolean(3, isServer); + } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) result.add(mapRow(rs)); } @@ -204,23 +193,28 @@ public final class SolanaUsersDAO { } /** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */ - public List searchByLoginPrefix(String prefix) throws SQLException { + public List searchByLoginPrefix(String prefix) throws SQLException { + return searchByLoginPrefix(prefix, null); + } + + /** Поиск по префиксу без внешнего соединения с optional-фильтром server PDA. */ + public List searchByLoginPrefix(String prefix, Boolean isServer) throws SQLException { try (Connection c = db.getConnection()) { - return searchByLoginPrefix(c, prefix); + return searchByLoginPrefix(c, prefix, isServer); } } // -------------------- MAPPER -------------------- - private SolanaUserEntry mapRow(ResultSet rs) throws SQLException { - SolanaUserEntry e = new SolanaUserEntry(); + private CurrentUserEntry mapRow(ResultSet rs) throws SQLException { + CurrentUserEntry e = new CurrentUserEntry(); e.setLogin(rs.getString("login")); e.setBlockchainName(rs.getString("blockchain_name")); - e.setSolanaKey(rs.getString("solana_key")); - e.setBlockchainKey(rs.getString("blockchain_key")); - e.setClientKey(rs.getString("client_key")); + e.setSolanaKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("solana_key"))); + e.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"))); + e.setClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"))); return e; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java index 5d2b4c56..ae063da4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DirectMessagesDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.DirectMessageEntry; import java.sql.Connection; @@ -8,7 +8,7 @@ import java.sql.PreparedStatement; public final class DirectMessagesDAO { private static volatile DirectMessagesDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private DirectMessagesDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java new file mode 100644 index 00000000..b5fe6410 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmSyncPeerStateDAO.java @@ -0,0 +1,173 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.DmSyncPeerStateEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class DmSyncPeerStateDAO { + private static volatile DmSyncPeerStateDAO instance; + private final DbController db = DbController.getInstance(); + + private DmSyncPeerStateDAO() {} + + public static DmSyncPeerStateDAO getInstance() { + if (instance == null) { + synchronized (DmSyncPeerStateDAO.class) { + if (instance == null) instance = new DmSyncPeerStateDAO(); + } + } + return instance; + } + + public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin); + if (existing != null) { + if (remoteServerUrl != null && !remoteServerUrl.isBlank() + && !remoteServerUrl.equals(existing.getRemoteServerUrl())) { + touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl); + existing.setRemoteServerUrl(remoteServerUrl); + } + return existing; + } + + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?) + ON CONFLICT DO NOTHING + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setLong(4, nowMs); + ps.executeUpdate(); + } + existing = get(ownerLogin, remoteServerLogin); + if (existing == null) { + throw new SQLException("Failed to create dm_sync_peer_state row"); + } + return existing; + } + + public void updateSuccess( + String ownerLogin, + String remoteServerLogin, + String remoteServerUrl, + long cursorStoredAtMs, + String cursorMessageKey, + boolean bootstrapCompleted + ) throws SQLException { + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) + ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET + remote_server_url = EXCLUDED.remote_server_url, + cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms, + cursor_message_key = EXCLUDED.cursor_message_key, + bootstrap_completed = EXCLUDED.bootstrap_completed, + last_sync_at_ms = EXCLUDED.last_sync_at_ms, + last_error = NULL, + updated_at_ms = EXCLUDED.updated_at_ms + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setLong(4, Math.max(0L, cursorStoredAtMs)); + ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey); + ps.setBoolean(6, bootstrapCompleted); + ps.setLong(7, nowMs); + ps.setLong(8, nowMs); + ps.executeUpdate(); + } + } + + public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException { + long nowMs = System.currentTimeMillis(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + INSERT INTO dm_sync_peer_state ( + owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + ) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?) + ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET + remote_server_url = EXCLUDED.remote_server_url, + last_error = EXCLUDED.last_error, + updated_at_ms = EXCLUDED.updated_at_ms + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl); + ps.setString(4, truncate(error)); + ps.setLong(5, nowMs); + ps.executeUpdate(); + } + } + + private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + SELECT owner_login, remote_server_login, remote_server_url, + cursor_stored_at_ms, cursor_message_key, bootstrap_completed, + last_sync_at_ms, last_error, updated_at_ms + FROM dm_sync_peer_state + WHERE LOWER(owner_login) = LOWER(?) + AND LOWER(remote_server_login) = LOWER(?) + LIMIT 1 + """)) { + ps.setString(1, ownerLogin); + ps.setString(2, remoteServerLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + DmSyncPeerStateEntry e = new DmSyncPeerStateEntry(); + e.setOwnerLogin(rs.getString("owner_login")); + e.setRemoteServerLogin(rs.getString("remote_server_login")); + e.setRemoteServerUrl(rs.getString("remote_server_url")); + e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms")); + e.setCursorMessageKey(rs.getString("cursor_message_key")); + e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed")); + long lastSyncAt = rs.getLong("last_sync_at_ms"); + e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt); + e.setLastError(rs.getString("last_error")); + e.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return e; + } + } + } + + private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE dm_sync_peer_state + SET remote_server_url = ?, updated_at_ms = ? + WHERE LOWER(owner_login) = LOWER(?) + AND LOWER(remote_server_login) = LOWER(?) + """)) { + ps.setString(1, remoteServerUrl); + ps.setLong(2, System.currentTimeMillis()); + ps.setString(3, ownerLogin); + ps.setString(4, remoteServerLogin); + ps.executeUpdate(); + } + } + + private static String truncate(String value) { + if (value == null) return null; + String clean = value.trim(); + if (clean.length() <= 500) return clean; + return clean.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java index 9c90c975..3ef4764a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.EspPairingRequestEntry; import java.sql.Connection; @@ -13,7 +13,7 @@ import java.util.List; public final class EspPairingRequestsDAO { private static volatile EspPairingRequestsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private EspPairingRequestsDAO() { } @@ -110,7 +110,7 @@ public final class EspPairingRequestsDAO { payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver FROM esp_pairing_requests - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND expires_at_ms > ? AND status = 'created' ORDER BY created_at_ms DESC @@ -131,7 +131,7 @@ public final class EspPairingRequestsDAO { StringBuilder sql = new StringBuilder(""" SELECT COUNT(*) FROM esp_pairing_requests - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND created_at_ms >= ? AND status IN ( """); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java index 873ba7dd..cc86a954 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.EspPairingSettingsEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.sql.SQLException; public final class EspPairingSettingsDAO { private static volatile EspPairingSettingsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private EspPairingSettingsDAO() { } @@ -74,7 +74,7 @@ public final class EspPairingSettingsDAO { String sql = """ SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms FROM esp_pairing_settings - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) LIMIT 1 """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java index db9d11d7..1b16a24e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/IpGeoCacheDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.IpGeoCacheEntry; import java.sql.*; @@ -20,7 +20,7 @@ import java.sql.*; public final class IpGeoCacheDAO { private static volatile IpGeoCacheDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private IpGeoCacheDAO() { } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java index 1fc3ef30..8586c01c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/PushTokensDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.PushTokenEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.util.List; public final class PushTokensDAO { private static volatile PushTokensDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private PushTokensDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java index 6cea2249..0c9f9064 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.SignedDirectMessageHistoryEntry; import java.sql.Connection; @@ -8,7 +8,7 @@ import java.sql.PreparedStatement; public final class SignedDirectMessagesHistoryDAO { private static volatile SignedDirectMessagesHistoryDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SignedDirectMessagesHistoryDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java index 3144dffe..9d11354b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java @@ -1,13 +1,13 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; public final class SignedDmReplayDAO { private static volatile SignedDmReplayDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SignedDmReplayDAO() {} @@ -24,10 +24,11 @@ public final class SignedDmReplayDAO { cleanupExpired(nowMs - 15L * 60L * 1000L); try (Connection c = db.getConnection()) { String sql = """ - INSERT OR IGNORE INTO signed_direct_message_replay ( - from_login, time_ms, nonce, created_at_ms - ) VALUES (?, ?, ?, ?) - """; + INSERT INTO signed_direct_message_replay ( + from_login, time_ms, nonce, created_at_ms + ) VALUES (?, ?, ?, ?) + ON CONFLICT DO NOTHING + """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, fromLogin); ps.setLong(2, timeMs); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java new file mode 100644 index 00000000..e8d11c6f --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesDAO.java @@ -0,0 +1,823 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.entities.SignedMessageEntry; + +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 SignedMessagesDAO { + + public enum ApplyStatus { + APPLIED, + DUPLICATE_OR_OLDER, + BLOCKED_BY_MESSAGE_TOMBSTONE, + BLOCKED_BY_CONVERSATION_TOMBSTONE; + + public boolean applied() { + return this == APPLIED; + } + } + + private static volatile SignedMessagesDAO instance; + private final DbController db = DbController.getInstance(); + + private SignedMessagesDAO() {} + + public static SignedMessagesDAO getInstance() { + if (instance == null) { + synchronized (SignedMessagesDAO.class) { + if (instance == null) instance = new SignedMessagesDAO(); + } + } + return instance; + } + + public ApplyStatus insertIfAbsent(SignedMessageEntry e) throws Exception { + 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 INTO signed_messages ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + """; + 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; + } + } + }); + } + + public boolean insertPairBothOrNothing(SignedMessageEntry first, SignedMessageEntry second) throws Exception { + 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); + } + } + }); + } + + public ApplyStatus upsertContentPair(SignedMessageEntry incoming, SignedMessageEntry outgoing) throws Exception { + 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; + } + + 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); + } + } + }); + } + + public ApplyStatus upsertIncomingCopy(SignedMessageEntry incoming) throws Exception { + 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; + } + + 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(SignedMessageEntry tombstone) throws Exception { + 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()); + + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + }); + } + + public ApplyStatus applyDeleteConversation(SignedMessageEntry tombstone) throws Exception { + 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); + } + } + }); + } + + public SignedMessageEntry getByMessageKey(String messageKey) throws Exception { + try (Connection c = db.getConnection()) { + String sql = """ + SELECT + 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 %s + WHERE message_key = ? + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + } + + public SignedMessageEntry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { + try (Connection c = db.getConnection()) { + return getLatestConversationDelete(c, fromLogin, toLogin); + } + } + + public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception { + 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 INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) VALUES (?, ?, 0, NULL, ?) + ON CONFLICT DO NOTHING + """; + 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 { + 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; + } + }); + } + + public List listPendingForSession(String login, String sessionId) throws Exception { + return withBusyRetry(() -> { + try (Connection c = db.getConnection()) { + String fillSql = """ + INSERT INTO signed_message_session_delivery ( + message_key, session_id, delivered, delivered_at_ms, created_at_ms + ) + SELECT m.message_key, ?, 0, NULL, ? + FROM %s m + WHERE ( + (m.message_type IN (1, 3) AND LOWER(m.to_login) = LOWER(?)) + OR (m.message_type IN (2, 4) AND LOWER(m.from_login) = LOWER(?)) + OR (m.message_type IN (5, 6, 7, 8) + AND (LOWER(m.from_login) = LOWER(?) OR LOWER(m.to_login) = LOWER(?))) + ) + ON CONFLICT DO NOTHING + """.formatted(messagesTable()); + 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 %s 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 + """.formatted(messagesTable()); + 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 + 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 %s + WHERE LOWER(target_login) = LOWER(?) + AND message_type IN (1, 2) + AND ( + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + ) + AND ( + ? <= 0 + OR time_ms < ? + OR (time_ms = ? AND (? = '' OR message_key < ?)) + ) + ORDER BY time_ms DESC, message_key DESC + LIMIT ? + """.formatted(messagesTable()); + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + 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)); + } + } + return out; + } + } + + public SyncBatch listSyncBatch( + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes + ) throws Exception { + int safeLimit = Math.max(1, Math.min(limit, 500)); + int sqlLimit = safeLimit + 1; + String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey; + try (Connection c = db.getConnection()) { + String sql = """ + SELECT + 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 %s + WHERE ( + LOWER(target_login) = LOWER(?) + OR ( + message_type IN (5, 6, 7, 8) + AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?)) + ) + ) + AND ( + created_at_ms > ? + OR (created_at_ms = ? AND (? = '' OR message_key > ?)) + ) + ORDER BY created_at_ms ASC, message_key ASC + LIMIT ? + """.formatted(messagesTable()); + List out = new ArrayList<>(); + boolean hasMore = false; + int usedBytes = 0; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerLogin); + ps.setLong(4, Math.max(0L, afterStoredAtMs)); + ps.setLong(5, Math.max(0L, afterStoredAtMs)); + ps.setString(6, safeAfterMessageKey); + ps.setString(7, safeAfterMessageKey); + ps.setInt(8, sqlLimit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + SignedMessageEntry row = mapRow(rs); + byte[] raw = row.getRawBlock(); + int rowBytes = raw == null ? 0 : raw.length; + if (out.size() >= safeLimit) { + hasMore = true; + break; + } + if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) { + hasMore = true; + break; + } + out.add(row); + usedBytes += rowBytes; + } + } + } + return new SyncBatch(out, hasMore, usedBytes); + } + } + + private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException { + String sql = """ + INSERT INTO %s ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_key) DO UPDATE SET + base_key = excluded.base_key, + target_login = excluded.target_login, + from_login = excluded.from_login, + to_login = excluded.to_login, + time_ms = excluded.time_ms, + nonce = excluded.nonce, + message_type = excluded.message_type, + revision_time_ms = excluded.revision_time_ms, + reencrypted_at_ms = excluded.reencrypted_at_ms, + raw_block = excluded.raw_block, + created_at_ms = excluded.created_at_ms, + 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, + read_at_ms = COALESCE(%s.read_at_ms, excluded.read_at_ms) + """.formatted(messagesTable(), messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + ps.executeUpdate(); + } + } + + private void markMessageReadByReceipt(Connection c, SignedMessageEntry 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 %s + 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) + """.formatted(messagesTable()))) { + 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 + FROM %s + WHERE message_key = ? + LIMIT 1 + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, messageKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); + } + } + } + + private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT revision_time_ms, reencrypted_at_ms + FROM %s + WHERE base_key = ? + AND message_type IN (1, 2) + ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC + LIMIT 1 + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, baseKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); + } + } + } + + private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT 1 + FROM %s + WHERE base_key = ? + AND message_type IN (5, 6) + LIMIT 1 + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, baseKey); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException { + Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin); + return boundary != null && timeMs < boundary; + } + + private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException { + String sql = """ + SELECT MAX(time_ms) + FROM %s + WHERE message_type IN (7, 8) + AND ( + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + ) + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + ps.setString(3, toLogin); + ps.setString(4, fromLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + long value = rs.getLong(1); + return rs.wasNull() ? null : value; + } + } + } + + private SignedMessageEntry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { + String sql = """ + SELECT + 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 %s + WHERE message_type IN (7, 8) + AND ( + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + ) + ORDER BY time_ms DESC + LIMIT 1 + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + ps.setString(3, toLogin); + ps.setString(4, fromLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM %s + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """.formatted(messagesTable()), baseKey, baseKey); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM %s + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """.formatted(messagesTable()))) { + ps.setString(1, baseKey); + ps.setString(2, baseKey); + ps.executeUpdate(); + } + } + + private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM %s + WHERE time_ms < ? + AND ( + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + ) + """.formatted(messagesTable()), boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM %s + WHERE time_ms < ? + AND ( + (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + OR (LOWER(from_login) = LOWER(?) AND LOWER(to_login) = LOWER(?)) + ) + """.formatted(messagesTable()))) { + ps.setLong(1, boundaryTimeMs); + ps.setString(2, fromLogin); + ps.setString(3, toLogin); + ps.setString(4, toLogin); + ps.setString(5, fromLogin); + ps.executeUpdate(); + } + } + + private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException { + String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindObjects(ps, bindValues); + ps.executeUpdate(); + } + } + + private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(""" + UPDATE signed_message_session_delivery + SET delivered = 0, delivered_at_ms = NULL + WHERE message_key = ? + """)) { + ps.setString(1, messageKey); + ps.executeUpdate(); + } + } + + private int insertStrict(Connection c, SignedMessageEntry e) throws SQLException { + String sql = """ + INSERT INTO %s ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.formatted(messagesTable()); + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindSignedMessage(ps, e); + return ps.executeUpdate(); + } + } + + private void bindSignedMessage(PreparedStatement ps, SignedMessageEntry e) throws SQLException { + ps.setString(1, e.getMessageKey()); + ps.setString(2, e.getBaseKey()); + ps.setString(3, e.getTargetLogin()); + ps.setString(4, e.getFromLogin()); + ps.setString(5, e.getToLogin()); + ps.setLong(6, e.getTimeMs()); + ps.setLong(7, e.getNonce()); + ps.setInt(8, e.getMessageType()); + ps.setLong(9, e.getRevisionTimeMs()); + ps.setLong(10, e.getReencryptedAtMs()); + ps.setBytes(11, e.getRawBlock()); + ps.setLong(12, e.getCreatedAtMs()); + ps.setString(13, e.getSourceApi()); + ps.setString(14, e.getOriginSessionId()); + 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 { + for (int i = 0; i < bindValues.length; i++) { + Object value = bindValues[i]; + int param = i + 1; + if (value instanceof String s) { + ps.setString(param, s); + } else if (value instanceof Long l) { + ps.setLong(param, l); + } else if (value instanceof Integer n) { + ps.setInt(param, n); + } else { + ps.setObject(param, value); + } + } + } + + private boolean isConstraintViolation(SQLException ex) { + String msg = String.valueOf(ex.getMessage()).toLowerCase(); + return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); + } + + private T withBusyRetry(SqlWork work) throws Exception { + return work.run(); + } + + private int compareMarkers(RevisionMarker left, RevisionMarker right) { + int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs); + if (revisionCompare != 0) return revisionCompare; + return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs); + } + + private String messagesTable() { + return "signed_messages"; + } + + private SignedMessageEntry mapRow(ResultSet rs) throws Exception { + SignedMessageEntry e = new SignedMessageEntry(); + e.setMessageKey(rs.getString("message_key")); + e.setBaseKey(rs.getString("base_key")); + e.setTargetLogin(rs.getString("target_login")); + e.setFromLogin(rs.getString("from_login")); + e.setToLogin(rs.getString("to_login")); + e.setTimeMs(rs.getLong("time_ms")); + e.setNonce(rs.getLong("nonce")); + e.setMessageType(rs.getInt("message_type")); + e.setRevisionTimeMs(rs.getLong("revision_time_ms")); + e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms")); + e.setRawBlock(rs.getBytes("raw_block")); + e.setCreatedAtMs(rs.getLong("created_at_ms")); + e.setSourceApi(rs.getString("source_api")); + e.setOriginSessionId(rs.getString("origin_session_id")); + 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; + } + + private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) { + private static RevisionMarker of(SignedMessageEntry entry) { + return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); + } + } + + public record SyncBatch(List items, boolean hasMore, int rawBytes) {} + + @FunctionalInterface + private interface SqlWork { + T run() throws Exception; + } +} 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 deleted file mode 100644 index 530f4b4b..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ /dev/null @@ -1,622 +0,0 @@ -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.SignedMessageV2Entry; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -public final class SignedMessagesV2DAO { - public enum ApplyStatus { - APPLIED, - DUPLICATE_OR_OLDER, - BLOCKED_BY_MESSAGE_TOMBSTONE, - BLOCKED_BY_CONVERSATION_TOMBSTONE; - - public boolean applied() { - return this == APPLIED; - } - } - - private static volatile SignedMessagesV2DAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private SignedMessagesV2DAO() {} - - public static SignedMessagesV2DAO getInstance() { - if (instance == null) { - synchronized (SignedMessagesV2DAO.class) { - if (instance == null) instance = new SignedMessagesV2DAO(); - } - } - return instance; - } - - 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; - } - 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 false; - } - 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; - } - - 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); - 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; - } - - 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); - } - } - } - - 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; - } - - 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); - } - } - } - - 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; - } - - 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 { - try (Connection c = db.getConnection()) { - String sql = """ - SELECT - 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 - FROM signed_messages_v2 - WHERE message_key = ? - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, messageKey); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - } - - public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { - try (Connection c = db.getConnection()) { - return getLatestConversationDelete(c, fromLogin, toLogin); - } - } - - 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(); - } - } - } - - 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(); - } - - 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(); - } - - 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 - """; - 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; - } - } - - private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException { - String sql = """ - INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(message_key) DO UPDATE SET - base_key = excluded.base_key, - target_login = excluded.target_login, - from_login = excluded.from_login, - to_login = excluded.to_login, - time_ms = excluded.time_ms, - nonce = excluded.nonce, - message_type = excluded.message_type, - revision_time_ms = excluded.revision_time_ms, - reencrypted_at_ms = excluded.reencrypted_at_ms, - raw_block = excluded.raw_block, - created_at_ms = excluded.created_at_ms, - 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 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - bindSignedMessage(ps, e); - ps.executeUpdate(); - } - } - - private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException { - String sql = """ - SELECT revision_time_ms, reencrypted_at_ms - FROM signed_messages_v2 - WHERE message_key = ? - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, messageKey); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return new RevisionMarker(rs.getLong(1), rs.getLong(2)); - } - } - } - - private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException { - String sql = """ - SELECT revision_time_ms, reencrypted_at_ms - FROM signed_messages_v2 - WHERE base_key = ? - AND message_type IN (1, 2) - ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, baseKey); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return new RevisionMarker(rs.getLong(1), rs.getLong(2)); - } - } - } - - private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException { - String sql = """ - SELECT 1 - FROM signed_messages_v2 - WHERE base_key = ? - AND message_type IN (5, 6) - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, baseKey); - try (ResultSet rs = ps.executeQuery()) { - return rs.next(); - } - } - } - - private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException { - Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin); - return boundary != null && timeMs < boundary; - } - - private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException { - String sql = """ - SELECT MAX(time_ms) - FROM signed_messages_v2 - WHERE message_type IN (7, 8) - AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - ) - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, fromLogin); - ps.setString(2, toLogin); - ps.setString(3, toLogin); - ps.setString(4, fromLogin); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - long value = rs.getLong(1); - return rs.wasNull() ? null : value; - } - } - } - - private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { - String sql = """ - SELECT - 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 - FROM signed_messages_v2 - WHERE message_type IN (7, 8) - AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - ) - ORDER BY time_ms DESC - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, fromLogin); - ps.setString(2, toLogin); - ps.setString(3, toLogin); - ps.setString(4, fromLogin); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { - deleteDeliveryRowsByMessageSelection(c, """ - SELECT message_key - FROM signed_messages_v2 - WHERE (base_key = ? AND message_type IN (1, 2)) - OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) - """, baseKey, baseKey); - - try (PreparedStatement ps = c.prepareStatement(""" - DELETE FROM signed_messages_v2 - WHERE (base_key = ? AND message_type IN (1, 2)) - OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) - """)) { - ps.setString(1, baseKey); - ps.setString(2, baseKey); - ps.executeUpdate(); - } - } - - private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException { - deleteDeliveryRowsByMessageSelection(c, """ - SELECT message_key - FROM signed_messages_v2 - WHERE time_ms < ? - AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - ) - """, boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); - - try (PreparedStatement ps = c.prepareStatement(""" - DELETE FROM signed_messages_v2 - WHERE time_ms < ? - AND ( - (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) - ) - """)) { - ps.setLong(1, boundaryTimeMs); - ps.setString(2, fromLogin); - ps.setString(3, toLogin); - ps.setString(4, toLogin); - ps.setString(5, fromLogin); - ps.executeUpdate(); - } - } - - private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException { - String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")"; - try (PreparedStatement ps = c.prepareStatement(sql)) { - bindObjects(ps, bindValues); - ps.executeUpdate(); - } - } - - private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { - try (PreparedStatement ps = c.prepareStatement(""" - UPDATE signed_message_session_delivery - SET delivered = 0, delivered_at_ms = NULL - WHERE message_key = ? - """)) { - ps.setString(1, messageKey); - ps.executeUpdate(); - } - } - - private int insertStrict(Connection c, SignedMessageV2Entry e) throws SQLException { - String sql = """ - INSERT 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(); - } - } - - private void bindSignedMessage(PreparedStatement ps, SignedMessageV2Entry e) throws SQLException { - ps.setString(1, e.getMessageKey()); - ps.setString(2, e.getBaseKey()); - ps.setString(3, e.getTargetLogin()); - ps.setString(4, e.getFromLogin()); - ps.setString(5, e.getToLogin()); - ps.setLong(6, e.getTimeMs()); - ps.setLong(7, e.getNonce()); - ps.setInt(8, e.getMessageType()); - ps.setLong(9, e.getRevisionTimeMs()); - ps.setLong(10, e.getReencryptedAtMs()); - ps.setBytes(11, e.getRawBlock()); - ps.setLong(12, e.getCreatedAtMs()); - ps.setString(13, e.getSourceApi()); - ps.setString(14, e.getOriginSessionId()); - ps.setString(15, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(16, null); - else ps.setInt(16, e.getReceiptRefType()); - } - - private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException { - for (int i = 0; i < bindValues.length; i++) { - Object value = bindValues[i]; - int param = i + 1; - if (value instanceof String s) { - ps.setString(param, s); - } else if (value instanceof Long l) { - ps.setLong(param, l); - } else if (value instanceof Integer n) { - ps.setInt(param, n); - } else { - ps.setObject(param, value); - } - } - } - - private boolean isConstraintViolation(SQLException ex) { - String msg = String.valueOf(ex.getMessage()).toLowerCase(); - return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); - } - - private int compareMarkers(RevisionMarker left, RevisionMarker right) { - int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs); - if (revisionCompare != 0) return revisionCompare; - return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs); - } - - private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { - SignedMessageV2Entry e = new SignedMessageV2Entry(); - e.setMessageKey(rs.getString("message_key")); - e.setBaseKey(rs.getString("base_key")); - e.setTargetLogin(rs.getString("target_login")); - e.setFromLogin(rs.getString("from_login")); - e.setToLogin(rs.getString("to_login")); - e.setTimeMs(rs.getLong("time_ms")); - e.setNonce(rs.getLong("nonce")); - e.setMessageType(rs.getInt("message_type")); - e.setRevisionTimeMs(rs.getLong("revision_time_ms")); - e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms")); - e.setRawBlock(rs.getBytes("raw_block")); - e.setCreatedAtMs(rs.getLong("created_at_ms")); - e.setSourceApi(rs.getString("source_api")); - e.setOriginSessionId(rs.getString("origin_session_id")); - e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key")); - int maybeRefType = rs.getInt("receipt_ref_type"); - e.setReceiptRefType(rs.wasNull() ? null : maybeRefType); - return e; - } - - private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) { - private static RevisionMarker of(SignedMessageV2Entry entry) { - return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); - } - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java new file mode 100644 index 00000000..923bff29 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -0,0 +1,71 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем. + */ +public final class SolanaUserPdaCurrentDAO { + + private static volatile SolanaUserPdaCurrentDAO instance; + + private final DbController db = DbController.getInstance(); + + private SolanaUserPdaCurrentDAO() {} + + public static SolanaUserPdaCurrentDAO getInstance() { + if (instance == null) { + synchronized (SolanaUserPdaCurrentDAO.class) { + if (instance == null) { + instance = new SolanaUserPdaCurrentDAO(); + } + } + } + return instance; + } + + public SolanaUserPdaCurrentEntry getByBlockchainName(String blockchainName) throws SQLException { + try (Connection c = db.getConnection()) { + return getByBlockchainName(c, blockchainName); + } + } + + public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT + login, + blockchain_name, + blockchain_key, + paid_limit_bytes + FROM solana_user_pda_current + WHERE blockchain_name = ? + LIMIT 1 + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return null; + } + return mapRow(rs); + } + } + } + + private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException { + SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry(); + entry.setLogin(rs.getString("login")); + entry.setBlockchainName(rs.getString("blockchain_name")); + entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"))); + entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes")); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java index 3fe7b0cc..cd089db4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java @@ -1,7 +1,7 @@ package shine.db.dao; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.*; import java.util.ArrayList; @@ -25,7 +25,7 @@ import java.util.List; public final class SubscriptionsDAO { private static volatile SubscriptionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SubscriptionsDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java index edeeb9b9..bbf9b71c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.SyncServerEntry; import java.sql.Connection; @@ -17,7 +17,7 @@ import java.util.List; public final class SyncServersDAO { private static volatile SyncServersDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private SyncServersDAO() {} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java index 73571402..5ce4d44f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/TestFreeAvatarUploadsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.TestFreeAvatarUploadEntry; import java.sql.Connection; @@ -11,7 +11,7 @@ import java.sql.SQLException; public final class TestFreeAvatarUploadsDAO { private static volatile TestFreeAvatarUploadsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private TestFreeAvatarUploadsDAO() { } @@ -29,7 +29,7 @@ public final class TestFreeAvatarUploadsDAO { String sql = """ SELECT login, used_count, updated_at_ms, last_tx_id FROM test_free_avatar_uploads - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) LIMIT 1 """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java new file mode 100644 index 00000000..e95a6df4 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserAccessServersCurrentDAO.java @@ -0,0 +1,86 @@ +package shine.db.dao; + +import shine.db.DbController; +import shine.db.KeyEncodingUtil; +import shine.db.entities.UserAccessServerRouteEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * DAO локальной routing-проекции access servers пользователей. + */ +public final class UserAccessServersCurrentDAO { + + private static volatile UserAccessServersCurrentDAO instance; + private final DbController db = DbController.getInstance(); + + private UserAccessServersCurrentDAO() {} + + public static UserAccessServersCurrentDAO getInstance() { + if (instance == null) { + synchronized (UserAccessServersCurrentDAO.class) { + if (instance == null) instance = new UserAccessServersCurrentDAO(); + } + } + return instance; + } + + public List listByUserLogin(String userLogin) throws SQLException { + try (Connection c = db.getConnection()) { + return listByUserLogin(c, userLogin); + } + } + + public List listByUserLogin(Connection c, String userLogin) throws SQLException { + String sql = """ + SELECT user_login, server_login, server_url, server_client_key + FROM user_access_servers_current + WHERE LOWER(user_login) = LOWER(?) + ORDER BY server_login + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, userLogin); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(mapRow(rs)); + } + } + } + return result; + } + + public List listUserLoginsByServerLogin(String serverLogin) throws SQLException { + String sql = """ + SELECT user_login + FROM user_access_servers_current + WHERE LOWER(server_login) = LOWER(?) + ORDER BY user_login + """; + List result = new ArrayList<>(); + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, serverLogin); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(rs.getString("user_login")); + } + } + } + return result; + } + + private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException { + UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry(); + entry.setUserLogin(rs.getString("user_login")); + entry.setServerLogin(rs.getString("server_login")); + entry.setServerUrl(rs.getString("server_url")); + entry.setServerClientKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("server_client_key"))); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java deleted file mode 100644 index 8476f3a0..00000000 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java +++ /dev/null @@ -1,128 +0,0 @@ -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; - -import java.sql.*; - -/** - * UserCreateDAO — атомарное добавление пользователя: - * - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key) - * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) - * - * ВАЖНО: - * - только INSERT (без перезаписи существующих записей) - * - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято) - */ -public final class UserCreateDAO { - - private static volatile UserCreateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - - private UserCreateDAO() {} - - public static UserCreateDAO getInstance() { - if (instance == null) { - synchronized (UserCreateDAO.class) { - if (instance == null) instance = new UserCreateDAO(); - } - } - return instance; - } - - /** - * @return true если добавили; false если занято (login уже есть или blockchainName уже существует). - */ - public boolean insertUserWithBlockchain( - String login, - String blockchainName, - String solanaKey, - String blockchainKey, - String clientKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - - try { - // 1) solana_users - SolanaUserEntry u = new SolanaUserEntry(); - u.setLogin(login); - u.setBlockchainName(blockchainName); - u.setSolanaKey(solanaKey); - u.setBlockchainKey(blockchainKey); - u.setClientKey(clientKey); - - usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint - - // 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку) - insertBlockchainStateStrict( - c, - blockchainName, - login, - blockchainKey, - sizeLimit, - nowMs - ); - - c.commit(); - return true; - - } catch (SQLException e) { - c.rollback(); - - String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); - if (msg.contains("constraint")) { - return false; - } - throw e; - - } finally { - c.setAutoCommit(oldAuto); - } - } - } - - private static void insertBlockchainStateStrict( - Connection c, - String blockchainName, - String login, - String blockchainKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - String sql = """ - INSERT INTO blockchain_state ( - blockchain_name, - login, - blockchain_key, - size_limit, - file_size_bytes, - last_block_number, - last_block_hash, - updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - ps.setString(i++, blockchainName); - ps.setString(i++, login); - ps.setString(i++, blockchainKey); - - ps.setLong(i++, sizeLimit); - ps.setLong(i++, 0L); - - ps.setInt(i++, -1); - ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет - ps.setLong(i++, nowMs); - - ps.executeUpdate(); // если blockchainName занят -> constraint (PK) - } - } -} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java index fe20aeb4..9166759b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java @@ -1,6 +1,6 @@ package shine.db.dao; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.entities.UserParamEntry; import java.sql.*; @@ -21,7 +21,7 @@ import java.util.List; public final class UserParamsDAO { private static volatile UserParamsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); + private final DbController db = DbController.getInstance(); private UserParamsDAO() { } @@ -89,7 +89,7 @@ public final class UserParamsDAO { client_key, signature FROM users_params - WHERE login = ? COLLATE NOCASE AND param = ? + WHERE LOWER(login) = LOWER(?) AND param = ? LIMIT 1 """; @@ -120,7 +120,7 @@ public final class UserParamsDAO { client_key, signature FROM users_params - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) ORDER BY time_ms DESC """; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java index ec7fb49d..b9812543 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java @@ -13,6 +13,10 @@ public class ChannelNameStateEntry { private int channelRootBlockNumber; private byte[] channelRootBlockHash; private long createdAtMs; + private String avaAr; + private String avaSha256; + private long avaSize; + private long metaUpdatedAtMs; public String getSlug() { return slug; @@ -93,4 +97,36 @@ public class ChannelNameStateEntry { public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + + public String getAvaAr() { + return avaAr; + } + + public void setAvaAr(String avaAr) { + this.avaAr = avaAr; + } + + public String getAvaSha256() { + return avaSha256; + } + + public void setAvaSha256(String avaSha256) { + this.avaSha256 = avaSha256; + } + + public long getAvaSize() { + return avaSize; + } + + public void setAvaSize(long avaSize) { + this.avaSize = avaSize; + } + + public long getMetaUpdatedAtMs() { + return metaUpdatedAtMs; + } + + public void setMetaUpdatedAtMs(long metaUpdatedAtMs) { + this.metaUpdatedAtMs = metaUpdatedAtMs; + } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java similarity index 88% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java index 7552faf1..8a14051b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/CurrentUserEntry.java @@ -3,18 +3,19 @@ package shine.db.entities; import java.util.Base64; /** - * SolanaUserEntry — локальная запись пользователя из Solana. + * CurrentUserEntry — локальная runtime-проекция текущего состояния пользователя. * - * Таблица: solana_users + * Источник: + * - solana_user_pda_current * * Поля: - * - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE) + * - login — PRIMARY KEY (TEXT) * - blockchain_name — TEXT NOT NULL * - solana_key — TEXT NOT NULL * - blockchain_key — TEXT NOT NULL * - client_key — TEXT NOT NULL */ -public class SolanaUserEntry { +public class CurrentUserEntry { private String login; @@ -29,9 +30,9 @@ public class SolanaUserEntry { /** Ключ устройства (публичный ключ устройства) */ private String clientKey; - public SolanaUserEntry() {} + public CurrentUserEntry() {} - public SolanaUserEntry(String login, + public CurrentUserEntry(String login, String blockchainName, String solanaKey, String blockchainKey, @@ -81,4 +82,4 @@ public class SolanaUserEntry { return null; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java new file mode 100644 index 00000000..7730e349 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/DmSyncPeerStateEntry.java @@ -0,0 +1,32 @@ +package shine.db.entities; + +public class DmSyncPeerStateEntry { + private String ownerLogin; + private String remoteServerLogin; + private String remoteServerUrl; + private long cursorStoredAtMs; + private String cursorMessageKey; + private boolean bootstrapCompleted; + private Long lastSyncAtMs; + private String lastError; + private long updatedAtMs; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public String getRemoteServerLogin() { return remoteServerLogin; } + public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; } + public String getRemoteServerUrl() { return remoteServerUrl; } + public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; } + public long getCursorStoredAtMs() { return cursorStoredAtMs; } + public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; } + public String getCursorMessageKey() { return cursorMessageKey; } + public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; } + public boolean isBootstrapCompleted() { return bootstrapCompleted; } + public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; } + public Long getLastSyncAtMs() { return lastSyncAtMs; } + public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; } + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + public long getUpdatedAtMs() { return updatedAtMs; } + public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } +} 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/SignedMessageEntry.java similarity index 93% rename from SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java rename to SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageEntry.java index 84e4bdfa..207fdb7c 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/SignedMessageEntry.java @@ -1,6 +1,6 @@ package shine.db.entities; -public class SignedMessageV2Entry { +public class SignedMessageEntry { private String messageKey; private String baseKey; private String targetLogin; @@ -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-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java new file mode 100644 index 00000000..33fcce29 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserPdaCurrentEntry.java @@ -0,0 +1,46 @@ +package shine.db.entities; + +/** + * Минимальный срез текущей записи пользователя из PostgreSQL sync-таблицы. + * + * Источник: solana_user_pda_current + */ +public final class SolanaUserPdaCurrentEntry { + + private String login; + private String blockchainName; + private String blockchainKey; + private long paidLimitBytes; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getBlockchainName() { + return blockchainName; + } + + public void setBlockchainName(String blockchainName) { + this.blockchainName = blockchainName; + } + + public String getBlockchainKey() { + return blockchainKey; + } + + public void setBlockchainKey(String blockchainKey) { + this.blockchainKey = blockchainKey; + } + + public long getPaidLimitBytes() { + return paidLimitBytes; + } + + public void setPaidLimitBytes(long paidLimitBytes) { + this.paidLimitBytes = paidLimitBytes; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java new file mode 100644 index 00000000..ac4f042d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserAccessServerRouteEntry.java @@ -0,0 +1,27 @@ +package shine.db.entities; + +/** + * Локальная routing-проекция access server пользователя. + * + * Источник: + * - user_access_servers_current + */ +public class UserAccessServerRouteEntry { + + private String userLogin; + private String serverLogin; + private String serverUrl; + private String serverClientKey; + + public String getUserLogin() { return userLogin; } + public void setUserLogin(String userLogin) { this.userLogin = userLogin; } + + public String getServerLogin() { return serverLogin; } + public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; } + + public String getServerUrl() { return serverUrl; } + public void setServerUrl(String serverUrl) { this.serverUrl = serverUrl; } + + public String getServerClientKey() { return serverClientKey; } + public void setServerClientKey(String serverClientKey) { this.serverClientKey = serverClientKey; } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java new file mode 100644 index 00000000..837e5aec --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/sql/CurrentUsersSql.java @@ -0,0 +1,24 @@ +package shine.db.sql; + +public final class CurrentUsersSql { + + private CurrentUsersSql() {} + + public static String usersSubquery(String alias) { + if (alias == null || alias.isBlank()) { + throw new IllegalArgumentException("alias is blank"); + } + return """ + ( + SELECT + current_users.login AS login, + current_users.blockchain_name AS blockchain_name, + current_users.client_key AS solana_key, + current_users.blockchain_key AS blockchain_key, + current_users.client_key AS client_key, + current_users.is_server AS is_server + FROM solana_user_pda_current current_users + ) %s + """.formatted(alias); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql new file mode 100644 index 00000000..172ea9f3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v2.sql @@ -0,0 +1,178 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_access_servers_current ( + user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_url TEXT NOT NULL, + server_client_key TEXT NOT NULL, + user_record_number INTEGER NOT NULL, + user_updated_at_ms BIGINT NOT NULL, + server_record_number INTEGER NOT NULL, + server_updated_at_ms BIGINT NOT NULL, + refreshed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_login, server_login) +); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_user + ON user_access_servers_current(user_login); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_server + ON user_access_servers_current(server_login); + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(user_login) = LOWER(p_user_login); + + INSERT INTO user_access_servers_current ( + user_login, + server_login, + server_url, + server_client_key, + user_record_number, + user_updated_at_ms, + server_record_number, + server_updated_at_ms, + refreshed_at_ms + ) + SELECT + u.login, + s.login, + s.server_address, + s.client_key, + u.record_number, + u.updated_at_ms, + s.record_number, + s.updated_at_ms, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + JOIN solana_user_pda_current s + ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) + AND s.is_server = TRUE + AND btrim(COALESCE(s.server_address, '')) <> '' + WHERE LOWER(u.login) = LOWER(p_user_login) + ON CONFLICT (user_login, server_login) DO UPDATE SET + server_url = EXCLUDED.server_url, + server_client_key = EXCLUDED.server_client_key, + user_record_number = EXCLUDED.user_record_number, + user_updated_at_ms = EXCLUDED.user_updated_at_ms, + server_record_number = EXCLUDED.server_record_number, + server_updated_at_ms = EXCLUDED.server_updated_at_ms, + refreshed_at_ms = EXCLUDED.refreshed_at_ms; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(server_login) = LOWER(p_server_login); + + FOR affected_user IN + SELECT u.login + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login) + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_row +AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row(); + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_truncate +AFTER TRUNCATE ON solana_user_pda_current +FOR EACH STATEMENT +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); + +SELECT shine_refresh_user_access_servers_all(); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql new file mode 100644 index 00000000..97f55e62 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v3.sql @@ -0,0 +1,47 @@ +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor + ON signed_messages(target_login, created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower + ON signed_messages(LOWER(target_login), created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor + ON signed_messages(from_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor + ON signed_messages(to_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower + ON signed_messages(LOWER(from_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower + ON signed_messages(LOWER(to_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( + owner_login TEXT NOT NULL, + remote_server_login TEXT NOT NULL, + remote_server_url TEXT NOT NULL, + cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0, + cursor_message_key TEXT NOT NULL DEFAULT '', + bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE, + last_sync_at_ms BIGINT, + last_error TEXT, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_login, remote_server_login) +); + +CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner + ON dm_sync_peer_state(owner_login); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql new file mode 100644 index 00000000..88523668 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v4.sql @@ -0,0 +1,15 @@ +BEGIN; + +ALTER TABLE channel_names_state + ADD COLUMN IF NOT EXISTS ava_ar TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS ava_sha256 TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS ava_size BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS meta_updated_at_ms BIGINT NOT NULL DEFAULT 0; + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 4, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +COMMIT; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql new file mode 100644 index 00000000..e514899d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -0,0 +1,1095 @@ +-- SHiNE PostgreSQL runtime schema v1 +-- Дата: 2026-07-28 +-- +-- Назначение: +-- - поднять пустую PostgreSQL БД сервера SHiNE с нуля; +-- - включить таблицы модуля синхронизации Solana users; +-- - включить runtime-таблицы сервера без legacy-таблиц старого runtime; +-- - основной runtime DM storage = signed_messages +-- +-- Важно: +-- - источник истины по пользователям: solana_user_pda_current; +-- - runtime table blockchain_state остаётся как локальное серверное состояние chain, +-- но не мигрируется из старого локального runtime и не считается identity-слоем; +-- - триггеры переписаны под PostgreSQL и сохраняют текущую серверную логику. + +BEGIN; + +CREATE TABLE IF NOT EXISTS db_schema_version ( + id INTEGER PRIMARY KEY, + schema_version INTEGER NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +INSERT INTO db_schema_version (id, schema_version, updated_at_ms) +VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) +ON CONFLICT (id) DO UPDATE SET + schema_version = EXCLUDED.schema_version, + updated_at_ms = EXCLUDED.updated_at_ms; + +CREATE TABLE IF NOT EXISTS solana_sync_state ( + id INTEGER PRIMARY KEY, + status TEXT NOT NULL, + ready BOOLEAN NOT NULL, + last_poll_at_ms BIGINT, + last_successful_poll_at_ms BIGINT, + last_seen_signature TEXT, + last_seen_slot BIGINT, + last_relevant_signature TEXT, + last_relevant_slot BIGINT, + last_error TEXT, + economy_config_version INTEGER, + registration_fee_lamports BIGINT, + lamports_per_limit_step BIGINT, + start_bonus_limit BIGINT, + updated_at_ms BIGINT +); + +INSERT INTO solana_sync_state (id, status, ready) +VALUES (1, 'EMPTY', FALSE) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS solana_sync_tx_history ( + id BIGSERIAL PRIMARY KEY, + signature TEXT NOT NULL UNIQUE, + slot BIGINT NOT NULL, + block_time BIGINT, + tx_kind TEXT NOT NULL, + is_relevant BOOLEAN NOT NULL, + affected_pda_address TEXT, + affected_login TEXT, + raw_summary_json TEXT NOT NULL, + processed_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_slot + ON solana_sync_tx_history(slot); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_relevant + ON solana_sync_tx_history(is_relevant); + +CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login + ON solana_sync_tx_history(affected_login); + +CREATE TABLE IF NOT EXISTS solana_user_pda_current ( + pda_address TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + record_number INTEGER NOT NULL, + slot BIGINT NOT NULL, + last_tx_signature TEXT NOT NULL, + recovery_key TEXT NOT NULL, + root_key TEXT NOT NULL, + client_key TEXT NOT NULL, + blockchain_name TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + paid_limit_bytes BIGINT NOT NULL, + used_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash TEXT NOT NULL, + last_block_signature TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + is_server BOOLEAN NOT NULL, + address_format_type INTEGER NOT NULL, + address_format_version INTEGER NOT NULL, + server_address TEXT NOT NULL, + sync_servers_json TEXT NOT NULL, + access_servers_json TEXT NOT NULL, + sessions_mode INTEGER NOT NULL, + sessions_json TEXT NOT NULL, + trusted_count INTEGER NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + prev_record_hash TEXT NOT NULL, + record_signature TEXT NOT NULL, + raw_data_base64 TEXT NOT NULL, + first_seen_at_ms BIGINT NOT NULL, + last_synced_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot + ON solana_user_pda_current(slot); + +CREATE TABLE IF NOT EXISTS user_access_servers_current ( + user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, + server_url TEXT NOT NULL, + server_client_key TEXT NOT NULL, + user_record_number INTEGER NOT NULL, + user_updated_at_ms BIGINT NOT NULL, + server_record_number INTEGER NOT NULL, + server_updated_at_ms BIGINT NOT NULL, + refreshed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_login, server_login) +); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_user + ON user_access_servers_current(user_login); + +CREATE INDEX IF NOT EXISTS idx_user_access_servers_server + ON user_access_servers_current(server_login); + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +BEGIN + IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(user_login) = LOWER(p_user_login); + + INSERT INTO user_access_servers_current ( + user_login, + server_login, + server_url, + server_client_key, + user_record_number, + user_updated_at_ms, + server_record_number, + server_updated_at_ms, + refreshed_at_ms + ) + SELECT + u.login, + s.login, + s.server_address, + s.client_key, + u.record_number, + u.updated_at_ms, + s.record_number, + s.updated_at_ms, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + JOIN solana_user_pda_current s + ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) + AND s.is_server = TRUE + AND btrim(COALESCE(s.server_address, '')) <> '' + WHERE LOWER(u.login) = LOWER(p_user_login) + ON CONFLICT (user_login, server_login) DO UPDATE SET + server_url = EXCLUDED.server_url, + server_client_key = EXCLUDED.server_client_key, + user_record_number = EXCLUDED.user_record_number, + user_updated_at_ms = EXCLUDED.user_updated_at_ms, + server_record_number = EXCLUDED.server_record_number, + server_updated_at_ms = EXCLUDED.server_updated_at_ms, + refreshed_at_ms = EXCLUDED.refreshed_at_ms; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(server_login) = LOWER(p_server_login); + + FOR affected_user IN + SELECT u.login + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login) + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + affected_user RECORD; +BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_row +AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row(); + +DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current; +CREATE TRIGGER trg_refresh_user_access_servers_truncate +AFTER TRUNCATE ON solana_user_pda_current +FOR EACH STATEMENT +EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); + +CREATE TABLE IF NOT EXISTS solana_user_pda_history ( + id BIGSERIAL PRIMARY KEY, + tx_signature TEXT NOT NULL, + slot BIGINT NOT NULL, + block_time BIGINT, + pda_address TEXT NOT NULL, + login TEXT NOT NULL, + record_number INTEGER NOT NULL, + recovery_key TEXT NOT NULL, + root_key TEXT NOT NULL, + client_key TEXT NOT NULL, + blockchain_name TEXT NOT NULL, + blockchain_key TEXT NOT NULL, + paid_limit_bytes BIGINT NOT NULL, + used_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash TEXT NOT NULL, + last_block_signature TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + is_server BOOLEAN NOT NULL, + address_format_type INTEGER NOT NULL, + address_format_version INTEGER NOT NULL, + server_address TEXT NOT NULL, + sync_servers_json TEXT NOT NULL, + access_servers_json TEXT NOT NULL, + sessions_mode INTEGER NOT NULL, + sessions_json TEXT NOT NULL, + trusted_count INTEGER NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + prev_record_hash TEXT NOT NULL, + record_signature TEXT NOT NULL, + raw_data_base64 TEXT NOT NULL, + saved_at_ms BIGINT NOT NULL, + UNIQUE (pda_address, record_number) +); + +CREATE INDEX IF NOT EXISTS idx_user_pda_history_login + ON solana_user_pda_history(login); + +CREATE INDEX IF NOT EXISTS idx_user_pda_history_slot + ON solana_user_pda_history(slot); + +CREATE TABLE IF NOT EXISTS active_sessions ( + session_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + session_key TEXT NOT NULL, + storage_pwd TEXT NOT NULL, + session_created_at_ms BIGINT NOT NULL, + last_authirificated_at_ms BIGINT NOT NULL, + push_endpoint TEXT, + push_p256dh_key TEXT, + push_auth_key TEXT, + client_ip TEXT, + client_info_from_client TEXT, + client_info_from_request TEXT, + session_type INTEGER NOT NULL DEFAULT 1, + client_platform TEXT NOT NULL DEFAULT '', + user_language TEXT +); + +CREATE INDEX IF NOT EXISTS idx_active_sessions_login + ON active_sessions(login); + +CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login), + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms BIGINT NOT NULL DEFAULT 0, + blocked_until_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms BIGINT NOT NULL, + expires_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests(login, status, expires_at_ms); + +CREATE TABLE IF NOT EXISTS users_params ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + param TEXT NOT NULL, + time_ms BIGINT NOT NULL, + value TEXT NOT NULL, + client_key TEXT, + signature TEXT, + UNIQUE (login, param) +); + +CREATE INDEX IF NOT EXISTS idx_users_params_login + ON users_params(login); + +CREATE TABLE IF NOT EXISTS ip_geo_cache ( + ip TEXT PRIMARY KEY, + geo TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at + ON ip_geo_cache(updated_at_ms); + +CREATE TABLE IF NOT EXISTS test_free_avatar_uploads ( + login TEXT PRIMARY KEY REFERENCES solana_user_pda_current(login), + used_count INTEGER NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + last_tx_id TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated + ON test_free_avatar_uploads(updated_at_ms); + +CREATE TABLE IF NOT EXISTS sync_servers ( + login TEXT PRIMARY KEY, + server_address TEXT NOT NULL DEFAULT '', + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sync_servers_updated + ON sync_servers(updated_at_ms); + +CREATE TABLE IF NOT EXISTS blockchain_state ( + blockchain_name TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + blockchain_key TEXT NOT NULL, + size_limit BIGINT NOT NULL, + file_size_bytes BIGINT NOT NULL, + last_block_number INTEGER NOT NULL, + last_block_hash BYTEA, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_blockchain_state_login + ON blockchain_state(login); + +CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at + ON blockchain_state(updated_at_ms); + +CREATE TABLE IF NOT EXISTS blocks ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + bch_name TEXT NOT NULL REFERENCES blockchain_state(blockchain_name), + block_number INTEGER NOT NULL CHECK (block_number >= 0), + msg_type INTEGER NOT NULL, + msg_sub_type INTEGER NOT NULL, + block_bytes BYTEA NOT NULL, + to_login TEXT, + to_bch_name TEXT, + to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0), + to_block_hash BYTEA, + block_hash BYTEA NOT NULL, + block_signature BYTEA NOT NULL, + edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), + line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), + prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), + prev_line_hash BYTEA, + this_line_number INTEGER CHECK (this_line_number IS NULL OR this_line_number >= 0), + UNIQUE (bch_name, block_number) +); + +CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number + ON blocks (bch_name, block_number); + +CREATE INDEX IF NOT EXISTS idx_blocks_to_target + ON blocks (to_login, to_bch_name, to_block_number); + +CREATE INDEX IF NOT EXISTS idx_blocks_by_line + ON blocks (bch_name, line_code, this_line_number); + +CREATE TABLE IF NOT EXISTS connections_state ( + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + rel_type INTEGER NOT NULL, + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + UNIQUE (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_connections_state_login + ON connections_state(login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_to_login + ON connections_state(to_login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_pair + ON connections_state(login, to_login); + +CREATE INDEX IF NOT EXISTS idx_connections_state_target + ON connections_state(login, rel_type, to_bch_name, to_block_number); + +CREATE TABLE IF NOT EXISTS message_stats ( + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + likes_count INTEGER NOT NULL DEFAULT 0, + replies_count INTEGER NOT NULL DEFAULT 0, + edits_count INTEGER NOT NULL DEFAULT 0, + UNIQUE (to_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_message_stats_target + ON message_stats (to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_message_stats_login + ON message_stats (to_login); + +CREATE TABLE IF NOT EXISTS reactions_state ( + from_login TEXT NOT NULL, + from_bch_name TEXT NOT NULL, + reaction_type INTEGER NOT NULL, + to_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + last_sub_type INTEGER NOT NULL, + UNIQUE ( + from_login, + from_bch_name, + reaction_type, + to_login, + to_bch_name, + to_block_number, + to_block_hash + ) +); + +CREATE INDEX IF NOT EXISTS idx_reactions_state_target + ON reactions_state (to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_reactions_state_actor + ON reactions_state (from_login, from_bch_name, reaction_type); + +CREATE TABLE IF NOT EXISTS channel_names_state ( + slug TEXT NOT NULL, + display_name TEXT NOT NULL, + channel_description TEXT NOT NULL DEFAULT '', + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_type_code INTEGER NOT NULL DEFAULT 1, + channel_type_version INTEGER NOT NULL DEFAULT 1, + channel_root_block_number INTEGER NOT NULL, + channel_root_block_hash BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL, + ava_ar TEXT NOT NULL DEFAULT '', + ava_sha256 TEXT NOT NULL DEFAULT '', + ava_size BIGINT NOT NULL DEFAULT 0, + meta_updated_at_ms BIGINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug + ON channel_names_state (owner_bch_name, channel_type_code, slug); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target + ON channel_names_state (owner_bch_name, channel_root_block_number, channel_root_block_hash); + +CREATE INDEX IF NOT EXISTS idx_channel_names_state_owner + ON channel_names_state (owner_login, owner_bch_name); + +CREATE TABLE IF NOT EXISTS chat200_state ( + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL, + channel_root_block_hash BYTEA NOT NULL, + channel_name TEXT NOT NULL, + channel_type_version INTEGER NOT NULL, + chat_title TEXT NOT NULL DEFAULT '', + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_bch_name, channel_root_block_number) +); + +CREATE INDEX IF NOT EXISTS idx_chat200_state_owner + ON chat200_state (owner_login, owner_bch_name); + +CREATE TABLE IF NOT EXISTS chat200_members_state ( + owner_bch_name TEXT NOT NULL, + channel_root_block_number INTEGER NOT NULL, + member_login TEXT NOT NULL, + member_channel_name TEXT NOT NULL, + is_active INTEGER NOT NULL, + updated_at_ms BIGINT NOT NULL, + updated_by_block_number INTEGER NOT NULL, + PRIMARY KEY ( + owner_bch_name, + channel_root_block_number, + member_login, + member_channel_name + ) +); + +CREATE INDEX IF NOT EXISTS idx_chat200_members_owner + ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active); + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + token_id TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + session_id TEXT NOT NULL, + provider TEXT NOT NULL, + token TEXT NOT NULL, + platform TEXT, + user_agent TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login + ON user_push_tokens(login); + +CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session + ON user_push_tokens(login, session_id); + +CREATE TABLE IF NOT EXISTS signed_direct_message_replay ( + from_login TEXT NOT NULL, + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + UNIQUE (from_login, time_ms, nonce) +); + +CREATE INDEX IF NOT EXISTS idx_signed_dm_replay_created + ON signed_direct_message_replay(created_at_ms); + +CREATE TABLE IF NOT EXISTS signed_direct_messages_history ( + message_id TEXT PRIMARY KEY, + from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + target_mode INTEGER NOT NULL, + target_session_id TEXT, + message_type INTEGER NOT NULL, + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + raw_packet BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_signed_dm_history_to + ON signed_direct_messages_history(to_login, created_at_ms); + +CREATE TABLE IF NOT EXISTS signed_messages ( + message_key TEXT PRIMARY KEY, + base_key TEXT NOT NULL, + target_login TEXT NOT NULL, + from_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + to_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + time_ms BIGINT NOT NULL, + nonce BIGINT NOT NULL, + message_type INTEGER NOT NULL, + revision_time_ms BIGINT NOT NULL DEFAULT 0, + reencrypted_at_ms BIGINT NOT NULL DEFAULT 0, + raw_block BYTEA NOT NULL, + created_at_ms BIGINT NOT NULL, + source_api TEXT NOT NULL, + origin_session_id TEXT, + receipt_ref_base_key TEXT, + receipt_ref_type INTEGER, + read_at_ms BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_target + ON signed_messages(target_login, time_ms, created_at_ms); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_base + ON signed_messages(base_key, message_type); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor + ON signed_messages(target_login, created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower + ON signed_messages(LOWER(target_login), created_at_ms, message_key); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor + ON signed_messages(from_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor + ON signed_messages(to_login, created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower + ON signed_messages(LOWER(from_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower + ON signed_messages(LOWER(to_login), created_at_ms, message_key) + WHERE message_type IN (5, 6, 7, 8); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming + ON signed_messages(target_login, receipt_ref_base_key) + WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_outgoing + ON signed_messages(target_login, receipt_ref_base_key) + WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS signed_message_session_delivery ( + message_key TEXT NOT NULL REFERENCES signed_messages(message_key), + session_id TEXT NOT NULL, + delivered INTEGER NOT NULL DEFAULT 0, + delivered_at_ms BIGINT, + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (message_key, session_id) +); + +CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session + ON signed_message_session_delivery(session_id, delivered); + +CREATE TABLE IF NOT EXISTS dm_sync_peer_state ( + owner_login TEXT NOT NULL, + remote_server_login TEXT NOT NULL, + remote_server_url TEXT NOT NULL, + cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0, + cursor_message_key TEXT NOT NULL DEFAULT '', + bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE, + last_sync_at_ms BIGINT, + last_error TEXT, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (owner_login, remote_server_login) +); + +CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner + ON dm_sync_peer_state(owner_login); + +CREATE TABLE IF NOT EXISTS message_views_state ( + viewer_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BYTEA NOT NULL, + first_seen_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, to_bch_name, to_block_number, to_block_hash) +); + +CREATE INDEX IF NOT EXISTS idx_message_views_state_target + ON message_views_state(to_bch_name, to_block_number, to_block_hash); + +CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel + ON message_views_state(viewer_login, to_bch_name); + +CREATE OR REPLACE FUNCTION shine_login_from_blockchain_name(blockchain_name_in TEXT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + dash_pos INTEGER; + suffix TEXT; +BEGIN + IF blockchain_name_in IS NULL OR length(blockchain_name_in) <= 4 THEN + RETURN NULL; + END IF; + + dash_pos := length(blockchain_name_in) - 3; + IF substr(blockchain_name_in, dash_pos, 1) <> '-' THEN + RETURN NULL; + END IF; + + suffix := substr(blockchain_name_in, dash_pos + 1); + IF suffix !~ '^[0-9]{3}$' THEN + RETURN NULL; + END IF; + + RETURN substr(blockchain_name_in, 1, dash_pos - 1); +END; +$$; + +CREATE OR REPLACE FUNCTION shine_resolve_login(target_login_in TEXT, target_bch_name_in TEXT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; +BEGIN + IF target_login_in IS NOT NULL AND btrim(target_login_in) <> '' THEN + RETURN target_login_in; + END IF; + + IF target_bch_name_in IS NOT NULL AND btrim(target_bch_name_in) <> '' THEN + SELECT login + INTO resolved_login + FROM solana_user_pda_current + WHERE blockchain_name = target_bch_name_in + LIMIT 1; + + IF resolved_login IS NOT NULL AND btrim(resolved_login) <> '' THEN + RETURN resolved_login; + END IF; + END IF; + + RETURN shine_login_from_blockchain_name(target_bch_name_in); +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + prev_block RECORD; +BEGIN + IF NEW.line_code IS NULL + AND NEW.prev_line_number IS NULL + AND NEW.prev_line_hash IS NULL + AND NEW.this_line_number IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.msg_type NOT IN (0, 1, 3, 4) THEN + RAISE EXCEPTION 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE'; + END IF; + + IF NEW.line_code IS NULL + OR NEW.prev_line_number IS NULL + OR NEW.prev_line_hash IS NULL + OR NEW.this_line_number IS NULL THEN + RAISE EXCEPTION 'LINE_ERR_PARTIAL_FIELDS'; + END IF; + + SELECT * + INTO prev_block + FROM blocks + WHERE bch_name = NEW.bch_name + AND block_number = NEW.prev_line_number + LIMIT 1; + + IF NOT FOUND THEN + RAISE EXCEPTION 'LINE_ERR_NO_PREV'; + END IF; + + IF prev_block.block_hash IS DISTINCT FROM NEW.prev_line_hash THEN + RAISE EXCEPTION 'LINE_ERR_PREV_HASH_MISMATCH'; + END IF; + + IF NEW.prev_line_number <> NEW.line_code + AND prev_block.line_code IS DISTINCT FROM NEW.line_code THEN + RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; + END IF; + + IF NEW.prev_line_number = NEW.line_code THEN + IF NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END) THEN + RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; + END IF; + RETURN NEW; + END IF; + + IF prev_block.this_line_number IS NULL THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + + IF NEW.msg_type = 1 THEN + IF NEW.this_line_number <> prev_block.this_line_number + AND NEW.this_line_number <> prev_block.this_line_number + 1 THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + ELSE + IF NEW.this_line_number <> prev_block.this_line_number + 1 THEN + RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + resolved_login TEXT; + positive_rel_type INTEGER; +BEGIN + IF NEW.msg_type <> 3 THEN + RETURN NEW; + END IF; + + resolved_login := shine_resolve_login(NEW.to_login, NEW.to_bch_name); + + IF NEW.msg_sub_type IN (10, 20, 30, 40, 50, 52, 54, 60, 70, 74) THEN + IF resolved_login IS NULL OR NEW.to_bch_name IS NULL THEN + RETURN NEW; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = NEW.msg_sub_type + AND to_login = resolved_login; + + INSERT INTO connections_state ( + login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash + ) VALUES ( + NEW.login, + NEW.msg_sub_type, + resolved_login, + NEW.to_bch_name, + COALESCE(NEW.to_block_number, 0), + COALESCE(NEW.to_block_hash, decode(repeat('00', 32), 'hex')) + ); + + RETURN NEW; + END IF; + + positive_rel_type := CASE NEW.msg_sub_type + WHEN 11 THEN 10 + WHEN 21 THEN 20 + WHEN 31 THEN 30 + WHEN 41 THEN 40 + WHEN 51 THEN 50 + WHEN 53 THEN 52 + WHEN 55 THEN 54 + WHEN 61 THEN 60 + WHEN 71 THEN 70 + WHEN 75 THEN 74 + ELSE NULL + END; + + IF positive_rel_type IS NULL OR resolved_login IS NULL THEN + RETURN NEW; + END IF; + + DELETE FROM connections_state + WHERE login = NEW.login + AND rel_type = positive_rel_type + AND to_login = resolved_login; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + previous_sub_type INTEGER; +BEGIN + IF NEW.msg_type <> 2 OR NEW.msg_sub_type NOT IN (1, 2) THEN + RETURN NEW; + END IF; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + SELECT last_sub_type + INTO previous_sub_type + FROM reactions_state + WHERE from_login = NEW.login + AND from_bch_name = NEW.bch_name + AND reaction_type = 1 + AND to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash + LIMIT 1; + + IF NEW.msg_sub_type = 1 AND previous_sub_type IS DISTINCT FROM 1 THEN + UPDATE message_stats + SET likes_count = likes_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + ELSIF NEW.msg_sub_type = 2 AND previous_sub_type = 1 THEN + UPDATE message_stats + SET likes_count = GREATEST(0, likes_count - 1) + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + END IF; + + INSERT INTO reactions_state ( + from_login, from_bch_name, reaction_type, + to_login, to_bch_name, to_block_number, to_block_hash, + last_sub_type + ) VALUES ( + NEW.login, NEW.bch_name, 1, + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + NEW.msg_sub_type + ) + ON CONFLICT ( + from_login, from_bch_name, reaction_type, + to_login, to_bch_name, to_block_number, to_block_hash + ) DO UPDATE SET + last_sub_type = EXCLUDED.last_sub_type; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_message_stats_reply_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.msg_type <> 1 OR NEW.msg_sub_type <> 20 THEN + RETURN NEW; + END IF; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + UPDATE message_stats + SET replies_count = replies_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION shine_blocks_edit_apply_ai() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.msg_type <> 1 OR NEW.msg_sub_type NOT IN (11, 21) THEN + RETURN NEW; + END IF; + + UPDATE blocks + SET edited_by_block_number = NEW.block_number + WHERE login = NEW.login + AND bch_name = NEW.bch_name + AND block_number = NEW.to_block_number + AND NEW.to_block_number IS NOT NULL; + + IF NEW.to_login IS NULL OR NEW.to_bch_name IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO message_stats ( + to_login, to_bch_name, to_block_number, to_block_hash, + likes_count, replies_count, edits_count + ) VALUES ( + NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, + 0, 0, 0 + ) + ON CONFLICT (to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING; + + UPDATE message_stats + SET edits_count = edits_count + 1 + WHERE to_login = NEW.to_login + AND to_bch_name = NEW.to_bch_name + AND to_block_number = NEW.to_block_number + AND to_block_hash = NEW.to_block_hash; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi ON blocks; +CREATE TRIGGER trg_blocks_line_integrity_bi +BEFORE INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_line_integrity_bi(); + +DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks; +CREATE TRIGGER trg_blocks_connection_state_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_connection_state_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai ON blocks; +CREATE TRIGGER trg_blocks_message_stats_like_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_message_stats_like_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai ON blocks; +CREATE TRIGGER trg_blocks_message_stats_reply_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_message_stats_reply_ai(); + +DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai ON blocks; +CREATE TRIGGER trg_blocks_edit_apply_ai +AFTER INSERT ON blocks +FOR EACH ROW +EXECUTE FUNCTION shine_blocks_edit_apply_ai(); + +COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java index 742c851e..4877f45f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java @@ -1,7 +1,7 @@ package server.logic.ws_protocol.JSON; import org.eclipse.jetty.websocket.api.Session; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.ActiveSessionEntry; /** @@ -25,8 +25,8 @@ public class ConnectionContext { public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь - // Полный пользователь из БД (solana_users) - private SolanaUserEntry solanaUserEntry; + // Полный пользователь из runtime БД (current users / solana_user_pda_current) + private CurrentUserEntry currentUserEntry; // Активная сессия из БД (active_sessions) private ActiveSessionEntry activeSessionEntry; @@ -89,12 +89,12 @@ public class ConnectionContext { // --- SolanaUser / ActiveSession --- - public SolanaUserEntry getSolanaUser() { - return solanaUserEntry; + public CurrentUserEntry getCurrentUser() { + return currentUserEntry; } - public void setSolanaUser(SolanaUserEntry solanaUserEntry) { - this.solanaUserEntry = solanaUserEntry; + public void setCurrentUser(CurrentUserEntry currentUserEntry) { + this.currentUserEntry = currentUserEntry; } public ActiveSessionEntry getActiveSession() { @@ -108,7 +108,7 @@ public class ConnectionContext { // --- Удобный геттер для логина --- public String getLogin() { - return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; + return currentUserEntry != null ? currentUserEntry.getLogin() : null; } // --- sessionId --- @@ -176,7 +176,7 @@ public class ConnectionContext { } public void reset() { - solanaUserEntry = null; + currentUserEntry = null; activeSessionEntry = null; sessionId = null; @@ -198,4 +198,4 @@ public class ConnectionContext { ", authenticationStatus=" + authenticationStatus + '}'; } -} \ No newline at end of file +} 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..787efd20 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 @@ -43,9 +43,6 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; - import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler; @@ -91,6 +88,8 @@ 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_DmSyncBatch_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 +101,8 @@ 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_DmSyncBatch_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; @@ -140,7 +141,6 @@ public final class JsonHandlerRegistry { } private static final Map HANDLERS = Map.ofEntries( - Map.entry("AddUser", new Net_AddUser_Handler()), Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()), Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()), @@ -200,6 +200,8 @@ 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("DmSyncBatch", new Net_DmSyncBatch_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()), @@ -220,7 +222,6 @@ public final class JsonHandlerRegistry { ); private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("AddUser", Net_AddUser_Request.class), Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class), Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class), @@ -280,6 +281,8 @@ 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("DmSyncBatch", Net_DmSyncBatch_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_AuthChallenge_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java index 3b956b8f..1a3aed35 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Re import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.CurrentUserEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +26,7 @@ import java.security.SecureRandom; * * Что делает: * 1) Проверяет login. - * 2) Находит пользователя (solana_users). + * 2) Находит пользователя в текущем PDA snapshot. * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. */ @@ -60,33 +60,17 @@ public class Net_AuthChallenge_Handler implements JsonMessageHandler { ); } - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - try { - solanaUserEntry = SolanaUserPdaImportService.findOrImportByLogin(login); - if (solanaUserEntry != null) { - log.info("AuthChallenge: пользователь {} импортирован из Solana PDA", solanaUserEntry.getLogin()); - } - } catch (Exception e) { - log.error("AuthChallenge: ошибка lazy-import пользователя {} из Solana", login, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "SOLANA_IMPORT_FAILED", - "Ошибка проверки пользователя в Solana" - ); - } - } + CurrentUserEntry solanaUserEntry = CurrentUsersDAO.getInstance().getByLogin(login); if (solanaUserEntry == null) { return NetExceptionResponseFactory.error( - req, + req, WireCodes.Status.UNVERIFIED, "UNKNOWN_USER", "Пользователь с таким логином не найден" ); } - ctx.setSolanaUser(solanaUserEntry); + ctx.setCurrentUser(solanaUserEntry); ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); byte[] buf = new byte[32]; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java index e5ceb22c..cd29954f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CloseActiveSession_Handler.java @@ -18,7 +18,7 @@ import server.logic.ws_protocol.WireCodes; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; @@ -42,7 +42,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { + if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, @@ -51,7 +51,7 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { ); } - SolanaUserEntry user = ctx.getSolanaUser(); + CurrentUserEntry user = ctx.getCurrentUser(); String currentLogin = user.getLogin(); String targetSessionId = req.getSessionId(); @@ -152,4 +152,4 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler { ); } } -} \ No newline at end of file +} 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..4ca98505 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,15 +10,14 @@ 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; import server.ws.WsConnectionUtils; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.ClientInfoService; import shine.geo.GeoLookupService; import utils.crypto.Ed25519Util; @@ -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 @@ -61,7 +58,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; if (ctx == null - || ctx.getSolanaUser() == null + || ctx.getCurrentUser() == null || ctx.getAuthNonce() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { @@ -75,7 +72,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - SolanaUserEntry userFromContext = ctx.getSolanaUser(); + CurrentUserEntry userFromContext = ctx.getCurrentUser(); String loginFromContext = userFromContext.getLogin(); String loginFromReq = req.getLogin(); if (loginFromReq == null || loginFromReq.isBlank()) { @@ -100,9 +97,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - SolanaUserEntry user; + CurrentUserEntry user; try { - user = SolanaUsersDAO.getInstance().getByLogin(loginFromContext); + user = CurrentUsersDAO.getInstance().getByLogin(loginFromContext); } catch (SQLException e) { Net_Response err = NetExceptionResponseFactory.error( req, @@ -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_ListSessions_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java index e53a24db..8f62a4d7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListSessions_Handler.java @@ -14,7 +14,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.GeoLookupService; import java.sql.SQLException; @@ -36,7 +36,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { + if (ctx == null || ctx.getCurrentUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { return NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, @@ -45,7 +45,7 @@ public class Net_ListSessions_Handler implements JsonMessageHandler { ); } - SolanaUserEntry user = ctx.getSolanaUser(); + CurrentUserEntry user = ctx.getCurrentUser(); String currentLogin = user.getLogin(); List sessions; 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..c2dea576 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,14 +10,13 @@ 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; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.geo.ClientInfoService; import shine.geo.GeoLookupService; import utils.crypto.Ed25519Util; @@ -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; @@ -191,9 +188,9 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ctx.setSessionLoginNonceExpiresAtMs(0); // подтягиваем пользователя - SolanaUserEntry user; + CurrentUserEntry user; try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); + user = CurrentUsersDAO.getInstance().getByLogin(session.getLogin()); } catch (SQLException e) { return NetExceptionResponseFactory.error( req, @@ -297,12 +294,11 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { // ctx ctx.setActiveSession(session); - ctx.setSolanaUser(user); + ctx.setCurrentUser(user); ctx.setSessionId(sessionId); 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/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index bf08799a..ed6254e8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -15,10 +15,10 @@ import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.EspPairingRequestsDAO; import shine.db.dao.EspPairingSettingsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.EspPairingRequestEntry; import shine.db.entities.EspPairingSettingsEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.List; @@ -60,7 +60,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PASSWORD_HASH_FORMAT", "passwordHash должен быть пустым или иметь формат sha256$<64 hex>"); } - SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(login); if (user == null) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 60065c90..4f58614a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -4,9 +4,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserCreateDAO; -import shine.db.entities.SolanaUserEntry; import utils.config.SolanaProgramsConfig; import java.net.URI; @@ -22,8 +19,7 @@ import java.util.Locale; import java.util.Map; /** - * Lazy-import пользователя из Solana PDA в локальную БД сервера. - * Используется при входе, если в solana_users нет записи по login. + * Чтение пользовательских и серверных PDA из Solana. */ public final class SolanaUserPdaImportService { @@ -34,34 +30,6 @@ public final class SolanaUserPdaImportService { private SolanaUserPdaImportService() {} - public static SolanaUserEntry findOrImportByLogin(String loginRaw) throws Exception { - String login = normalizeLogin(loginRaw); - if (login == null) return null; - - SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - SolanaUserEntry existing = usersDao.getByLogin(login); - if (existing != null) return existing; - - ParsedSolanaUser parsed = fetchFromSolana(login); - if (parsed == null) return null; - - long now = System.currentTimeMillis(); - long sizeLimit = parsed.paidLimitBytes > 0 ? parsed.paidLimitBytes : 100_000L; - boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain( - parsed.login, - parsed.blockchainName, - parsed.clientKeyB64, // в текущей модели solanaKey = clientKey - parsed.blockchainKeyB64, - parsed.clientKeyB64, - sizeLimit, - now - ); - if (!inserted) { - return usersDao.getByLogin(login); - } - return usersDao.getByLogin(login); - } - public static SessionTypeCheckResult checkSessionTypeAgainstPda(String loginRaw, String sessionKeyApi, int requestedSessionType) throws Exception { String login = normalizeLogin(loginRaw); if (login == null) return SessionTypeCheckResult.noRecord(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md index 9e6ab857..de8c9143 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md @@ -33,46 +33,10 @@ } } -1. Добавление пользователя (AddUser) +1. Пользователь уже существует в актуальном PDA snapshot -Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey. - -📤 Запрос клиента -{ -"op": "AddUser", -"requestId": "req-1", -"payload": { -"login": "anya4", -"loginId": 100212, -"bchId": 4222, -"solanaKey": "BASE64_LOGIN_KEY", -"clientKey": "BASE64_DEVICE_KEY", -"bchLimit": 1000000 -} -} - -🖥 Действия сервера - -Проверяет корректность данных. - -Вставляет запись в таблицу: - -CREATE TABLE solana_users ( -login TEXT NOT NULL, -loginId INTEGER PRIMARY KEY, -bchId INTEGER NOT NULL, -solanaKey TEXT, -clientKey TEXT, -bchLimit INTEGER -); - -📥 Ответ -{ -"op": "AddUser", -"requestId": "req-1", -"status": 200, -"payload": { "ok": true } -} +Назначение: перед началом auth-flow сервер уже должен видеть пользователя +в актуальном `solana_user_pda_current`. 2. Шаг 1 — запрос временного пароля сессии AuthChallenge @@ -173,4 +137,4 @@ pushAuthKey TEXT } 📘 Итоговая схема создания сессии -AddUser → AuthChallenge → CreateAuthSession → Session Created +PDA snapshot ready → AuthChallenge → CreateAuthSession → Session Created 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 298dc245..5b3e6d3f 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 @@ -21,6 +21,7 @@ import server.sync.BlockchainResyncGuard; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; +import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.WireCodes; import server.sync.AddBlockSyncService; @@ -277,20 +278,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } ChannelNameStateEntry channelNameStateEntry = null; + ChannelNameStateEntry channelMetaUpdateEntry = null; Chat200CreateSeed chat200CreateSeed = null; if (block.body instanceof CreateChannelBody createChannelBody) { + int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); + int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); final String normalizedName; final String slug; try { - normalizedName = ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName); + normalizedName = channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PERSONAL & 0xFFFF) + ? ChannelNameRules.requireValidDisplayNameForCreate(createChannelBody.channelName) + : ChannelNameRules.requireValidPublicDisplayNameForCreate(createChannelBody.channelName); slug = ChannelNameRules.toCanonicalSlug(normalizedName); } catch (IllegalArgumentException badName) { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex); } - int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); - int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); - try { if (channelNameStateDAO.existsByOwnerTypeAndSlug(blockchainName, channelTypeCode, slug)) { return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex); @@ -301,14 +304,36 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); } + String displayName = normalizedName; + String channelDescription = createChannelBody.channelDescription == null + ? "" + : ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription); + String avaAr = ""; + String avaSha256 = ""; + long avaSize = 0L; + long metaUpdatedAtMs = 0L; + if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) { + try { + ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, normalizedName); + displayName = createMeta.title(); + channelDescription = createMeta.description(); + avaAr = createMeta.avaAr(); + avaSha256 = createMeta.avaSha256(); + avaSize = createMeta.avaSize(); + metaUpdatedAtMs = block.timestamp * 1000L; + } catch (IllegalArgumentException e) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex); + } + } + channelNameStateEntry = new ChannelNameStateEntry(); channelNameStateEntry.setSlug(slug); - channelNameStateEntry.setDisplayName(normalizedName); - channelNameStateEntry.setChannelDescription( - createChannelBody.channelDescription == null - ? "" - : ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription) - ); + channelNameStateEntry.setDisplayName(displayName); + channelNameStateEntry.setChannelDescription(channelDescription); + channelNameStateEntry.setAvaAr(avaAr); + channelNameStateEntry.setAvaSha256(avaSha256); + channelNameStateEntry.setAvaSize(avaSize); + channelNameStateEntry.setMetaUpdatedAtMs(metaUpdatedAtMs); channelNameStateEntry.setOwnerLogin(login); channelNameStateEntry.setOwnerBlockchainName(blockchainName); channelNameStateEntry.setChannelTypeCode(channelTypeCode); @@ -328,6 +353,39 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription(); chat200CreateSeed.updatedAtMs = block.timestamp * 1000L; } + } else if ((block.type & 0xFFFF) == 1 + && (block.subType & 0xFFFF) == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF) + && block.body instanceof TextLineBody metaBody) { + int lineCode = metaBody.lineCode(); + if (lineCode <= 0) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_meta_line", serverLastNum, serverLastHashHex); + } + ExistingChannelState existingChannel; + try { + existingChannel = loadExistingChannelState(blockchainName, lineCode); + } catch (Exception e) { + log.error("AddBlock: channel_meta_lookup_failed (login={}, blockchainName={}, lineCode={})", + login, blockchainName, lineCode, e); + return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); + } + if (existingChannel == null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_not_found", serverLastNum, serverLastHashHex); + } + ChannelMetaTextParser.Parsed parsedMeta; + try { + parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, existingChannel.slug); + } catch (IllegalArgumentException e) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, e.getMessage(), serverLastNum, serverLastHashHex); + } + channelMetaUpdateEntry = new ChannelNameStateEntry(); + channelMetaUpdateEntry.setOwnerBlockchainName(blockchainName); + channelMetaUpdateEntry.setChannelRootBlockNumber(lineCode); + channelMetaUpdateEntry.setDisplayName(parsedMeta.title()); + channelMetaUpdateEntry.setChannelDescription(parsedMeta.description()); + channelMetaUpdateEntry.setAvaAr(parsedMeta.avaAr()); + channelMetaUpdateEntry.setAvaSha256(parsedMeta.avaSha256()); + channelMetaUpdateEntry.setAvaSize(parsedMeta.avaSize()); + channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } // 4.2) запрет дырок: blockNumber строго last+1 @@ -484,7 +542,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ); } - dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry); + dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry); if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); @@ -543,8 +601,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { long updatedAtMs; } + private static final class ExistingChannelState { + String slug; + } + + private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception { + try (Connection c = shine.db.DbController.getInstance().getConnection(); + PreparedStatement ps = c.prepareStatement(""" + SELECT slug + FROM channel_names_state + WHERE owner_bch_name = ? AND channel_root_block_number = ? + LIMIT 1 + """)) { + ps.setString(1, ownerBch); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + ExistingChannelState state = new ExistingChannelState(); + state.slug = rs.getString("slug"); + return state; + } + } + } + private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" INSERT INTO chat200_state ( owner_login, owner_bch_name, channel_root_block_number, channel_root_block_hash, @@ -586,7 +667,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { long updatedAtMs = block.timestamp * 1000L; if ("desc".equals(cmd.command)) { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" UPDATE chat200_state SET chat_title = ?, updated_at_ms = ? @@ -606,7 +687,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String memberChannel = cmd.arg2 == null ? "" : cmd.arg2.trim(); if (memberLogin.isBlank() || memberChannel.isBlank()) return; - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" INSERT INTO chat200_members_state ( owner_bch_name, channel_root_block_number, member_login, member_channel_name, @@ -630,7 +711,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } private boolean isChat200Channel(String ownerBch, int rootBlockNumber) throws Exception { - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection(); + try (Connection c = shine.db.DbController.getInstance().getConnection(); PreparedStatement ps = c.prepareStatement(""" SELECT channel_type_code FROM channel_names_state diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 6a5ab99b..7c2171b9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -58,7 +58,8 @@ public final class BlockchainWriter { BlockchainStateEntry st, BlockEntry be, UserParamEntry userParamEntry, - ChannelNameStateEntry channelNameStateEntry) throws SQLException { + ChannelNameStateEntry channelNameStateEntry, + ChannelNameStateEntry channelMetaUpdateEntry) throws SQLException { long nowMs = System.currentTimeMillis(); byte[] blockBytes = block.toBytes(); @@ -68,7 +69,7 @@ public final class BlockchainWriter { prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); boolean committed = false; - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { // 1) insert block @@ -91,6 +92,10 @@ public final class BlockchainWriter { channelNameStateDAO.insert(c, channelNameStateEntry); } + if (channelMetaUpdateEntry != null) { + channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); + } + c.commit(); committed = true; } catch (Exception e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java new file mode 100644 index 00000000..df48579e --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelMetaTextParser.java @@ -0,0 +1,112 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import java.util.HashMap; +import java.util.Map; + +public final class ChannelMetaTextParser { + public static final int MAX_TITLE_CHARS = 50; + public static final int MAX_DESCRIPTION_CHARS = 250; + + private ChannelMetaTextParser() {} + + public static Parsed parseStrict(String rawText, String fallbackTitle) { + String text = String.valueOf(rawText == null ? "" : rawText); + String title = ""; + String avaAr = ""; + String avaSha256 = ""; + long avaSize = 0L; + boolean seenTitle = false; + boolean seenAvatar = false; + + int offset = 0; + while (text.startsWith("', offset); + if (end < 0) throw new IllegalArgumentException("bad_channel_meta_tag"); + String body = text.substring(offset + " MAX_DESCRIPTION_CHARS) { + throw new IllegalArgumentException("channel_meta_description_too_long"); + } + + String cleanTitle = title.isBlank() ? String.valueOf(fallbackTitle == null ? "" : fallbackTitle).trim() : title.trim(); + validateTitle(cleanTitle); + + return new Parsed(cleanTitle, description, avaAr, avaSha256, avaSize); + } + + private static String parseTitleTag(String body) { + String prefix = "title;v=1;"; + if (!body.startsWith(prefix)) throw new IllegalArgumentException("bad_channel_meta_title"); + String title = body.substring(prefix.length()).trim(); + validateTitle(title); + return title; + } + + private static Avatar parseAvatarTag(String body) { + Map fields = parseFields(body.substring("avatar;".length())); + if (!"1".equals(fields.get("v"))) throw new IllegalArgumentException("bad_channel_meta_avatar_version"); + String ar = String.valueOf(fields.getOrDefault("ar", "")).trim(); + String sha256 = String.valueOf(fields.getOrDefault("sha256", "")).trim().toLowerCase(); + String sizeRaw = String.valueOf(fields.getOrDefault("size", "")).trim(); + if (!ar.matches("^[A-Za-z0-9_-]{43}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_ar"); + if (!sha256.matches("^[0-9a-f]{64}$")) throw new IllegalArgumentException("bad_channel_meta_avatar_sha256"); + long size; + try { + size = Long.parseLong(sizeRaw); + } catch (Exception e) { + throw new IllegalArgumentException("bad_channel_meta_avatar_size"); + } + if (size <= 0L) throw new IllegalArgumentException("bad_channel_meta_avatar_size"); + return new Avatar(ar, sha256, size); + } + + private static Map parseFields(String raw) { + Map out = new HashMap<>(); + for (String part : String.valueOf(raw == null ? "" : raw).split(";")) { + int eq = part.indexOf('='); + if (eq <= 0) continue; + String key = part.substring(0, eq).trim(); + String value = part.substring(eq + 1).trim(); + if (!key.isEmpty()) out.put(key, value); + } + return out; + } + + private static void validateTitle(String title) { + String value = String.valueOf(title == null ? "" : title); + if (value.indexOf('<') >= 0 || value.indexOf('>') >= 0 || value.indexOf(';') >= 0 + || value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0 + || value.indexOf('\0') >= 0) { + throw new IllegalArgumentException("bad_channel_meta_title_chars"); + } + if (countCodePoints(value) > MAX_TITLE_CHARS) { + throw new IllegalArgumentException("channel_meta_title_too_long"); + } + } + + private static int countCodePoints(String value) { + return String.valueOf(value == null ? "" : value).codePointCount(0, String.valueOf(value == null ? "" : value).length()); + } + + private record Avatar(String ar, String sha256, long size) {} + + public record Parsed(String title, String description, String avaAr, String avaSha256, long avaSize) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java index 4b61c9d6..84576500 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java @@ -1,10 +1,12 @@ package server.logic.ws_protocol.JSON.handlers.channels; import blockchain.BchBlockEntry; +import blockchain.MsgSubType; import blockchain.body.CreateChannelBody; +import blockchain.body.TextLineBody; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.channels.ChannelNameRules; import shine.db.dao.ChannelNameStateDAO; import shine.db.entities.ChannelNameStateEntry; @@ -20,6 +22,7 @@ import java.util.Map; public final class ChannelNamesStateBootstrapper { private static final Logger log = LoggerFactory.getLogger(ChannelNamesStateBootstrapper.class); private static final int MSG_TYPE_TECH = 0; + private static final int MSG_TYPE_TEXT = 1; private static final int MSG_SUB_TYPE_CREATE_CHANNEL = 1; private static volatile boolean bootstrapped; @@ -48,7 +51,7 @@ public final class ChannelNamesStateBootstrapper { ORDER BY bch_name, block_number """; - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -74,17 +77,30 @@ public final class ChannelNamesStateBootstrapper { continue; } - final String displayName; + String displayName; final String slug; - final String channelDescription; + String channelDescription; final int channelTypeCode; final int channelTypeVersion; + String avaAr = ""; + String avaSha256 = ""; + long avaSize = 0L; + long metaUpdatedAtMs = 0L; try { displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName); slug = ChannelNameRules.toCanonicalSlug(displayName); channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription); channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); + if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) { + ChannelMetaTextParser.Parsed createMeta = ChannelMetaTextParser.parseStrict(channelDescription, displayName); + displayName = createMeta.title(); + channelDescription = createMeta.description(); + avaAr = createMeta.avaAr(); + avaSha256 = createMeta.avaSha256(); + avaSize = createMeta.avaSize(); + metaUpdatedAtMs = parsed.timestamp * 1000L; + } } catch (Exception badName) { skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)"); continue; @@ -102,6 +118,10 @@ public final class ChannelNamesStateBootstrapper { entry.setSlug(slug); entry.setDisplayName(displayName); entry.setChannelDescription(channelDescription == null ? "" : channelDescription); + entry.setAvaAr(avaAr); + entry.setAvaSha256(avaSha256); + entry.setAvaSize(avaSize); + entry.setMetaUpdatedAtMs(metaUpdatedAtMs); entry.setOwnerLogin(ownerLogin); entry.setOwnerBlockchainName(ownerBch); entry.setChannelTypeCode(channelTypeCode); @@ -116,6 +136,8 @@ public final class ChannelNamesStateBootstrapper { dao.clearAll(c); dao.insertAll(c, entries); + + applyMetaUpdates(c, dao, entries, skipped); c.commit(); log.info("channel_names_state bootstrapped: {}", entries.size()); if (!conflicts.isEmpty()) { @@ -148,4 +170,53 @@ public final class ChannelNamesStateBootstrapper { throw new RuntimeException("Failed to bootstrap channel_names_state", e); } } + + private static void applyMetaUpdates(Connection c, + ChannelNameStateDAO dao, + List entries, + List skipped) throws Exception { + Map fallbackTitles = new LinkedHashMap<>(); + for (ChannelNameStateEntry entry : entries) { + fallbackTitles.put(entry.getOwnerBlockchainName() + "#" + entry.getChannelRootBlockNumber(), entry.getSlug()); + } + + String sql = """ + SELECT bch_name, block_number, block_bytes + FROM blocks + WHERE msg_type = ? AND msg_sub_type = ? + ORDER BY bch_name, block_number + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, MSG_TYPE_TEXT); + ps.setInt(2, MsgSubType.TEXT_CHANNEL_META); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String ownerBch = rs.getString("bch_name"); + int blockNumber = rs.getInt("block_number"); + byte[] blockBytes = rs.getBytes("block_bytes"); + try { + BchBlockEntry parsed = new BchBlockEntry(blockBytes); + if (!(parsed.body instanceof TextLineBody metaBody)) continue; + int lineCode = metaBody.lineCode(); + String fallbackTitle = fallbackTitles.get(ownerBch + "#" + lineCode); + if (fallbackTitle == null) continue; + ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(metaBody.message, fallbackTitle); + + ChannelNameStateEntry update = new ChannelNameStateEntry(); + update.setOwnerBlockchainName(ownerBch); + update.setChannelRootBlockNumber(lineCode); + update.setDisplayName(parsedMeta.title()); + update.setChannelDescription(parsedMeta.description()); + update.setAvaAr(parsedMeta.avaAr()); + update.setAvaSha256(parsedMeta.avaSha256()); + update.setAvaSize(parsedMeta.avaSize()); + update.setMetaUpdatedAtMs(parsed.timestamp * 1000L); + dao.updateMeta(c, update); + } catch (Exception e) { + skipped.add(ownerBch + "#" + blockNumber + " (invalid_meta)"); + } + } + } + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index b1e3fc80..35fbfc29 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -8,6 +8,7 @@ import blockchain.body.TextLineBody; import blockchain.body.TextReplyBody; import shine.db.channels.ChannelNameRules; import shine.db.MsgSubType; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -22,15 +23,14 @@ final class ChannelsReadSupport { static final int MSG_TYPE_REACTION = 2; static final int MSG_TYPE_TECH = 0; static final String STORIES_CHANNEL_NAME = "stories"; - static final String COMMAND_PREFIX = "/."; - static final String COMMAND_DESC = "desc"; static final String COMMAND_ADD = "add"; static final String COMMAND_REMOVE = "remove"; private ChannelsReadSupport() {} static String canonicalLogin(Connection c, String anyCaseLogin) throws SQLException { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = ("SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, anyCaseLogin); try (ResultSet rs = ps.executeQuery()) { @@ -45,11 +45,40 @@ final class ChannelsReadSupport { if (rootNumber == 0) { meta.channelName = STORIES_CHANNEL_NAME; - meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0); + meta.displayName = STORIES_CHANNEL_NAME; + meta.channelDescription = ""; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF; return meta; } + String stateSql = """ + SELECT slug, display_name, channel_description, channel_type_code, channel_type_version, + ava_ar, ava_sha256, ava_size, meta_updated_at_ms + FROM channel_names_state + WHERE owner_bch_name = ? AND channel_root_block_number = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(stateSql)) { + ps.setString(1, ownerBch); + ps.setInt(2, rootNumber); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + meta.channelName = rs.getString("slug"); + meta.displayName = rs.getString("display_name"); + meta.channelDescription = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description")); + meta.channelTypeCode = rs.getInt("channel_type_code"); + meta.channelTypeVersion = rs.getInt("channel_type_version"); + meta.avaAr = String.valueOf(rs.getString("ava_ar") == null ? "" : rs.getString("ava_ar")); + meta.avaSha256 = String.valueOf(rs.getString("ava_sha256") == null ? "" : rs.getString("ava_sha256")); + meta.avaSize = rs.getLong("ava_size"); + meta.metaUpdatedAtMs = rs.getLong("meta_updated_at_ms"); + return meta; + } + } + } catch (SQLException ignored) { + // fallback ниже для окружений до миграции. + } + String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); @@ -66,21 +95,29 @@ final class ChannelsReadSupport { BodyRecord body = e.body; if (body instanceof CreateChannelBody ccb) { meta.channelName = ccb.channelName; + meta.displayName = ccb.channelName; meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription; meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode); meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion); + if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) { + ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(meta.channelDescription, meta.channelName); + meta.displayName = parsedMeta.title(); + meta.channelDescription = parsedMeta.description(); + meta.avaAr = parsedMeta.avaAr(); + meta.avaSha256 = parsedMeta.avaSha256(); + meta.avaSize = parsedMeta.avaSize(); + meta.metaUpdatedAtMs = e.timestamp * 1000L; + } } else { meta.channelName = null; + meta.displayName = null; meta.channelDescription = ""; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF; } - String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber); - if (updatedDescription != null) { - meta.channelDescription = updatedDescription; - } return meta; } catch (Exception ignored) { meta.channelName = null; + meta.displayName = null; meta.channelDescription = ""; meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF; return meta; @@ -104,7 +141,7 @@ final class ChannelsReadSupport { static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { String sql = """ - SELECT login,bch_name,block_number,block_hash,block_bytes + SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? ORDER BY block_number DESC @@ -131,7 +168,7 @@ final class ChannelsReadSupport { static PostBlock loadLastVersion(Connection c, String ownerBch, int originalBlockNumber, byte[] originalHash) throws SQLException { String sql = """ - SELECT login,bch_name,block_number,block_hash,block_bytes + SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND to_bch_name=? AND to_block_number=? AND to_block_hash=? @@ -268,8 +305,7 @@ final class ChannelsReadSupport { static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException { if (rootNumber == 0) { - String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0); - return fromCommand == null ? "" : fromCommand; + return ""; } // Preferred source: persisted state (fast path, works for CreateChannelBody v2). @@ -285,8 +321,7 @@ final class ChannelsReadSupport { try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description")); - String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber); - return fromCommand == null ? saved : fromCommand; + return saved; } } } catch (SQLException ignored) { @@ -304,8 +339,6 @@ final class ChannelsReadSupport { BchBlockEntry e = new BchBlockEntry(bytes); BodyRecord body = e.body; if (body instanceof CreateChannelBody ccb) { - String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber); - if (fromCommand != null) return fromCommand; return ccb.channelDescription == null ? "" : ccb.channelDescription; } return ""; @@ -361,45 +394,97 @@ final class ChannelsReadSupport { return meta.channelTypeVersion; } - static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException { - String sql = """ - SELECT block_bytes + static List channelMetaEvents(Connection c, String ownerBch, int lineCode) throws SQLException { + List out = new ArrayList<>(); + if (lineCode <= 0) return out; + ChannelMeta base = detectChannelMeta(c, ownerBch, lineCode); + String rootSql = """ + SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? - ORDER BY block_number DESC - LIMIT 300 + WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND block_number=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(rootSql)) { + ps.setString(1, ownerBch); + ps.setInt(2, MSG_TYPE_TECH); + ps.setInt(3, 1); + ps.setInt(4, lineCode); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + MetaEvent event = new MetaEvent(); + event.kind = "created"; + event.login = rs.getString("login"); + event.bchName = rs.getString("bch_name"); + event.blockNumber = rs.getInt("block_number"); + event.blockHash = rs.getBytes("block_hash"); + event.lineStep = null; + try { + BchBlockEntry entry = new BchBlockEntry(rs.getBytes("block_bytes")); + event.createdAtMs = entry.timestamp * 1000L; + if (entry.body instanceof CreateChannelBody ccb) { + event.title = ccb.channelName; + event.description = ccb.channelDescription == null ? "" : ccb.channelDescription; + if (Short.toUnsignedInt(ccb.channelTypeCode) == (CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF)) { + ChannelMetaTextParser.Parsed parsedMeta = ChannelMetaTextParser.parseStrict(event.description, ccb.channelName); + event.title = parsedMeta.title(); + event.description = parsedMeta.description(); + event.avaAr = parsedMeta.avaAr(); + event.avaSha256 = parsedMeta.avaSha256(); + event.avaSize = parsedMeta.avaSize(); + } + } + } catch (Exception ignored) { + event.createdAtMs = 0L; + } + if (event.title == null || event.title.isBlank()) { + event.title = base.displayName == null || base.displayName.isBlank() ? base.channelName : base.displayName; + } + if (event.description == null) event.description = ""; + if (event.avaAr == null) event.avaAr = ""; + if (event.avaSha256 == null) event.avaSha256 = ""; + out.add(event); + } + } + } + + String sql = """ + SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number + FROM blocks + WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=? + ORDER BY block_number ASC """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); - ps.setInt(3, MsgSubType.TEXT_POST); - ps.setInt(4, MsgSubType.TEXT_REPOST); - ps.setInt(5, lineCode); + ps.setInt(3, MsgSubType.TEXT_CHANNEL_META); + ps.setInt(4, lineCode); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { - TextInfo info = parseTextAndTime(rs.getBytes("block_bytes")); - CommandInfo commandInfo = parseCommandText(info.text); - if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) { - return commandInfo.arg; + byte[] bytes = rs.getBytes("block_bytes"); + TextInfo info = parseTextAndTime(bytes); + try { + ChannelMetaTextParser.Parsed parsed = ChannelMetaTextParser.parseStrict(info.text, base.channelName); + MetaEvent event = new MetaEvent(); + event.kind = "profile_updated"; + event.login = rs.getString("login"); + event.bchName = rs.getString("bch_name"); + event.blockNumber = rs.getInt("block_number"); + event.blockHash = rs.getBytes("block_hash"); + event.lineStep = (Integer) rs.getObject("this_line_number"); + event.createdAtMs = info.createdAtMs; + event.title = parsed.title(); + event.description = parsed.description(); + event.avaAr = parsed.avaAr(); + event.avaSha256 = parsed.avaSha256(); + event.avaSize = parsed.avaSize(); + out.add(event); + } catch (Exception ignored) { + // Невалидный meta-блок не участвует в системной истории UI. } } } } - return null; - } - - static CommandInfo parseCommandText(String text) { - String value = String.valueOf(text == null ? "" : text).trim(); - if (!value.startsWith(COMMAND_PREFIX)) return null; - - String raw = value.substring(COMMAND_PREFIX.length()).trim(); - if (raw.isEmpty()) return null; - - int sp = raw.indexOf(' '); - String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase(); - String arg = sp < 0 ? "" : raw.substring(sp + 1).trim(); - if (cmd.isEmpty()) return null; - return new CommandInfo(cmd, arg); + return out; } static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException { @@ -411,7 +496,7 @@ final class ChannelsReadSupport { String partnerBchSql = """ SELECT blockchain_name FROM blockchain_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1 """; @@ -462,7 +547,7 @@ final class ChannelsReadSupport { String sql = """ SELECT msg_sub_type FROM blocks - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND msg_type = ? AND to_bch_name = ? AND to_block_number = ? @@ -524,19 +609,29 @@ final class ChannelsReadSupport { static final class ChannelMeta { String channelName; + String displayName; String channelDescription; int channelTypeCode; int channelTypeVersion; + String avaAr = ""; + String avaSha256 = ""; + long avaSize = 0L; + long metaUpdatedAtMs = 0L; } - static final class CommandInfo { - final String command; - final String arg; - - CommandInfo(String command, String arg) { - this.command = command; - this.arg = arg; - } + static final class MetaEvent { + String kind; + String login; + String bchName; + int blockNumber; + byte[] blockHash; + Integer lineStep; + long createdAtMs; + String title; + String description; + String avaAr; + String avaSha256; + long avaSize; } static final class PairChannelSelector { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 2dacd60e..9a882ea4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMe import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.MsgSubType; import utils.blockchain.BlockchainNameUtil; import blockchain.body.CreateChannelBody; @@ -39,7 +39,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); @@ -57,7 +57,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode); channel.setChannelName(meta.channelName); + channel.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName); channel.setChannelDescription(meta.channelDescription); + channel.setAvaAr(meta.avaAr); + channel.setAvaSha256(meta.avaSha256); + channel.setAvaSize(meta.avaSize); + channel.setMetaUpdatedAtMs(meta.metaUpdatedAtMs); channel.setChannelTypeCode(meta.channelTypeCode); channel.setChannelTypeVersion(meta.channelTypeVersion); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); @@ -66,6 +71,27 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { channel.setChannelRoot(rootRef); resp.setChannel(channel); + List metaEvents = new ArrayList<>(); + for (ChannelsReadSupport.MetaEvent event : ChannelsReadSupport.channelMetaEvents(c, ownerBch, lineCode)) { + Net_GetChannelMessages_Response.ChannelMetaEvent outEvent = new Net_GetChannelMessages_Response.ChannelMetaEvent(); + outEvent.setKind(event.kind); + Net_GetChannelMessages_Response.BlockRef eventRef = new Net_GetChannelMessages_Response.BlockRef(); + eventRef.setBlockNumber(event.blockNumber); + eventRef.setBlockHash(ChannelsReadSupport.toHex(event.blockHash)); + outEvent.setMessageRef(eventRef); + outEvent.setAuthorLogin(event.login); + outEvent.setAuthorBlockchainName(event.bchName); + outEvent.setLineStep(event.lineStep); + outEvent.setCreatedAtMs(event.createdAtMs); + outEvent.setTitle(event.title); + outEvent.setDescription(event.description); + outEvent.setAvaAr(event.avaAr); + outEvent.setAvaSha256(event.avaSha256); + outEvent.setAvaSize(event.avaSize); + metaEvents.add(outEvent); + } + resp.setMetaEvents(metaEvents); + List posts = new ArrayList<>( ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc) ); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java index 9f775da6..59c25b57 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.MsgSubType; import java.sql.Connection; @@ -26,7 +26,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { if (req.getLogin() == null || req.getLogin().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); @@ -52,7 +52,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String sql = """ SELECT COUNT(*) FROM connections_state - WHERE login = ? COLLATE NOCASE + WHERE LOWER(login) = LOWER(?) AND rel_type = ? """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -68,7 +68,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String sql = """ SELECT COUNT(*) FROM channel_names_state - WHERE owner_login = ? COLLATE NOCASE + WHERE LOWER(owner_login) = LOWER(?) AND channel_type_code = ? """; try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -81,7 +81,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { } private int countMyChannels(Connection c, String login) throws Exception { - String bchCountSql = "SELECT COUNT(*) FROM blockchain_state WHERE login = ? COLLATE NOCASE"; + String bchCountSql = "SELECT COUNT(*) FROM blockchain_state WHERE LOWER(login) = LOWER(?)"; int stories = 0; try (PreparedStatement ps = c.prepareStatement(bchCountSql)) { ps.setString(1, login); @@ -92,7 +92,7 @@ public class Net_GetChannelsCounters_Handler implements JsonMessageHandler { String namedSql = """ SELECT COUNT(*) FROM channel_names_state - WHERE owner_login = ? COLLATE NOCASE + WHERE LOWER(owner_login) = LOWER(?) AND channel_type_code IN (1,100,200) """; int named = 0; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java index c4e23f2a..c9855c61 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDial import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.channels.ChannelNameRules; import java.sql.Connection; @@ -33,7 +33,7 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля group"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { Net_GetGroupDialog_Response resp = new Net_GetGroupDialog_Response(); resp.setOp(req.getOp()); resp.setRequestId(req.getRequestId()); @@ -115,7 +115,7 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, ref.memberLogin); if (canonicalLogin == null || canonicalLogin.isBlank()) return null; - String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE login = ? COLLATE NOCASE ORDER BY blockchain_name LIMIT 1"; + String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1"; String memberBch = null; try (PreparedStatement ps = c.prepareStatement(bchSql)) { ps.setString(1, canonicalLogin); @@ -214,4 +214,3 @@ public class Net_GetGroupDialog_Handler implements JsonMessageHandler { String text; } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 7ee1ee5e..243ea507 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -12,7 +12,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageTh import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -28,21 +28,21 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { Net_GetMessageThread_Request req = (Net_GetMessageThread_Request) baseRequest; if (req.getMessage() == null || req.getMessage().getBlockchainName() == null || req.getMessage().getBlockNumber() == null) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); } int depthUp = req.getDepthUp() == null ? 20 : Math.max(0, req.getDepthUp()); int depthDown = req.getDepthDown() == null ? 2 : Math.max(0, req.getDepthDown()); int childLimit = req.getLimitChildrenPerNode() == null ? 50 : Math.max(1, req.getLimitChildrenPerNode()); - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); } - PostRow focusRow = findByNumber(c, req.getMessage().getBlockchainName(), req.getMessage().getBlockNumber()); + PostRow focusRow = findFocusRow(c, req.getMessage()); if (focusRow == null) { - return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение РЅРµ найдено"); + return NetExceptionResponseFactory.error(req, 404, "message_not_found", "Сообщение не найдено"); } Net_GetMessageThread_Response resp = new Net_GetMessageThread_Response(); @@ -67,10 +67,26 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { return resp; } catch (Exception e) { log.error("GetMessageThread failed", e); - return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); } } + private PostRow findFocusRow(Connection c, Net_GetMessageThread_Request.MessageSelector selector) throws Exception { + String blockchainName = String.valueOf(selector.getBlockchainName() == null ? "" : selector.getBlockchainName()).trim(); + int blockNumber = selector.getBlockNumber(); + byte[] blockHash = parseOptionalHash(selector.getBlockHash()); + + if (blockHash != null) { + PostRow exact = findByNumberAndHash(c, blockchainName, blockNumber, blockHash); + if (exact != null) return exact; + } + + PostRow byNumber = findByNumber(c, blockchainName, blockNumber); + if (byNumber != null) return byNumber; + + return null; + } + private List loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception { if (depthDown <= 0) return List.of(); List replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit); @@ -123,6 +139,33 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { } } + private PostRow findByNumberAndHash(Connection c, String bchName, int blockNumber, byte[] blockHash) throws Exception { + String sql = """ + SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number + FROM blocks + WHERE bch_name=? AND block_number=? AND block_hash=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bchName); + ps.setInt(2, blockNumber); + ps.setBytes(3, blockHash); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? mapRow(rs) : null; + } + } + } + + private byte[] parseOptionalHash(String hex) { + String value = String.valueOf(hex == null ? "" : hex).trim(); + if (value.isEmpty()) return null; + try { + return ChannelsReadSupport.hexToBytes(value); + } catch (Exception ignored) { + return null; + } + } + private PostRow mapRow(ResultSet rs) throws Exception { PostRow row = new PostRow(); row.login = rs.getString("login"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java index 6b5af1c3..5de34d74 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupCha import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -27,7 +27,7 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { if (req.getLogin() == null || req.getLogin().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); @@ -59,7 +59,7 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { ) m ON m.owner_bch_name = s.owner_bch_name AND m.channel_root_block_number = s.channel_root_block_number - WHERE s.owner_login = ? COLLATE NOCASE + WHERE LOWER(s.owner_login) = LOWER(?) ORDER BY s.updated_at_ms DESC, s.channel_root_block_number DESC """; List out = new ArrayList<>(); @@ -83,4 +83,3 @@ public class Net_ListGroupChats200_Handler implements JsonMessageHandler { return out; } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index aaeb677c..2874e982 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -11,7 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscrip import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -29,7 +29,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim()); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); @@ -65,7 +65,12 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { channelRef.setOwnerLogin(key.ownerLogin); channelRef.setOwnerBlockchainName(key.ownerBch); channelRef.setChannelName(meta.channelName); + channelRef.setDisplayName(meta.displayName == null || meta.displayName.isBlank() ? meta.channelName : meta.displayName); channelRef.setChannelDescription(meta.channelDescription); + channelRef.setAvaAr(meta.avaAr); + channelRef.setAvaSha256(meta.avaSha256); + channelRef.setAvaSize(meta.avaSize); + channelRef.setMetaUpdatedAtMs(meta.metaUpdatedAtMs); channelRef.setChannelTypeCode(meta.channelTypeCode); channelRef.setChannelTypeVersion(meta.channelTypeVersion); channelRef.setPersonal(meta.channelTypeCode == 100); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java index 3d96ee8a..a41b9811 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java @@ -11,7 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelM import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import java.sql.Connection; import java.sql.PreparedStatement; @@ -37,7 +37,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { return ok; } - try (Connection c = SqliteDbController.getInstance().getConnection()) { + try (Connection c = DbController.getInstance().getConnection()) { String viewerLogin = ctx != null ? ctx.getLogin() : null; if (viewerLogin == null || viewerLogin.isBlank()) { viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); @@ -64,9 +64,10 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { """.formatted(strictChannelMatch ? "AND line_code = ?" : ""); String insertSql = """ - INSERT OR IGNORE INTO message_views_state ( + INSERT INTO message_views_state ( viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING """; int seenAccepted = 0; @@ -132,4 +133,3 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { } } } - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index eeb95c25..9c1a80ff 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -8,6 +8,7 @@ import java.util.List; public class Net_GetChannelMessages_Response extends Net_Response { private Channel channel; private List messages = new ArrayList<>(); + private List metaEvents = new ArrayList<>(); public Channel getChannel() { return channel; } public void setChannel(Channel channel) { this.channel = channel; } @@ -15,12 +16,20 @@ public class Net_GetChannelMessages_Response extends Net_Response { public List getMessages() { return messages; } public void setMessages(List messages) { this.messages = messages; } + public List getMetaEvents() { return metaEvents; } + public void setMetaEvents(List metaEvents) { this.metaEvents = metaEvents; } + public static class Channel { private String ownerLogin; private String ownerBlockchainName; private String channelName; + private String displayName; private String channelDescription; + private String avaAr; + private String avaSha256; + private Long avaSize; + private Long metaUpdatedAtMs; private Integer channelTypeCode; private Integer channelTypeVersion; private BlockRef channelRoot; @@ -34,9 +43,24 @@ public class Net_GetChannelMessages_Response extends Net_Response { public String getChannelName() { return channelName; } public void setChannelName(String channelName) { this.channelName = channelName; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getChannelDescription() { return channelDescription; } public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; } + public String getAvaAr() { return avaAr; } + public void setAvaAr(String avaAr) { this.avaAr = avaAr; } + + public String getAvaSha256() { return avaSha256; } + public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; } + + public Long getAvaSize() { return avaSize; } + public void setAvaSize(Long avaSize) { this.avaSize = avaSize; } + + public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; } + public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; } + public Integer getChannelTypeCode() { return channelTypeCode; } public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; } @@ -47,6 +71,53 @@ public class Net_GetChannelMessages_Response extends Net_Response { public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; } } + public static class ChannelMetaEvent { + private String kind; + private BlockRef messageRef; + private String authorLogin; + private String authorBlockchainName; + private Integer lineStep; + private long createdAtMs; + private String title; + private String description; + private String avaAr; + private String avaSha256; + private Long avaSize; + + public String getKind() { return kind; } + public void setKind(String kind) { this.kind = kind; } + + public BlockRef getMessageRef() { return messageRef; } + public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; } + + public String getAuthorLogin() { return authorLogin; } + public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; } + + public String getAuthorBlockchainName() { return authorBlockchainName; } + public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; } + + public Integer getLineStep() { return lineStep; } + public void setLineStep(Integer lineStep) { this.lineStep = lineStep; } + + public long getCreatedAtMs() { return createdAtMs; } + public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getAvaAr() { return avaAr; } + public void setAvaAr(String avaAr) { this.avaAr = avaAr; } + + public String getAvaSha256() { return avaSha256; } + public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; } + + public Long getAvaSize() { return avaSize; } + public void setAvaSize(Long avaSize) { this.avaSize = avaSize; } + } + public static class MessageItem { private BlockRef messageRef; private Integer msgSubType; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 0d30472c..2c1651ac 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -46,7 +46,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { private String ownerLogin; private String ownerBlockchainName; private String channelName; + private String displayName; private String channelDescription; + private String avaAr; + private String avaSha256; + private Long avaSize; + private Long metaUpdatedAtMs; private Integer channelTypeCode; private Integer channelTypeVersion; private boolean personal; @@ -61,9 +66,24 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public String getChannelName() { return channelName; } public void setChannelName(String channelName) { this.channelName = channelName; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getChannelDescription() { return channelDescription; } public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; } + public String getAvaAr() { return avaAr; } + public void setAvaAr(String avaAr) { this.avaAr = avaAr; } + + public String getAvaSha256() { return avaSha256; } + public void setAvaSha256(String avaSha256) { this.avaSha256 = avaSha256; } + + public Long getAvaSize() { return avaSize; } + public void setAvaSize(Long avaSize) { this.avaSize = avaSize; } + + public Long getMetaUpdatedAtMs() { return metaUpdatedAtMs; } + public void setMetaUpdatedAtMs(Long metaUpdatedAtMs) { this.metaUpdatedAtMs = metaUpdatedAtMs; } + public Integer getChannelTypeCode() { return channelTypeCode; } public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java index 9a7350ae..bbb03fc4 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseF import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -31,7 +32,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нельзя добавить себя"); } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { String canonicalTo = findCanonicalLogin(c, toLogin); if (canonicalTo == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); @@ -42,8 +43,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } // Idempotent insert for close-friend relation. - // Using INSERT OR IGNORE avoids ON CONFLICT(column list) mismatches - // across DB instances with different UNIQUE schemas. + // Rely on PostgreSQL ON CONFLICT DO NOTHING. insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch); Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response(); @@ -58,7 +58,8 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } private String findCanonicalLogin(Connection c, String login) throws Exception { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); try (ResultSet rs = ps.executeQuery()) { @@ -68,7 +69,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { } private String findPrimaryBlockchain(Connection c, String login) throws Exception { - String sql = "SELECT blockchain_name FROM blockchain_state WHERE login = ? COLLATE NOCASE ORDER BY blockchain_name LIMIT 1"; + String sql = "SELECT blockchain_name FROM blockchain_state WHERE LOWER(login) = LOWER(?) ORDER BY blockchain_name LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, login); try (ResultSet rs = ps.executeQuery()) { @@ -82,10 +83,11 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler { String toLogin, String toBchName) throws Exception { String sql = """ - INSERT OR IGNORE INTO connections_state ( + INSERT INTO connections_state ( login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING """; try (PreparedStatement ps = c.prepareStatement(sql)) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java index 1c6a4765..0306fac6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java @@ -11,8 +11,9 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.dao.ConnectionsStateDAO; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -26,10 +27,7 @@ import java.util.List; * * ВАЖНО: * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. + * - в ответе возвращаем канонический регистр логина. */ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { @@ -51,12 +49,12 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { final String loginAnyCase = req.getLogin().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); try (Connection c = db.getConnection()) { - // 1) Канонизируем login через solana_users (NOCASE) + // 1) Канонизируем login через current users слой (NOCASE) String canonicalLogin = findCanonicalLogin(c, loginAnyCase); if (canonicalLogin == null) { return NetExceptionResponseFactory.error( @@ -99,10 +97,10 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler { private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { String sql = """ SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE + FROM %s + WHERE LOWER(login) = LOWER(?) LIMIT 1 - """; + """.formatted(CurrentUsersSql.usersSubquery("su")); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, loginAnyCase); try (ResultSet rs = ps.executeQuery()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java index 36bca444..ea83a791 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java @@ -10,6 +10,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; import shine.db.dao.ConnectionsStateDAO; +import shine.db.sql.CurrentUsersSql; import java.sql.Connection; import java.sql.PreparedStatement; @@ -39,7 +40,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { } } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { String canonicalLogin = findCanonicalLogin(c, requestedLogin); if (canonicalLogin == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); @@ -113,7 +114,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { } private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1"; + String sql = "SELECT login FROM " + CurrentUsersSql.usersSubquery("su") + + " WHERE LOWER(login) = LOWER(?) LIMIT 1"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, loginAnyCase); try (ResultSet rs = ps.executeQuery()) { @@ -168,19 +170,19 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value, MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value, MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value - FROM solana_users su + FROM %s LEFT JOIN users_params up - ON up.login = su.login COLLATE NOCASE + ON LOWER(up.login) = LOWER(su.login) AND up.param IN ('gender', 'official', 'shine', 'ava') - WHERE su.login COLLATE NOCASE IN (%s) + WHERE LOWER(su.login) IN (%s) GROUP BY su.login ORDER BY su.login - """.formatted(String.join(", ", placeholders)); + """.formatted(CurrentUsersSql.usersSubquery("su"), String.join(", ", placeholders)); try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; for (String login : logins) { - ps.setString(i, login); + ps.setString(i, normKey(login)); i += 1; } try (ResultSet rs = ps.executeQuery()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java index 7275ed79..92dcd2df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java @@ -22,7 +22,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); } - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { + try (Connection c = shine.db.DbController.getInstance().getConnection()) { List contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT); Net_ListContacts_Response resp = new Net_ListContacts_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java index 9bf9c582..e7137593 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java @@ -11,19 +11,19 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserPro import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; /** * GetSyncUserProfile — server-to-server профиль пользователя для межсерверной синхронизации. - * Нужен, чтобы принимающий сервер мог создать локальные solana_users + blockchain_state - * без прямого запроса в Solana RPC. + * Нужен, чтобы принимающий сервер мог создать локальную runtime-проекцию пользователя + * и blockchain_state без прямого запроса в Solana RPC. */ public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_GetSyncUserProfile_Handler.class); - private final SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + private final CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); @Override @@ -41,7 +41,7 @@ public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler } try { - SolanaUserEntry user = usersDAO.getByLogin(login); + CurrentUserEntry user = usersDAO.getByLogin(login); Net_GetSyncUserProfile_Response resp = new Net_GetSyncUserProfile_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java deleted file mode 100644 index d2756b0b..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_AddUser_Handler.java +++ /dev/null @@ -1,26 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -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.handlers.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; - -/** - * AddUser отключен: регистрация работает через Solana. - */ -public class Net_AddUser_Handler implements JsonMessageHandler { - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - return NetExceptionResponseFactory.error( - req, - 410, - "ADD_USER_DISABLED", - "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana." - ); - } -} - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 728b8d3f..961faa65 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -5,16 +5,15 @@ 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.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; import java.util.Arrays; @@ -38,14 +37,11 @@ public class Net_GetUser_Handler implements JsonMessageHandler { ); } - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - if (u == null) { - u = SolanaUserPdaImportService.findOrImportByLogin(req.getLogin()); - } + CurrentUserEntry u = usersDAO.getByLogin(req.getLogin()); Net_GetUser_Response resp = new Net_GetUser_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java index 6647fea2..afe7356c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_SearchUsers_Handler.java @@ -10,8 +10,8 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUser import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.CurrentUserEntry; import java.sql.SQLException; import java.util.ArrayList; @@ -35,13 +35,14 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler { } String prefix = req.getPrefix().trim(); + Boolean isServer = req.getIsServer(); try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 + CurrentUsersDAO dao = CurrentUsersDAO.getInstance(); + List users = dao.searchByLoginPrefix(prefix, isServer); // case-insensitive + LIMIT 5 List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { + for (CurrentUserEntry u : users) { if (u != null && u.getLogin() != null) { logins.add(u.getLogin()); // регистр как в БД } @@ -53,7 +54,7 @@ public class Net_SearchUsers_Handler implements JsonMessageHandler { resp.setStatus(WireCodes.Status.OK); resp.setLogins(logins); - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); + log.info("✅ SearchUsers ok: prefix='{}', isServer={} -> {}", prefix, isServer, logins.size()); return resp; } catch (SQLException e) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java deleted file mode 100644 index 28b6a6b7..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java +++ /dev/null @@ -1,58 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "clientKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String clientKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getClientKey() { return clientKey; } - public void setClientKey(String clientKey) { this.clientKey = clientKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java deleted file mode 100644 index 465cdcb6..00000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java +++ /dev/null @@ -1,21 +0,0 @@ -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} \ No newline at end of file diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java index 1e974d52..3f2e9990 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_SearchUsers_Request.java @@ -18,7 +18,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SearchUsers_Request extends Net_Request { private String prefix; + private Boolean isServer; public String getPrefix() { return prefix; } public void setPrefix(String prefix) { this.prefix = prefix; } -} \ No newline at end of file + public Boolean getIsServer() { return isServer; } + public void setIsServer(Boolean isServer) { this.isServer = isServer; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java index 93c12989..69b69bf0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java @@ -10,7 +10,7 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserPar import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; +import shine.db.DbController; import shine.db.dao.UserParamsDAO; import shine.db.entities.UserParamEntry; @@ -48,7 +48,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler { String param = req.getParam().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); UserParamsDAO dao = UserParamsDAO.getInstance(); try (Connection c = db.getConnection()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java index 866a9c58..d6460161 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java @@ -10,10 +10,10 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserPa import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.UserParamEntry; import java.sql.Connection; @@ -50,7 +50,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { String login = req.getLogin().trim(); try { - SqliteDbController db = SqliteDbController.getInstance(); + DbController db = DbController.getInstance(); UserParamsDAO dao = UserParamsDAO.getInstance(); List entries; @@ -63,7 +63,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { resp.setRequestId(req.getRequestId()); resp.setStatus(WireCodes.Status.OK); - SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + CurrentUserEntry user = CurrentUsersDAO.getInstance().getByLogin(login); resp.setLogin(user != null && user.getLogin() != null ? user.getLogin() : login); List items = new ArrayList<>(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java index 1d1f369a..5fa03b14 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java @@ -11,10 +11,10 @@ import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUser import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; +import shine.db.DbController; +import shine.db.dao.CurrentUsersDAO; import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import shine.db.entities.UserParamEntry; import utils.config.ShineSignatureConstants; import utils.crypto.Ed25519Util; @@ -104,13 +104,13 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { } // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + DbController db = DbController.getInstance(); + CurrentUsersDAO usersDAO = CurrentUsersDAO.getInstance(); UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); try (Connection c = db.getConnection()) { // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); + CurrentUserEntry user = usersDAO.getByLogin(c, login); if (user == null) { return NetExceptionResponseFactory.error( req, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java new file mode 100644 index 00000000..37a1f0e0 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/DmSyncApplySupport.java @@ -0,0 +1,50 @@ +package server.logic.ws_protocol.JSON.messages; + +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; + +public final class DmSyncApplySupport { + private DmSyncApplySupport() {} + + public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception { + if (ownerLogin == null || ownerLogin.isBlank()) { + throw new IllegalArgumentException("EMPTY_OWNER_LOGIN"); + } + if (blobB64 == null || blobB64.isBlank()) { + throw new IllegalArgumentException("EMPTY_BLOB"); + } + + SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64); + SignedMessagesCore.verifyUsersAndSignature(block); + SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null); + String owner = ownerLogin.trim(); + boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner); + boolean ownerMatchesDeletePair = block.isDeleteType() + && (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner)); + if (!ownerMatchesTarget && !ownerMatchesDeletePair) { + throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH"); + } + + SignedMessagesDAO.ApplyStatus status; + if (block.isContentType()) { + status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry); + } else if (block.isReadReceiptType()) { + status = SignedMessagesDAO.getInstance().insertIfAbsent(entry); + } else if (block.isMessageDeleteType()) { + status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry); + } else if (block.isConversationDeleteType()) { + status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry); + } else { + throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); + } + + return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status); + } + + public record ApplyResult( + String messageKey, + String baseKey, + int messageType, + SignedMessagesDAO.ApplyStatus status + ) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java index 4ada25e8..98493a04 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_AckSessionDelivery_Handler.java @@ -8,7 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Re import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SignedMessagesV2DAO; +import shine.db.dao.SignedMessagesDAO; public class Net_AckSessionDelivery_Handler implements JsonMessageHandler { @Override @@ -22,7 +22,7 @@ public class Net_AckSessionDelivery_Handler implements JsonMessageHandler { } String messageKey = req.getMessageKey().trim(); - SignedMessagesV2DAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis()); + SignedMessagesDAO.getInstance().markDelivered(messageKey, ctx.getSessionId(), System.currentTimeMillis()); Net_AckSessionDelivery_Response resp = new Net_AckSessionDelivery_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java index a1023a2b..359529e0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallInviteBroadcast_Handler.java @@ -15,9 +15,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.HashSet; import java.util.List; @@ -42,7 +42,7 @@ public class Net_CallInviteBroadcast_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/callId/type=100 обязательны"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java index 60ec4df3..b81bd0c5 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java @@ -17,9 +17,9 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetIdGenerator; import server.logic.ws_protocol.WireCodes; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.util.HashSet; import java.util.List; @@ -50,7 +50,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetSessionId/callId/type обязательны"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java index a8a7f503..66c0b25e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Re import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; public class Net_DeleteConversation_Handler implements JsonMessageHandler { @Override @@ -38,8 +38,8 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); - SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); + SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); + SignedMessagesDAO.ApplyStatus status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java index aac091c7..fe2e2ae2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Respons import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; public class Net_DeleteMessage_Handler implements JsonMessageHandler { @Override @@ -38,8 +38,8 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); - SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); + SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); + SignedMessagesDAO.ApplyStatus status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java new file mode 100644 index 00000000..81192f17 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DmSyncBatch_Handler.java @@ -0,0 +1,114 @@ +package server.logic.ws_protocol.JSON.messages; + +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_DmSyncBatch_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.SignedMessageEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +public class Net_DmSyncBatch_Handler implements JsonMessageHandler { + private static final int DEFAULT_LIMIT = 500; + private static final int MAX_LIMIT = 500; + private static final int DEFAULT_MAX_BYTES = 3_000_000; + private static final int MAX_BYTES_CAP = 5_000_000; + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_DmSyncBatch_Request req = (Net_DmSyncBatch_Request) baseRequest; + String ownerLogin = normalizeOriginal(req.getOwnerLogin()); + if (ownerLogin == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен"); + } + + String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); + if (ownServerLogin == null) { + return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен"); + } + if (!isLocalAccessServer(ownerLogin, ownServerLogin)) { + return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя"); + } + + int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT); + int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP); + long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs()); + String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim(); + + SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch( + ownerLogin, + afterStoredAtMs, + afterMessageKey, + limit, + maxBytes + ); + + Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setOwnerLogin(ownerLogin); + resp.setLimit(limit); + resp.setRawBytes(batch.rawBytes()); + resp.setHasMore(batch.hasMore()); + resp.setNextStoredAtMs(afterStoredAtMs); + resp.setNextMessageKey(afterMessageKey); + + List items = new ArrayList<>(); + Base64.Encoder encoder = Base64.getEncoder(); + for (SignedMessageEntry entry : batch.items()) { + Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item(); + item.setMessageKey(entry.getMessageKey()); + item.setBaseKey(entry.getBaseKey()); + item.setTargetLogin(entry.getTargetLogin()); + item.setFromLogin(entry.getFromLogin()); + item.setToLogin(entry.getToLogin()); + item.setMessageType(entry.getMessageType()); + item.setTimeMs(entry.getTimeMs()); + item.setStoredAtMs(entry.getCreatedAtMs()); + item.setBlobB64(encoder.encodeToString(entry.getRawBlock())); + items.add(item); + resp.setNextStoredAtMs(entry.getCreatedAtMs()); + resp.setNextMessageKey(entry.getMessageKey()); + } + resp.setItems(items); + return resp; + } + + private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception { + for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) { + if (route == null || route.getServerLogin() == null) continue; + if (ownServerLogin.equals(normalize(route.getServerLogin()))) { + return true; + } + } + return false; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(); + return s.isEmpty() ? null : s; + } + + private static String normalizeOriginal(String value) { + if (value == null) return null; + String s = value.trim(); + return s.isEmpty() ? null : s; + } +} 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..eca54d60 --- /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.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; + +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 = SignedMessagesDAO.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 (SignedMessageEntry 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/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 1f87ffd3..811d42ce 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -8,8 +8,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessag import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_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 shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; import java.util.Base64; @@ -40,21 +40,26 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - final SignedMessageV2Entry entry; + final SignedMessageEntry entry; try { entry = SignedMessagesCore.toEntry(incoming, "ReceiveIncomingMessage", null); } catch (IllegalArgumentException ex) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesV2DAO.ApplyStatus status = incoming.isContentType() - ? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry) - : SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + SignedMessagesDAO.ApplyStatus status = incoming.isContentType() + ? SignedMessagesDAO.getInstance().upsertIncomingCopy(entry) + : SignedMessagesDAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); + server.sync.DmFederationService.fanOutIncomingToRecipientAccessServers( + incoming.toLogin, + req.getIncomingBlobB64().trim(), + req.getSourceServerLogin() + ); } - if (status == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); } @@ -74,7 +79,7 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { } private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { - SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + SignedMessageEntry tombstone = SignedMessagesDAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; server.sync.DmFederationService.fanOutDeleteConversation( tombstone.getFromLogin(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java index 353f335d..32b80d64 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java @@ -18,11 +18,11 @@ import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.DirectMessagesDAO; import shine.db.dao.SignedDirectMessagesHistoryDAO; import shine.db.dao.SignedDmReplayDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.DirectMessageEntry; import shine.db.entities.SignedDirectMessageHistoryEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.nio.charset.StandardCharsets; @@ -54,8 +54,8 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета"); } - SolanaUserEntry fromUser = SolanaUsersDAO.getInstance().getByLogin(packet.fromLogin); - SolanaUserEntry toUser = SolanaUsersDAO.getInstance().getByLogin(packet.toLogin); + CurrentUserEntry fromUser = CurrentUsersDAO.getInstance().getByLogin(packet.fromLogin); + CurrentUserEntry toUser = CurrentUsersDAO.getInstance().getByLogin(packet.toLogin); if (fromUser == null || toUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index d1baee68..332a5555 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -9,8 +9,8 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Respo import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import server.sync.DmFederationService; -import shine.db.dao.SignedMessagesV2DAO; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.dao.SignedMessagesDAO; +import shine.db.entities.SignedMessageEntry; import java.util.Base64; @@ -41,8 +41,8 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); } - SignedMessageV2Entry incomingEntry; - SignedMessageV2Entry outgoingEntry; + SignedMessageEntry incomingEntry; + SignedMessageEntry outgoingEntry; try { String sourceApi = "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; @@ -52,15 +52,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - SignedMessagesV2DAO.ApplyStatus pairStatus; + SignedMessagesDAO.ApplyStatus pairStatus; if (incoming.isContentType()) { - pairStatus = SignedMessagesV2DAO.getInstance().upsertContentPair( + pairStatus = SignedMessagesDAO.getInstance().upsertContentPair( incomingEntry, outgoingEntry ); } else { - pairStatus = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) - ? SignedMessagesV2DAO.ApplyStatus.APPLIED - : SignedMessagesV2DAO.ApplyStatus.DUPLICATE_OR_OLDER; + pairStatus = SignedMessagesDAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) + ? SignedMessagesDAO.ApplyStatus.APPLIED + : SignedMessagesDAO.ApplyStatus.DUPLICATE_OR_OLDER; } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); @@ -77,7 +77,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); } - if (pairStatus == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); } @@ -107,7 +107,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { } private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { - SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + SignedMessageEntry tombstone = SignedMessagesDAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; DmFederationService.fanOutDeleteConversation( tombstone.getFromLogin(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java index 6a751cd8..7c24e117 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -16,9 +16,9 @@ import server.logic.ws_protocol.JSON.push.WsEventSender; import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.CurrentUsersDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.security.MessageDigest; @@ -74,12 +74,12 @@ public class Net_SendSignal_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд"); } - SolanaUserEntry senderUser = ctx.getSolanaUser(); + CurrentUserEntry senderUser = ctx.getCurrentUser(); if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key"); } - SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + CurrentUserEntry targetUser = CurrentUsersDAO.getInstance().getByLogin(toRequest); if (targetUser == null) { return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index f51c2eb7..ad16a106 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,9 +1,8 @@ package server.logic.ws_protocol.JSON.messages; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SignedMessageV2Entry; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; +import shine.db.entities.SignedMessageEntry; +import shine.db.entities.CurrentUserEntry; import utils.crypto.Ed25519Util; import java.util.Base64; @@ -47,25 +46,22 @@ final class SignedMessagesCore { } static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { - SolanaUserEntry from = resolveUser(block.fromLogin); - SolanaUserEntry to = resolveUser(block.toLogin); + CurrentUserEntry from = resolveUser(block.fromLogin); + CurrentUserEntry to = resolveUser(block.toLogin); if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin; - SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; + CurrentUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey()); if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { throw new IllegalArgumentException("BAD_SIGNATURE"); } } - private static SolanaUserEntry resolveUser(String login) throws Exception { - SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - SolanaUserEntry user = usersDao.getByLogin(login); - if (user != null) return user; - return SolanaUserPdaImportService.findOrImportByLogin(login); + private static CurrentUserEntry resolveUser(String login) throws Exception { + return CurrentUsersDAO.getInstance().getByLogin(login); } static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) { @@ -100,11 +96,11 @@ final class SignedMessagesCore { } } - static SignedMessageV2Entry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { + static SignedMessageEntry toEntry(SignedMessageBlock block, String sourceApi, String originSessionId) { String baseKey = SignedMessageKeys.baseKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce); String messageKey = SignedMessageKeys.messageKey(block.toLogin, block.fromLogin, block.timeMs, block.nonce, block.messageType); - SignedMessageV2Entry entry = new SignedMessageV2Entry(); + SignedMessageEntry entry = new SignedMessageEntry(); entry.setMessageKey(messageKey); entry.setBaseKey(baseKey); entry.setTargetLogin(primaryTargetLogin(block)); 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..76bd9306 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 @@ -9,9 +9,9 @@ import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.push.WebPushSender; import server.logic.ws_protocol.JSON.push.WsEventSender; import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SignedMessagesV2DAO; +import shine.db.dao.SignedMessagesDAO; import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SignedMessageV2Entry; +import shine.db.entities.SignedMessageEntry; import java.util.ArrayList; import java.util.Base64; @@ -37,12 +37,12 @@ public final class SignedMessagesRealtime { private SignedMessagesRealtime() {} - static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + static DeliveryCounters deliverToRelevantSessions(SignedMessageEntry message, SignedMessageBlock block) throws Exception { return deliverToRelevantSessions(message, block, null); } static DeliveryCounters deliverToRelevantSessions( - SignedMessageV2Entry message, + SignedMessageEntry message, SignedMessageBlock block, String excludeSessionId ) throws Exception { @@ -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); + } + SignedMessagesDAO.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++; @@ -89,9 +97,9 @@ public final class SignedMessagesRealtime { private static void dispatchPendingForSession(String login, String sessionId) { try { - List pending = SignedMessagesV2DAO.getInstance() + List pending = SignedMessagesDAO.getInstance() .listPendingForSession(login, sessionId); - for (SignedMessageV2Entry e : pending) { + for (SignedMessageEntry e : pending) { sendEventToSessionIfOnline(sessionId, login, e, true); } } catch (Exception e) { @@ -102,7 +110,7 @@ public final class SignedMessagesRealtime { private static boolean sendEventToSessionIfOnline( String sessionId, String actualTargetLogin, - SignedMessageV2Entry message, + SignedMessageEntry message, boolean backlog ) { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); @@ -126,14 +134,14 @@ public final class SignedMessagesRealtime { return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) { + private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageEntry message, SignedMessageBlock block) { if (block == null) return false; if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false; if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false; return block.revisionTimeMs == 0; } - private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { + private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageEntry message) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { @@ -152,7 +160,7 @@ public final class SignedMessagesRealtime { } } - private static List targetLoginsForMessage(SignedMessageV2Entry message) { + private static List targetLoginsForMessage(SignedMessageEntry message) { Set out = new LinkedHashSet<>(); int type = message.getMessageType(); if (type == SignedMessageBlock.TYPE_INCOMING_TEXT || type == SignedMessageBlock.TYPE_READ_INCOMING) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java new file mode 100644 index 00000000..fdaea682 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Request.java @@ -0,0 +1,22 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DmSyncBatch_Request extends Net_Request { + private String ownerLogin; + private Long afterStoredAtMs; + private String afterMessageKey; + private Integer limit; + private Integer maxBytes; + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public Long getAfterStoredAtMs() { return afterStoredAtMs; } + public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; } + public String getAfterMessageKey() { return afterMessageKey; } + public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; } + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + public Integer getMaxBytes() { return maxBytes; } + public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java new file mode 100644 index 00000000..edab6afd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DmSyncBatch_Response.java @@ -0,0 +1,62 @@ +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_DmSyncBatch_Response extends Net_Response { + private String ownerLogin; + private int limit; + private int rawBytes; + private boolean hasMore; + private long nextStoredAtMs; + private String nextMessageKey; + private List items = new ArrayList<>(); + + public String getOwnerLogin() { return ownerLogin; } + public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; } + public int getLimit() { return limit; } + public void setLimit(int limit) { this.limit = limit; } + public int getRawBytes() { return rawBytes; } + public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; } + public boolean isHasMore() { return hasMore; } + public void setHasMore(boolean hasMore) { this.hasMore = hasMore; } + public long getNextStoredAtMs() { return nextStoredAtMs; } + public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; } + public String getNextMessageKey() { return nextMessageKey; } + public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; } + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + public static class Item { + private String messageKey; + private String baseKey; + private String targetLogin; + private String fromLogin; + private String toLogin; + private int messageType; + private long timeMs; + private long storedAtMs; + 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 getTargetLogin() { return targetLogin; } + public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; } + 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 getStoredAtMs() { return storedAtMs; } + public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; } + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } + } +} 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/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java index 5b67429d..45dc2681 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_ReceiveIncomingMessage_Request.java @@ -4,7 +4,10 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_ReceiveIncomingMessage_Request extends Net_Request { private String incomingBlobB64; + private String sourceServerLogin; public String getIncomingBlobB64() { return incomingBlobB64; } public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } + public String getSourceServerLogin() { return sourceServerLogin; } + public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java index d2917221..7dc07361 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendMessagePair_Request.java @@ -5,9 +5,12 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; public class Net_SendMessagePair_Request extends Net_Request { private String incomingBlobB64; private String outgoingBlobB64; + private String sourceServerLogin; public String getIncomingBlobB64() { return incomingBlobB64; } public void setIncomingBlobB64(String incomingBlobB64) { this.incomingBlobB64 = incomingBlobB64; } public String getOutgoingBlobB64() { return outgoingBlobB64; } public void setOutgoingBlobB64(String outgoingBlobB64) { this.outgoingBlobB64 = outgoingBlobB64; } + public String getSourceServerLogin() { return sourceServerLogin; } + public void setSourceServerLogin(String sourceServerLogin) { this.sourceServerLogin = sourceServerLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java index 539ab1e2..93702ef9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java @@ -1,6 +1,6 @@ ////package server.logic.ws_protocol.JSON.utils; // -//import shine.db.entities.SolanaUserEntry; +//import shine.db.entities.CurrentUserEntry; //import utils.crypto.Ed25519Util; // //import java.nio.charset.StandardCharsets; @@ -35,7 +35,7 @@ // * Подпись проверяется над preimageCreateAuthSession(...). // */ // public static boolean verifyCreateAuthSessionSignature( -// SolanaUserEntry user, +// CurrentUserEntry user, // String login, // String authNonce, // long timeMs, diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java index b2fd337f..2e4741ab 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -114,6 +114,12 @@ public final class AddBlockSyncService { return; } + if (firstTry.serverAlreadyHasBlock(blockNumber)) { + log.info("AddBlock sync skipped: partner already has block. partner={} blockchainName={} blockNumber={} remoteLast={}", + partner.getLogin(), blockchainName, blockNumber, firstTry.serverLastGlobalNumber()); + return; + } + if (!firstTry.needsBackfill()) { log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}", partner.getLogin(), blockchainName, blockNumber, firstTry.code()); @@ -123,7 +129,7 @@ public final class AddBlockSyncService { int remoteLast = firstTry.serverLastGlobalNumber(); int fromBlockNumber = remoteLast + 1; if (fromBlockNumber > blockNumber) { - log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}", + log.info("AddBlock sync skipped: partner already caught up during backfill window. partner={} blockchainName={} remoteLast={} target={}", partner.getLogin(), blockchainName, remoteLast, blockNumber); return; } @@ -295,6 +301,12 @@ public final class AddBlockSyncService { boolean needsBackfill() { return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code)); } + + boolean serverAlreadyHasBlock(int targetBlockNumber) { + return !ok + && serverLastGlobalNumber != Integer.MIN_VALUE + && serverLastGlobalNumber >= targetBlockNumber; + } } private static final class SyncWsListener implements WebSocket.Listener { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java index 50589755..47994619 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java @@ -2,7 +2,8 @@ package server.sync; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.UserAccessServerRouteEntry; import utils.config.AppConfig; import java.util.LinkedHashMap; @@ -18,26 +19,48 @@ public final class DmFederationService { public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) { try { - Map senderRoutes = - routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin)); - Map recipientRoutes = - routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)); + Map senderRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin)); + Map recipientRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); String ownServerLogin = ownServerLogin(); - for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) { + for (UserAccessServerRouteEntry route : senderRoutes.values()) { if (isOwnServer(route, ownServerLogin)) continue; - REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64); + REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin); } - for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) { + for (UserAccessServerRouteEntry route : recipientRoutes.values()) { if (isOwnServer(route, ownServerLogin)) continue; - if (senderRoutes.containsKey(route.login())) continue; - REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64); + if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue; + REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); } } catch (Exception e) { log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); } } + public static void fanOutIncomingToRecipientAccessServers( + String toLogin, + String incomingBlobB64, + String sourceServerLogin + ) { + try { + Map recipientRoutes = + routesByLogin(UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin)); + String ownServerLogin = ownServerLogin(); + String normalizedSource = normalize(sourceServerLogin); + for (UserAccessServerRouteEntry route : recipientRoutes.values()) { + String routeLogin = normalize(route.getServerLogin()); + if (routeLogin == null) continue; + if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue; + if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue; + REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); + } + } catch (Exception e) { + log.warn("DM federation incoming relay failed: to={}", toLogin, e); + } + } + public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) { fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true); } @@ -48,17 +71,17 @@ public final class DmFederationService { private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) { try { - Map routes = routesByLogin( - SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin), - SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin) + Map routes = routesByLogin( + UserAccessServersCurrentDAO.getInstance().listByUserLogin(fromLogin), + UserAccessServersCurrentDAO.getInstance().listByUserLogin(toLogin) ); String ownServerLogin = ownServerLogin(); - for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) { + for (UserAccessServerRouteEntry route : routes.values()) { if (isOwnServer(route, ownServerLogin)) continue; if (oneMessageDelete) { - REMOTE.deleteMessage(route.serverAddress(), blobB64); + REMOTE.deleteMessage(route.getServerUrl(), blobB64); } else { - REMOTE.deleteConversation(route.serverAddress(), blobB64); + REMOTE.deleteConversation(route.getServerUrl(), blobB64); } } } catch (Exception e) { @@ -68,15 +91,15 @@ public final class DmFederationService { } @SafeVarargs - private static Map routesByLogin( - List... routeLists + private static Map routesByLogin( + List... routeLists ) { - Map out = new LinkedHashMap<>(); - for (List routeList : routeLists) { - for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) { + Map out = new LinkedHashMap<>(); + for (List routeList : routeLists) { + for (UserAccessServerRouteEntry route : routeList) { if (route == null) continue; - String login = normalize(route.login()); - String address = route.serverAddress() == null ? "" : route.serverAddress().trim(); + String login = normalize(route.getServerLogin()); + String address = route.getServerUrl() == null ? "" : route.getServerUrl().trim(); if (login == null || address.isBlank()) continue; out.putIfAbsent(login, route); } @@ -84,8 +107,11 @@ public final class DmFederationService { return out; } - private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) { - return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login()); + private static boolean isOwnServer(UserAccessServerRouteEntry route, String ownServerLogin) { + return ownServerLogin != null + && route != null + && route.getServerLogin() != null + && ownServerLogin.equalsIgnoreCase(route.getServerLogin()); } private static String ownServerLogin() { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java index c71d7185..cdd7c1df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -8,6 +8,8 @@ import java.net.http.HttpClient; import java.net.http.WebSocket; import java.nio.ByteBuffer; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -21,36 +23,83 @@ public final class RemoteDmSyncClient { .connectTimeout(Duration.ofSeconds(6)) .build(); - public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception { + public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64, String sourceServerLogin) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); + String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); JsonNode response = send(serverAddressRaw, """ { "op":"ReceiveOutcomingMessage", "requestId":%s, "payload":{ "incomingBlobB64":%s, - "outgoingBlobB64":%s + "outgoingBlobB64":%s%s } } - """.formatted("%s", incomingJson, outgoingJson)); + """.formatted("%s", incomingJson, outgoingJson, sourceServerLoginJson)); ensureOk("ReceiveOutcomingMessage", response); } - public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception { + public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64, String sourceServerLogin) throws Exception { String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); JsonNode response = send(serverAddressRaw, """ { "op":"ReceiveIncomingMessage", "requestId":%s, "payload":{ - "incomingBlobB64":%s + "incomingBlobB64":%s%s } } - """.formatted("%s", incomingJson)); + """.formatted("%s", incomingJson, sourceServerLoginJson)); ensureOk("ReceiveIncomingMessage", response); } + public RemoteDmBatch dmSyncBatch( + String serverAddressRaw, + String ownerLogin, + long afterStoredAtMs, + String afterMessageKey, + int limit, + int maxBytes + ) throws Exception { + String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin); + String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DmSyncBatch", + "requestId":%s, + "payload":{ + "ownerLogin":%s, + "afterStoredAtMs":%d, + "afterMessageKey":%s, + "limit":%d, + "maxBytes":%d + } + } + """.formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes)); + ensureOk("DmSyncBatch", response); + + JsonNode payload = response.path("payload"); + List items = new ArrayList<>(); + JsonNode arr = payload.path("items"); + if (arr.isArray()) { + for (JsonNode item : arr) { + items.add(new RemoteDmItem( + item.path("messageKey").asText(""), + item.path("storedAtMs").asLong(0L), + item.path("blobB64").asText("") + )); + } + } + return new RemoteDmBatch( + payload.path("nextStoredAtMs").asLong(afterStoredAtMs), + payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey), + payload.path("hasMore").asBoolean(false), + items + ); + } + public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { String blobJson = MAPPER.writeValueAsString(blobB64); JsonNode response = send(serverAddressRaw, """ @@ -107,6 +156,13 @@ public final class RemoteDmSyncClient { return MAPPER.readTree(responseJson); } + private String toOptionalJsonField(String fieldName, String value) throws Exception { + if (value == null || value.isBlank()) { + return ""; + } + return ",\n \"" + fieldName + "\":" + MAPPER.writeValueAsString(value.trim()); + } + private void ensureOk(String op, JsonNode response) { int status = response.path("status").asInt(500); if (status >= 200 && status < 300) return; @@ -126,6 +182,19 @@ public final class RemoteDmSyncClient { } } + public record RemoteDmBatch( + long nextStoredAtMs, + String nextMessageKey, + boolean hasMore, + List items + ) {} + + public record RemoteDmItem( + String messageKey, + long storedAtMs, + String blobB64 + ) {} + private static final class SyncWsListener implements WebSocket.Listener { private final CompletableFuture responseFuture; private final CountDownLatch openLatch; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java index 0a8ec176..5a7ba084 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/ws/WsConnectionUtils.java @@ -5,7 +5,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; -import shine.db.entities.SolanaUserEntry; +import shine.db.entities.CurrentUserEntry; import java.net.SocketAddress; import java.util.concurrent.atomic.AtomicLong; @@ -57,7 +57,7 @@ public final class WsConnectionUtils { final String sessionId = safeString(ctx.getSessionId()); final int authStatus = safeAuthStatus(ctx); - final SolanaUserEntry user = ctx.getSolanaUser(); + final CurrentUserEntry user = ctx.getCurrentUser(); final String login = (user != null ? safeString(user.getLogin()) : ""); final String activeSessionId = @@ -152,4 +152,4 @@ public final class WsConnectionUtils { return "remote=" + remote + ", local=" + local; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-solana-users-sync/build.gradle b/SHiNE-server/shine-server-solana-users-sync/build.gradle new file mode 100644 index 00000000..74314d62 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java' +} + +group = 'shine' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':shine-server-config') + + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'org.postgresql:postgresql:42.7.7' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.slf4j:slf4j-api:2.0.16' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java new file mode 100644 index 00000000..38597f90 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -0,0 +1,1444 @@ +package sync.codec; + +import com.fasterxml.jackson.databind.JsonNode; +import sync.util.Base58Util; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; + +public final class ShineUsersCodec { + + public static final String MAGIC = + "SHiNE"; + + public static final int IX_INIT_USERS_ECONOMY_CONFIG = + 1; + + public static final int IX_UPDATE_USERS_ECONOMY_CONFIG = + 2; + + public static final int IX_CREATE_USER_PDA = + 3; + + public static final int IX_UPDATE_USER_PDA = + 4; + + public static final long START_REGISTRATION_FEE_LAMPORTS = + 10_000_000L; + + public static final long START_LAMPORTS_PER_LIMIT_STEP = + 100_000L; + + public static final long START_BONUS_LIMIT = + 100_000L; + + private static final int BLOCK_TYPE_RECOVERY_KEY = + 0; + + private static final int BLOCK_TYPE_ROOT_KEY = + 1; + + private static final int BLOCK_TYPE_CLIENT_KEY = + 2; + + private static final int BLOCK_TYPE_BLOCKCHAIN_REGISTRY = + 3; + + private static final int BLOCK_TYPE_SERVER_PROFILE = + 30; + + private static final int BLOCK_TYPE_ACCESS_SERVERS = + 40; + + private static final int BLOCK_TYPE_SESSIONS = + 50; + + private static final int BLOCK_TYPE_TRUSTED_STATE = + 70; + + private static final int BLOCK_VERSION_0 = + 0; + + private static final int BLOCKCHAIN_TYPE_MAIN_USER = + 1; + + private static final byte[] ZERO_HASH = + new byte[32]; + + private ShineUsersCodec() { + } + + public static ParsedInstruction parseShineUsersInstruction( + JsonNode instructionNode, + String expectedProgramId + ) { + + if (instructionNode == null) { + return null; + } + + String programId = + instructionNode.path("programId") + .asText(""); + + if (!expectedProgramId.equals(programId)) { + return null; + } + + String dataBase58 = + instructionNode.path("data") + .asText(""); + + if (dataBase58.isBlank()) { + return new ParsedInstruction( + TxKind.OTHER, + false, + null, + null, + null, + null + ); + } + + byte[] data = + Base58Util.decode( + dataBase58 + ); + + Reader reader = + new Reader(data); + + int tag = + reader.readU8(); + + List accounts = + readInstructionAccounts( + instructionNode.path("accounts") + ); + + return switch (tag) { + case IX_INIT_USERS_ECONOMY_CONFIG -> + new ParsedInstruction( + TxKind.INIT_USERS_ECONOMY_CONFIG, + false, + null, + null, + EconomyConfigState.initial(), + null + ); + + case IX_UPDATE_USERS_ECONOMY_CONFIG -> + new ParsedInstruction( + TxKind.UPDATE_USERS_ECONOMY_CONFIG, + false, + null, + null, + new EconomyConfigState( + 1, + reader.readU64(), + reader.readU64(), + reader.readU64() + ), + null + ); + + case IX_CREATE_USER_PDA -> + parseCreateInstruction( + reader, + accounts + ); + + case IX_UPDATE_USER_PDA -> + parseUpdateInstruction( + reader, + accounts + ); + + default -> + new ParsedInstruction( + TxKind.OTHER, + false, + null, + null, + null, + null + ); + }; + } + + public static UserPdaSnapshot parseUserPdaAccount( + String pdaAddress, + long slot, + String rawDataBase64, + String lastTxSignature + ) { + + byte[] raw = + Base64.getDecoder() + .decode(rawDataBase64); + + if (raw.length < 9) { + throw new IllegalArgumentException( + "PDA record too short" + ); + } + + String magic = + new String( + raw, + 0, + 5, + StandardCharsets.UTF_8 + ); + + if (!MAGIC.equals(magic)) { + throw new IllegalArgumentException( + "Unsupported PDA magic" + ); + } + + int recordLen = + u16le( + raw, + 7 + ); + + if (recordLen > raw.length) { + throw new IllegalArgumentException( + "Invalid PDA record length" + ); + } + + byte[] useful = + new byte[recordLen]; + + System.arraycopy( + raw, + 0, + useful, + 0, + recordLen + ); + + Reader reader = + new Reader(useful); + + reader.skip(9); + + long createdAtMs = + reader.readU64(); + + long updatedAtMs = + reader.readU64(); + + int recordNumber = + (int) reader.readU32(); + + byte[] prevHash = + reader.readFixed(32); + + String login = + reader.readStringU8(); + + int blocksCount = + reader.readU8(); + + String recoveryKey = + null; + + String rootKey = + null; + + String clientKey = + null; + + String blockchainName = + null; + + String blockchainKey = + null; + + long paidLimitBytes = + 0L; + + long usedBytes = + 0L; + + int lastBlockNumber = + 0; + + String lastBlockHash = + ""; + + String lastBlockSignature = + ""; + + String arweaveTxId = + ""; + + boolean isServer = + false; + + int addressFormatType = + 0; + + int addressFormatVersion = + 0; + + String serverAddress = + ""; + + List syncServers = + new ArrayList<>(); + + List accessServers = + new ArrayList<>(); + + int sessionsMode = + 1; + + List sessions = + new ArrayList<>(); + + int trustedCount = + 0; + + for (int i = 0; i < blocksCount; i++) { + + int blockType = + reader.readU8(); + + int blockVersion = + reader.readU8(); + + if (blockVersion != BLOCK_VERSION_0) { + throw new IllegalArgumentException( + "Unsupported block version" + ); + } + + switch (blockType) { + + case BLOCK_TYPE_RECOVERY_KEY -> + recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_ROOT_KEY -> + rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_CLIENT_KEY -> + clientKey = + Base58Util.encode( + reader.readFixed(32) + ); + + case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> { + int count = + reader.readU8(); + + if (count != 1) { + throw new IllegalArgumentException( + "Unexpected blockchain count" + ); + } + + int blockchainType = + reader.readU8(); + + if (blockchainType != BLOCKCHAIN_TYPE_MAIN_USER) { + throw new IllegalArgumentException( + "Unsupported blockchain type" + ); + } + + blockchainName = + reader.readStringU8(); + + blockchainKey = + Base58Util.encode( + reader.readFixed(32) + ); + + paidLimitBytes = + reader.readU64(); + + usedBytes = + reader.readU64(); + + lastBlockNumber = + (int) reader.readU32(); + + lastBlockHash = + toHex( + reader.readFixed(32) + ); + + lastBlockSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + int arweavePresent = + reader.readU8(); + + if (arweavePresent == 1) { + arweaveTxId = + reader.readStringU8(); + } else if (arweavePresent != 0) { + throw new IllegalArgumentException( + "Invalid arweave marker" + ); + } + } + + case BLOCK_TYPE_SERVER_PROFILE -> { + isServer = + reader.readU8() == 1; + + if (isServer) { + addressFormatType = + reader.readU8(); + addressFormatVersion = + reader.readU8(); + serverAddress = + reader.readStringU8(); + int syncCount = + reader.readU8(); + for (int j = 0; j < syncCount; j++) { + syncServers.add( + reader.readStringU8() + ); + } + } + } + + case BLOCK_TYPE_ACCESS_SERVERS -> { + int accessCount = + reader.readU8(); + for (int j = 0; j < accessCount; j++) { + accessServers.add( + reader.readStringU8() + ); + } + } + + case BLOCK_TYPE_SESSIONS -> { + sessionsMode = + reader.readU8(); + + int sessionCount = + reader.readU8(); + + for (int j = 0; j < sessionCount; j++) { + sessions.add( + new UserSessionSnapshot( + reader.readU8(), + reader.readU8(), + reader.readStringU8(), + Base58Util.encode( + reader.readFixed(32) + ) + ) + ); + } + } + + case BLOCK_TYPE_TRUSTED_STATE -> + trustedCount = + reader.readU8(); + + default -> + throw new IllegalArgumentException( + "Unsupported block type: " + blockType + ); + } + } + + String signature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new UserPdaSnapshot( + pdaAddress, + login, + recordNumber, + slot, + lastTxSignature, + recoveryKey, + rootKey, + clientKey, + blockchainName, + blockchainKey, + paidLimitBytes, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + List.copyOf(syncServers), + List.copyOf(accessServers), + sessionsMode, + List.copyOf(sessions), + trustedCount, + createdAtMs, + updatedAtMs, + toHex(prevHash), + signature, + rawDataBase64 + ); + } + + public static UserPdaSnapshot buildCreateSnapshot( + UserPdaMutation mutation, + EconomyConfigState economyConfigState, + String txSignature, + long slot + ) { + + long paidLimitBytes = + economyConfigState.startBonusLimit() + + mutation.additionalLimit(); + + return buildSnapshot( + mutation, + 0, + slot, + txSignature, + mutation.createdAtMs(), + mutation.createdAtMs(), + toHex(ZERO_HASH), + paidLimitBytes + ); + } + + public static UserPdaSnapshot buildUpdateSnapshot( + UserPdaMutation mutation, + UserPdaSnapshot previous, + String txSignature, + long slot + ) { + + long paidLimitBytes = + previous.paidLimitBytes() + + mutation.additionalLimit(); + + return buildSnapshot( + mutation, + mutation.version(), + slot, + txSignature, + mutation.createdAtMs(), + mutation.updatedAtMs(), + toHex(mutation.prevHash()), + paidLimitBytes + ); + } + + public static String serializeSnapshotToBase64( + UserPdaSnapshot snapshot + ) { + + byte[] recoveryKey = + Base58Util.decode( + snapshot.recoveryKey() + ); + + byte[] rootKey = + Base58Util.decode( + snapshot.rootKey() + ); + + byte[] clientKey = + Base58Util.decode( + snapshot.clientKey() + ); + + byte[] blockchainKey = + Base58Util.decode( + snapshot.blockchainKey() + ); + + byte[] lastBlockHash = + fromHex( + snapshot.lastBlockHash() + ); + + byte[] lastBlockSignature = + Base58Util.decode( + snapshot.lastBlockSignature() + ); + + byte[] prevHash = + fromHex( + snapshot.prevRecordHash() + ); + + byte[] recordSignature = + Base58Util.decode( + snapshot.recordSignature() + ); + + List syncServers = + snapshot.syncServers(); + + List accessServers = + snapshot.accessServers(); + + List sessions = + snapshot.sessions(); + + byte[] loginBytes = + snapshot.login() + .getBytes(StandardCharsets.UTF_8); + + List out = + new ArrayList<>(); + + pushFixed(out, MAGIC.getBytes(StandardCharsets.UTF_8)); + out.add((byte) 1); + out.add((byte) 0); + pushU16(out, 0); + pushU64(out, snapshot.createdAtMs()); + pushU64(out, snapshot.updatedAtMs()); + pushU32(out, snapshot.recordNumber()); + pushFixed(out, prevHash); + pushStringU8(out, loginBytes); + + int blocksCount = + snapshot.isServer() + ? 8 + : 7; + + out.add((byte) blocksCount); + + out.add((byte) BLOCK_TYPE_RECOVERY_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, recoveryKey); + + out.add((byte) BLOCK_TYPE_ROOT_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, rootKey); + + out.add((byte) BLOCK_TYPE_CLIENT_KEY); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, clientKey); + + out.add((byte) BLOCK_TYPE_BLOCKCHAIN_REGISTRY); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) 1); + out.add((byte) BLOCKCHAIN_TYPE_MAIN_USER); + pushString(out, snapshot.blockchainName()); + pushFixed(out, blockchainKey); + pushU64(out, snapshot.paidLimitBytes()); + pushU64(out, snapshot.usedBytes()); + pushU32(out, snapshot.lastBlockNumber()); + pushFixed(out, lastBlockHash); + pushFixed(out, lastBlockSignature); + + if (snapshot.arweaveTxId().isBlank()) { + out.add((byte) 0); + } else { + out.add((byte) 1); + pushString(out, snapshot.arweaveTxId()); + } + + if (snapshot.isServer()) { + out.add((byte) BLOCK_TYPE_SERVER_PROFILE); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) 1); + out.add((byte) snapshot.addressFormatType()); + out.add((byte) snapshot.addressFormatVersion()); + pushString(out, snapshot.serverAddress()); + out.add((byte) syncServers.size()); + for (String syncServer : syncServers) { + pushString(out, syncServer); + } + } + + out.add((byte) BLOCK_TYPE_ACCESS_SERVERS); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) accessServers.size()); + for (String accessServer : accessServers) { + pushString(out, accessServer); + } + + out.add((byte) BLOCK_TYPE_SESSIONS); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) snapshot.sessionsMode()); + out.add((byte) sessions.size()); + for (UserSessionSnapshot session : sessions) { + out.add((byte) session.sessionType()); + out.add((byte) session.sessionVersion()); + pushString(out, session.sessionName()); + pushFixed( + out, + Base58Util.decode( + session.sessionPubKey() + ) + ); + } + + out.add((byte) BLOCK_TYPE_TRUSTED_STATE); + out.add((byte) BLOCK_VERSION_0); + out.add((byte) snapshot.trustedCount()); + + int recordLength = + out.size() + 64; + + byte[] recordLengthBytes = + new byte[]{ + (byte) (recordLength & 0xFF), + (byte) ((recordLength >> 8) & 0xFF) + }; + + out.set(7, recordLengthBytes[0]); + out.set(8, recordLengthBytes[1]); + + pushFixed(out, recordSignature); + + byte[] bytes = + new byte[out.size()]; + + for (int i = 0; i < out.size(); i++) { + bytes[i] = out.get(i); + } + + return Base64.getEncoder() + .encodeToString(bytes); + } + + private static ParsedInstruction parseCreateInstruction( + Reader reader, + List accounts + ) { + + CreateOrUpdateArgs args = + parseCreateArgs(reader); + + return new ParsedInstruction( + TxKind.CREATE_USER_PDA, + true, + accountAt( + accounts, + 1 + ), + args.login(), + null, + new UserPdaMutation( + true, + accountAt( + accounts, + 1 + ), + args.login(), + args.recoveryKey(), + args.rootKey(), + args.createdAtMs(), + args.createdAtMs(), + 0, + ZERO_HASH.clone(), + args.additionalLimit(), + args.fields(), + args.recordSignature() + ) + ); + } + + private static ParsedInstruction parseUpdateInstruction( + Reader reader, + List accounts + ) { + + UpdateArgs args = + parseUpdateArgs(reader); + + return new ParsedInstruction( + TxKind.UPDATE_USER_PDA, + true, + accountAt( + accounts, + 1 + ), + args.login(), + null, + new UserPdaMutation( + false, + accountAt( + accounts, + 1 + ), + args.login(), + args.recoveryKey(), + args.rootKey(), + args.createdAtMs(), + args.updatedAtMs(), + args.version(), + args.prevHash(), + args.additionalLimit(), + args.fields(), + args.recordSignature() + ) + ); + } + + private static CreateOrUpdateArgs parseCreateArgs( + Reader reader + ) { + + String login = + reader.readStringU8(); + + String recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + long createdAtMs = + reader.readU64(); + + long additionalLimit = + reader.readU64(); + + UserFields fields = + parseFields(reader); + + String recordSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new CreateOrUpdateArgs( + login, + recoveryKey, + rootKey, + createdAtMs, + additionalLimit, + fields, + recordSignature + ); + } + + private static UpdateArgs parseUpdateArgs( + Reader reader + ) { + + String login = + reader.readStringU8(); + + String recoveryKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String rootKey = + Base58Util.encode( + reader.readFixed(32) + ); + + long createdAtMs = + reader.readU64(); + + long updatedAtMs = + reader.readU64(); + + int version = + (int) reader.readU32(); + + byte[] prevHash = + reader.readFixed(32); + + long additionalLimit = + reader.readU64(); + + UserFields fields = + parseFields(reader); + + String recordSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + return new UpdateArgs( + login, + recoveryKey, + rootKey, + createdAtMs, + updatedAtMs, + version, + prevHash, + additionalLimit, + fields, + recordSignature + ); + } + + private static UserFields parseFields( + Reader reader + ) { + + String clientKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String blockchainKey = + Base58Util.encode( + reader.readFixed(32) + ); + + String blockchainName = + reader.readStringU8(); + + long usedBytes = + reader.readU64(); + + int lastBlockNumber = + (int) reader.readU32(); + + String lastBlockHash = + toHex( + reader.readFixed(32) + ); + + String lastBlockSignature = + Base58Util.encode( + reader.readFixed(64) + ); + + String arweaveTxId = + reader.readStringU8(); + + boolean isServer = + reader.readU8() == 1; + + int addressFormatType = + 0; + int addressFormatVersion = + 0; + String serverAddress = + ""; + List syncServers = + new ArrayList<>(); + + if (isServer) { + addressFormatType = + reader.readU8(); + addressFormatVersion = + reader.readU8(); + serverAddress = + reader.readStringU8(); + int syncCount = + reader.readU8(); + for (int i = 0; i < syncCount; i++) { + syncServers.add( + reader.readStringU8() + ); + } + } + + int accessCount = + reader.readU8(); + + List accessServers = + new ArrayList<>(); + + for (int i = 0; i < accessCount; i++) { + accessServers.add( + reader.readStringU8() + ); + } + + int sessionsMode = + reader.readU8(); + + int sessionsCount = + reader.readU8(); + + List sessions = + new ArrayList<>(); + + for (int i = 0; i < sessionsCount; i++) { + sessions.add( + new UserSessionSnapshot( + reader.readU8(), + reader.readU8(), + reader.readStringU8(), + Base58Util.encode( + reader.readFixed(32) + ) + ) + ); + } + + int trustedCount = + reader.readU8(); + + return new UserFields( + clientKey, + blockchainKey, + blockchainName, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + List.copyOf(syncServers), + List.copyOf(accessServers), + sessionsMode, + List.copyOf(sessions), + trustedCount + ); + } + + private static UserPdaSnapshot buildSnapshot( + UserPdaMutation mutation, + int recordNumber, + long slot, + String txSignature, + long createdAtMs, + long updatedAtMs, + String prevRecordHash, + long paidLimitBytes + ) { + + UserFields fields = + mutation.fields(); + + UserPdaSnapshot snapshot = + new UserPdaSnapshot( + mutation.pdaAddress(), + mutation.login(), + recordNumber, + slot, + txSignature, + mutation.recoveryKey(), + mutation.rootKey(), + fields.clientKey(), + fields.blockchainName(), + fields.blockchainKey(), + paidLimitBytes, + fields.usedBytes(), + fields.lastBlockNumber(), + fields.lastBlockHash(), + fields.lastBlockSignature(), + fields.arweaveTxId(), + fields.isServer(), + fields.addressFormatType(), + fields.addressFormatVersion(), + fields.serverAddress(), + fields.syncServers(), + fields.accessServers(), + fields.sessionsMode(), + fields.sessions(), + fields.trustedCount(), + createdAtMs, + updatedAtMs, + prevRecordHash, + mutation.recordSignature(), + "" + ); + + return snapshot.withRawDataBase64( + serializeSnapshotToBase64( + snapshot + ) + ); + } + + private static List readInstructionAccounts( + JsonNode accountsNode + ) { + + List accounts = + new ArrayList<>(); + + if (!accountsNode.isArray()) { + return accounts; + } + + for (JsonNode node : accountsNode) { + accounts.add( + node.asText("") + ); + } + + return accounts; + } + + private static String accountAt( + List accounts, + int index + ) { + + if (index < 0 + || index >= accounts.size()) { + return null; + } + + String value = + accounts.get(index); + + return value == null + || value.isBlank() + ? null + : value; + } + + private static String toHex( + byte[] bytes + ) { + return HexFormat.of() + .formatHex(bytes); + } + + private static byte[] fromHex( + String hex + ) { + if (hex == null + || hex.isBlank()) { + return new byte[0]; + } + + return HexFormat.of() + .parseHex(hex); + } + + private static int u16le( + byte[] data, + int offset + ) { + return (data[offset] & 0xFF) + | ((data[offset + 1] & 0xFF) << 8); + } + + private static void pushString( + List out, + String value + ) { + pushStringU8( + out, + value.getBytes(StandardCharsets.UTF_8) + ); + } + + private static void pushStringU8( + List out, + byte[] bytes + ) { + out.add((byte) bytes.length); + pushFixed( + out, + bytes + ); + } + + private static void pushFixed( + List out, + byte[] bytes + ) { + for (byte value : bytes) { + out.add(value); + } + } + + private static void pushU16( + List out, + int value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + } + + private static void pushU32( + List out, + int value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + out.add((byte) ((value >> 16) & 0xFF)); + out.add((byte) ((value >> 24) & 0xFF)); + } + + private static void pushU64( + List out, + long value + ) { + out.add((byte) (value & 0xFF)); + out.add((byte) ((value >> 8) & 0xFF)); + out.add((byte) ((value >> 16) & 0xFF)); + out.add((byte) ((value >> 24) & 0xFF)); + out.add((byte) ((value >> 32) & 0xFF)); + out.add((byte) ((value >> 40) & 0xFF)); + out.add((byte) ((value >> 48) & 0xFF)); + out.add((byte) ((value >> 56) & 0xFF)); + } + + private static final class Reader { + + private final byte[] data; + private int cursor; + + private Reader( + byte[] data + ) { + this.data = data; + this.cursor = 0; + } + + private int readU8() { + return data[cursor++] & 0xFF; + } + + private long readU32() { + long value = + (data[cursor] & 0xFFL) + | ((data[cursor + 1] & 0xFFL) << 8) + | ((data[cursor + 2] & 0xFFL) << 16) + | ((data[cursor + 3] & 0xFFL) << 24); + + cursor += 4; + return value; + } + + private long readU64() { + long value = 0L; + for (int i = 0; i < 8; i++) { + value |= (long) (data[cursor + i] & 0xFF) << (8 * i); + } + cursor += 8; + return value; + } + + private byte[] readFixed( + int length + ) { + byte[] out = + new byte[length]; + System.arraycopy( + data, + cursor, + out, + 0, + length + ); + cursor += length; + return out; + } + + private String readStringU8() { + int length = + readU8(); + String value = + new String( + data, + cursor, + length, + StandardCharsets.UTF_8 + ); + cursor += length; + return value; + } + + private void skip( + int bytes + ) { + cursor += bytes; + } + } + + public enum TxKind { + INIT_USERS_ECONOMY_CONFIG, + UPDATE_USERS_ECONOMY_CONFIG, + CREATE_USER_PDA, + UPDATE_USER_PDA, + OTHER + } + + public record ParsedInstruction( + TxKind kind, + boolean relevant, + String affectedPdaAddress, + String affectedLogin, + EconomyConfigState economyConfigState, + UserPdaMutation userPdaMutation + ) { + } + + public record EconomyConfigState( + int version, + long registrationFeeLamports, + long lamportsPerLimitStep, + long startBonusLimit + ) { + public static EconomyConfigState initial() { + return new EconomyConfigState( + 1, + START_REGISTRATION_FEE_LAMPORTS, + START_LAMPORTS_PER_LIMIT_STEP, + START_BONUS_LIMIT + ); + } + } + + public record UserSessionSnapshot( + int sessionType, + int sessionVersion, + String sessionName, + String sessionPubKey + ) { + } + + public record UserFields( + String clientKey, + String blockchainKey, + String blockchainName, + long usedBytes, + int lastBlockNumber, + String lastBlockHash, + String lastBlockSignature, + String arweaveTxId, + boolean isServer, + int addressFormatType, + int addressFormatVersion, + String serverAddress, + List syncServers, + List accessServers, + int sessionsMode, + List sessions, + int trustedCount + ) { + } + + private record CreateOrUpdateArgs( + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + private record UpdateArgs( + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long updatedAtMs, + int version, + byte[] prevHash, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + public record UserPdaMutation( + boolean create, + String pdaAddress, + String login, + String recoveryKey, + String rootKey, + long createdAtMs, + long updatedAtMs, + int version, + byte[] prevHash, + long additionalLimit, + UserFields fields, + String recordSignature + ) { + } + + public record UserPdaSnapshot( + String pdaAddress, + String login, + int recordNumber, + long slot, + String lastTxSignature, + String recoveryKey, + String rootKey, + String clientKey, + String blockchainName, + String blockchainKey, + long paidLimitBytes, + long usedBytes, + int lastBlockNumber, + String lastBlockHash, + String lastBlockSignature, + String arweaveTxId, + boolean isServer, + int addressFormatType, + int addressFormatVersion, + String serverAddress, + List syncServers, + List accessServers, + int sessionsMode, + List sessions, + int trustedCount, + long createdAtMs, + long updatedAtMs, + String prevRecordHash, + String recordSignature, + String rawDataBase64 + ) { + public UserPdaSnapshot withRawDataBase64( + String value + ) { + return new UserPdaSnapshot( + pdaAddress, + login, + recordNumber, + slot, + lastTxSignature, + recoveryKey, + rootKey, + clientKey, + blockchainName, + blockchainKey, + paidLimitBytes, + usedBytes, + lastBlockNumber, + lastBlockHash, + lastBlockSignature, + arweaveTxId, + isServer, + addressFormatType, + addressFormatVersion, + serverAddress, + syncServers, + accessServers, + sessionsMode, + sessions, + trustedCount, + createdAtMs, + updatedAtMs, + prevRecordHash, + recordSignature, + value + ); + } + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java new file mode 100644 index 00000000..9932f9c5 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/config/AppConfig.java @@ -0,0 +1,234 @@ +package sync.config; + +import java.time.Duration; + +public record AppConfig( + String rpcUrl, + String websocketUrl, + String programId, + String databaseUrl, + String databaseUser, + String databasePassword, + Duration pollInterval, + String commitment +) { + + public static final String DEFAULT_PROGRAM_ID = + "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"; + + public static final String FIXED_COMMITMENT = + "confirmed"; + + public static final String ENABLED_KEY = + "solana.users.sync.enabled"; + public static final String RPC_URL_KEY = + "solana.users.sync.rpcUrl"; + public static final String WEBSOCKET_URL_KEY = + "solana.users.sync.wsUrl"; + public static final String PROGRAM_ID_KEY = + "solana.users.sync.programId"; + public static final String DATABASE_URL_KEY = + "solana.users.sync.databaseUrl"; + public static final String DATABASE_USER_KEY = + "solana.users.sync.dbUser"; + public static final String DATABASE_PASSWORD_KEY = + "solana.users.sync.dbPassword"; + public static final String POLL_INTERVAL_KEY = + "solana.users.sync.pollIntervalSeconds"; + public static final String LEGACY_SOLANA_RPC_URL_KEY = + "solana.rpcUrl"; + + public static boolean isEnabled( + utils.config.AppConfig serverConfig + ) { + String value = + trimToNull( + serverConfig.getParam(ENABLED_KEY) + ); + + if (value == null) { + return false; + } + + return Boolean.parseBoolean(value); + } + + public static AppConfig fromServerConfig( + utils.config.AppConfig serverConfig + ) { + + String rpcUrl = + firstRequired( + serverConfig, + "Solana users sync RPC URL", + RPC_URL_KEY, + LEGACY_SOLANA_RPC_URL_KEY + ); + + String websocketUrl = + requireParam( + serverConfig, + WEBSOCKET_URL_KEY + ); + + String programId = + optionalParam( + serverConfig, + PROGRAM_ID_KEY + ); + + if (programId == null) { + programId = + DEFAULT_PROGRAM_ID; + } + + String databaseUrl = + requireParam( + serverConfig, + DATABASE_URL_KEY + ); + + String databaseUser = + requireParam( + serverConfig, + DATABASE_USER_KEY + ); + + String databasePassword = + requireParam( + serverConfig, + DATABASE_PASSWORD_KEY + ); + + long pollIntervalSeconds = + parsePositiveLong( + optionalParam( + serverConfig, + POLL_INTERVAL_KEY + ), + 300L, + POLL_INTERVAL_KEY + ); + + return new AppConfig( + rpcUrl, + websocketUrl, + programId, + databaseUrl, + databaseUser, + databasePassword, + Duration.ofSeconds( + pollIntervalSeconds + ), + FIXED_COMMITMENT + ); + } + + private static long parsePositiveLong( + String rawValue, + long defaultValue, + String envName + ) { + + if (rawValue == null) { + return defaultValue; + } + + try { + + long value = + Long.parseLong( + rawValue + ); + + if (value <= 0L) { + throw new IllegalArgumentException( + envName + " must be > 0" + ); + } + + return value; + + } catch (NumberFormatException exception) { + + throw new IllegalArgumentException( + envName + " must be a positive integer", + exception + ); + } + } + + private static String firstRequired( + utils.config.AppConfig serverConfig, + String humanName, + String... names + ) { + + for (String name : names) { + String value = + optionalParam( + serverConfig, + name + ); + + if (value != null) { + return value; + } + } + + throw new IllegalStateException( + "Missing required server config: " + + humanName + ); + } + + private static String requireParam( + utils.config.AppConfig serverConfig, + String name + ) { + + String value = + optionalParam( + serverConfig, + name + ); + + if (value == null) { + throw new IllegalStateException( + "Missing required server config: " + + name + ); + } + + return value; + } + + private static String optionalParam( + utils.config.AppConfig serverConfig, + String name + ) { + + String value = + serverConfig.getParam(name); + + return trimToNull(value); + } + + private static String trimToNull( + String value + ) { + + if (value == null) { + return null; + } + + String trimmed = + value.trim(); + + if (trimmed.isEmpty()) { + return null; + } + + return trimmed; + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java new file mode 100644 index 00000000..c86a6da3 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/ProgramAccountUpdate.java @@ -0,0 +1,12 @@ +package sync.model; + +public record ProgramAccountUpdate( + String address, + String owner, + long lamports, + long slot, + String dataBase64, + boolean executable, + Long rentEpoch +) { +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java new file mode 100644 index 00000000..62f12d22 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/model/SnapshotResult.java @@ -0,0 +1,9 @@ +package sync.model; + +import java.util.List; + +public record SnapshotResult( + long snapshotSlot, + List accounts +) { +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java new file mode 100644 index 00000000..5ef0b1c0 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -0,0 +1,822 @@ +package sync.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.codec.ShineUsersCodec; +import sync.config.AppConfig; +import sync.model.ProgramAccountUpdate; +import sync.model.SnapshotResult; +import sync.source.AccountUpdateListener; +import sync.source.ConnectionListener; +import sync.source.rpc.SolanaRpcClient; +import sync.source.rpc.SolanaWebSocketClient; +import sync.storage.postgres.PostgresStorageRepository; +import sync.util.SolanaPdaUtil; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class SolanaUsersSyncService + implements AutoCloseable { + + private static final int FULL_SNAPSHOT_PROGRESS_STEP = + 100; + + private static final Logger log = + LoggerFactory.getLogger( + SolanaUsersSyncService.class + ); + + private final AppConfig config; + private final ObjectMapper mapper; + private final PostgresStorageRepository storage; + private final SolanaRpcClient rpcClient; + private final SolanaWebSocketClient webSocketClient; + private final ExecutorService syncExecutor; + private final ScheduledExecutorService pollScheduler; + private final CompletableFuture readyFuture = + new CompletableFuture<>(); + private final AtomicBoolean closed = + new AtomicBoolean(false); + private final AtomicBoolean syncRequested = + new AtomicBoolean(false); + private final AtomicBoolean syncWorkerScheduled = + new AtomicBoolean(false); + private final String economyConfigPda; + + private volatile boolean initialSyncCompleted = + false; + + public SolanaUsersSyncService( + AppConfig config + ) throws Exception { + + this.config = + config; + + this.mapper = + new ObjectMapper(); + + this.storage = + new PostgresStorageRepository( + config.databaseUrl(), + config.databaseUser(), + config.databasePassword(), + mapper + ); + + this.rpcClient = + new SolanaRpcClient( + config.rpcUrl(), + config.programId(), + config.commitment() + ); + + this.webSocketClient = + new SolanaWebSocketClient( + config.websocketUrl(), + config.programId(), + config.commitment() + ); + + this.syncExecutor = + Executors.newSingleThreadExecutor( + runnable -> { + Thread thread = + new Thread( + runnable, + "solana-users-sync-worker" + ); + thread.setDaemon(true); + return thread; + } + ); + + this.pollScheduler = + Executors.newSingleThreadScheduledExecutor( + runnable -> { + Thread thread = + new Thread( + runnable, + "solana-users-sync-periodic" + ); + thread.setDaemon(true); + return thread; + } + ); + + this.economyConfigPda = + SolanaPdaUtil.findProgramAddress( + List.of( + "shine_users_economy_config" + .getBytes(StandardCharsets.UTF_8) + ), + config.programId() + ); + } + + public void start() + throws Exception { + + log.info( + "Starting sync service. programId={} economyConfigPda={} pollInterval={}", + config.programId(), + economyConfigPda, + config.pollInterval() + ); + + storage.updateLifecycleState( + "STARTING", + false, + null, + null, + null + ); + + webSocketClient.start( + new AccountUpdateListener() { + @Override + public void onAccountUpdate( + ProgramAccountUpdate update + ) { + log.debug( + "Realtime notification received. address={} slot={}", + update.address(), + update.slot() + ); + requestSync( + "realtime" + ); + } + }, + new ConnectionListener() { + @Override + public void onConnected( + boolean firstConnection + ) { + log.info( + "Solana websocket connected. firstConnection={}", + firstConnection + ); + requestSync( + firstConnection + ? "initial-connect" + : "reconnect" + ); + } + + @Override + public void onDisconnected( + Throwable cause + ) { + log.warn( + "Solana websocket disconnected: {}", + cause == null + ? "unknown" + : cause.getMessage() + ); + } + } + ); + + long periodSeconds = + config.pollInterval() + .getSeconds(); + + pollScheduler.scheduleWithFixedDelay( + () -> requestSync( + "periodic" + ), + periodSeconds, + periodSeconds, + TimeUnit.SECONDS + ); + } + + public void awaitReady() + throws Exception { + readyFuture.get(); + } + + public boolean isReady() { + return readyFuture.isDone() + && !readyFuture.isCompletedExceptionally(); + } + + private void requestSync( + String reason + ) { + + if (closed.get()) { + return; + } + + syncRequested.set(true); + + if (!syncWorkerScheduled.compareAndSet( + false, + true + )) { + return; + } + + syncExecutor.submit( + () -> runSyncLoop(reason) + ); + } + + private void runSyncLoop( + String firstReason + ) { + + String reason = + firstReason; + + try { + + while (!closed.get()) { + + boolean shouldRun = + syncRequested.getAndSet(false); + + if (!shouldRun) { + return; + } + + performSync(reason); + reason = "coalesced"; + } + + } catch (Exception exception) { + + log.error( + "Sync loop failed", + exception + ); + + try { + storage.updateLifecycleState( + "FAILED", + false, + exception.getMessage(), + System.currentTimeMillis(), + null + ); + } catch (Exception storageException) { + log.error( + "Failed to persist sync failure state", + storageException + ); + } + + readyFuture.completeExceptionally( + exception + ); + + } finally { + + syncWorkerScheduled.set(false); + + if (syncRequested.get() + && !closed.get() + && syncWorkerScheduled.compareAndSet( + false, + true + )) { + syncExecutor.submit( + () -> runSyncLoop("rescheduled") + ); + } + } + } + + private void performSync( + String reason + ) throws Exception { + + long nowMs = + System.currentTimeMillis(); + + PostgresStorageRepository.SyncStateSnapshot state = + storage.loadState(); + + log.info( + "Starting history sync. reason={} lastSeenSignature={}", + reason, + state.lastSeenSignature() + ); + + storage.updateLifecycleState( + initialSyncCompleted + ? "SYNCING" + : "BOOTSTRAPPING", + false, + null, + nowMs, + null + ); + + SolanaRpcClient.SignatureFetchResult fetchResult = + rpcClient.getSignaturesForAddressSince( + economyConfigPda, + state.lastSeenSignature() + ); + + if (state.lastSeenSignature() != null + && !fetchResult.anchorFound()) { + + log.error( + "History anchor signature not found anymore: {}. Running current-state full snapshot fallback.", + state.lastSeenSignature() + ); + + runFullSnapshotFallback( + state, + fetchResult, + nowMs + ); + + markReadyAfterSync( + state, + nowMs + ); + + return; + } + + if (fetchResult.signatures().isEmpty()) { + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + state.lastSeenSignature(), + state.lastSeenSlot(), + state.lastRelevantSignature(), + state.lastRelevantSlot(), + null, + state.economyConfigState(), + nowMs + ); + + storage.applyHistoryBatch( + List.of(), + List.of(), + newState + ); + + log.info( + "History sync completed with no new transactions." + ); + + markReadyAfterSync( + newState, + nowMs + ); + + return; + } + + List chronologicalSignatures = + new ArrayList<>( + fetchResult.signatures() + ); + + Collections.reverse( + chronologicalSignatures + ); + + List envelopes = + new ArrayList<>(); + + Set updatePdaAddresses = + new LinkedHashSet<>(); + + for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) { + + JsonNode transaction = + rpcClient.getTransactionJsonParsed( + signatureRecord.signature() + ); + + ParsedTxEnvelope envelope = + parseTransactionEnvelope( + signatureRecord, + transaction + ); + + envelopes.add( + envelope + ); + + if (envelope.parsedInstruction() != null + && envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA + && envelope.parsedInstruction().affectedPdaAddress() != null) { + updatePdaAddresses.add( + envelope.parsedInstruction() + .affectedPdaAddress() + ); + } + } + + Map currentSnapshots = + storage.getCurrentSnapshots( + updatePdaAddresses + ); + + List txEntries = + new ArrayList<>(); + + List snapshotsToPersist = + new ArrayList<>(); + + ShineUsersCodec.EconomyConfigState economyState = + state.economyConfigState(); + + String lastRelevantSignature = + state.lastRelevantSignature(); + + Long lastRelevantSlot = + state.lastRelevantSlot(); + + for (ParsedTxEnvelope envelope : envelopes) { + + ShineUsersCodec.ParsedInstruction parsedInstruction = + envelope.parsedInstruction(); + + String txKind = + parsedInstruction == null + ? "failed_or_unavailable" + : parsedInstruction.kind().name(); + + boolean relevant = + false; + + String affectedPdaAddress = + null; + + String affectedLogin = + null; + + if (parsedInstruction != null) { + + if (parsedInstruction.kind() == ShineUsersCodec.TxKind.INIT_USERS_ECONOMY_CONFIG + || parsedInstruction.kind() == ShineUsersCodec.TxKind.UPDATE_USERS_ECONOMY_CONFIG) { + economyState = + parsedInstruction.economyConfigState(); + } + + if (parsedInstruction.relevant() + && parsedInstruction.userPdaMutation() != null) { + + relevant = true; + affectedPdaAddress = parsedInstruction.affectedPdaAddress(); + affectedLogin = parsedInstruction.affectedLogin(); + + ShineUsersCodec.UserPdaSnapshot snapshot; + + if (parsedInstruction.kind() == ShineUsersCodec.TxKind.CREATE_USER_PDA) { + + if (economyState == null) { + economyState = + ShineUsersCodec.EconomyConfigState.initial(); + log.warn( + "Economy config state was absent while processing create tx {}. Falling back to initial constants.", + envelope.signatureRecord().signature() + ); + } + + snapshot = + ShineUsersCodec.buildCreateSnapshot( + parsedInstruction.userPdaMutation(), + economyState, + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot() + ); + + } else { + + ShineUsersCodec.UserPdaSnapshot previous = + currentSnapshots.get( + affectedPdaAddress + ); + + if (previous == null) { + throw new IllegalStateException( + "Missing previous snapshot for update PDA " + + affectedPdaAddress + ); + } + + snapshot = + ShineUsersCodec.buildUpdateSnapshot( + parsedInstruction.userPdaMutation(), + previous, + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot() + ); + } + + currentSnapshots.put( + snapshot.pdaAddress(), + snapshot + ); + + snapshotsToPersist.add( + snapshot + ); + + lastRelevantSignature = + envelope.signatureRecord().signature(); + + lastRelevantSlot = + envelope.signatureRecord().slot(); + } + } + + txEntries.add( + new PostgresStorageRepository.TxHistoryEntry( + envelope.signatureRecord().signature(), + envelope.signatureRecord().slot(), + envelope.signatureRecord().blockTime(), + txKind, + relevant, + affectedPdaAddress, + affectedLogin, + envelope.rawTransactionJson(), + nowMs + ) + ); + } + + SolanaRpcClient.SignatureRecord newestSeen = + fetchResult.signatures() + .get(0); + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + newestSeen.signature(), + newestSeen.slot(), + lastRelevantSignature, + lastRelevantSlot, + null, + economyState, + nowMs + ); + + storage.applyHistoryBatch( + txEntries, + snapshotsToPersist, + newState + ); + + log.info( + "History sync completed. txCount={} relevantCount={} latestSignature={}", + txEntries.size(), + snapshotsToPersist.size(), + newestSeen.signature() + ); + + markReadyAfterSync( + newState, + nowMs + ); + } + + private void runFullSnapshotFallback( + PostgresStorageRepository.SyncStateSnapshot state, + SolanaRpcClient.SignatureFetchResult fetchResult, + long nowMs + ) throws Exception { + + log.warn( + "Starting full snapshot fallback because incremental history anchor is unavailable." + ); + + SnapshotResult snapshotResult = + rpcClient.loadFullSnapshot(); + + log.info( + "Full snapshot downloaded. snapshotSlot={} rawAccounts={}", + snapshotResult.snapshotSlot(), + snapshotResult.accounts().size() + ); + + List currentSnapshots = + new ArrayList<>(); + + int processedAccounts = + 0; + + for (ProgramAccountUpdate account : snapshotResult.accounts()) { + + processedAccounts++; + + try { + currentSnapshots.add( + ShineUsersCodec.parseUserPdaAccount( + account.address(), + account.slot(), + account.dataBase64(), + state.lastSeenSignature() + ) + ); + } catch (Exception ignored) { + } + + if (processedAccounts == 1 + || processedAccounts % FULL_SNAPSHOT_PROGRESS_STEP == 0 + || processedAccounts == snapshotResult.accounts().size()) { + log.info( + "Full snapshot parse progress: {}/{} accounts, {} user PDA snapshots accepted.", + processedAccounts, + snapshotResult.accounts().size(), + currentSnapshots.size() + ); + } + } + + String newestSignature = + fetchResult.signatures().isEmpty() + ? state.lastSeenSignature() + : fetchResult.signatures().get(0).signature(); + + Long newestSlot = + fetchResult.signatures().isEmpty() + ? state.lastSeenSlot() + : fetchResult.signatures().get(0).slot(); + + PostgresStorageRepository.SyncStateSnapshot newState = + new PostgresStorageRepository.SyncStateSnapshot( + "READY", + true, + nowMs, + nowMs, + newestSignature, + newestSlot, + state.lastRelevantSignature(), + state.lastRelevantSlot(), + "history_anchor_missing_full_snapshot_fallback", + state.economyConfigState(), + nowMs + ); + + storage.replaceCurrentFromFullSnapshot( + currentSnapshots, + newState + ); + + log.warn( + "Full snapshot fallback completed. currentSnapshots={} newestSignature={} newestSlot={}", + currentSnapshots.size(), + newestSignature, + newestSlot + ); + } + + private void markReadyAfterSync( + PostgresStorageRepository.SyncStateSnapshot state, + long nowMs + ) throws Exception { + + initialSyncCompleted = true; + + if (!readyFuture.isDone()) { + readyFuture.complete(null); + log.info( + "Sync service entered READY state." + ); + } + + storage.updateLifecycleState( + "READY", + true, + state.lastError(), + nowMs, + nowMs + ); + } + + private ParsedTxEnvelope parseTransactionEnvelope( + SolanaRpcClient.SignatureRecord signatureRecord, + JsonNode transaction + ) { + + if (signatureRecord.failed()) { + return new ParsedTxEnvelope( + signatureRecord, + new ShineUsersCodec.ParsedInstruction( + ShineUsersCodec.TxKind.OTHER, + false, + null, + null, + null, + null + ), + transaction == null + ? "{\"failed\":true,\"error\":" + + String.valueOf(signatureRecord.errorJson()) + "}" + : transaction.toString() + ); + } + + if (transaction == null + || transaction.isNull()) { + return new ParsedTxEnvelope( + signatureRecord, + null, + "{\"transaction\":null}" + ); + } + + JsonNode instructions = + transaction.path("transaction") + .path("message") + .path("instructions"); + + if (instructions.isArray()) { + for (JsonNode instruction : instructions) { + ShineUsersCodec.ParsedInstruction parsedInstruction = + ShineUsersCodec.parseShineUsersInstruction( + instruction, + config.programId() + ); + + if (parsedInstruction != null) { + return new ParsedTxEnvelope( + signatureRecord, + parsedInstruction, + transaction.toString() + ); + } + } + } + + return new ParsedTxEnvelope( + signatureRecord, + new ShineUsersCodec.ParsedInstruction( + ShineUsersCodec.TxKind.OTHER, + false, + null, + null, + null, + null + ), + transaction.toString() + ); + } + + @Override + public void close() { + + if (!closed.compareAndSet( + false, + true + )) { + return; + } + + try { + pollScheduler.shutdownNow(); + } catch (Exception ignored) { + } + + try { + webSocketClient.close(); + } catch (Exception ignored) { + } + + try { + syncExecutor.shutdownNow(); + } catch (Exception ignored) { + } + + try { + rpcClient.close(); + } catch (Exception ignored) { + } + + try { + storage.close(); + } catch (Exception ignored) { + } + } + + private record ParsedTxEnvelope( + SolanaRpcClient.SignatureRecord signatureRecord, + ShineUsersCodec.ParsedInstruction parsedInstruction, + String rawTransactionJson + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java new file mode 100644 index 00000000..619ccb55 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/AccountUpdateListener.java @@ -0,0 +1,11 @@ +package sync.source; + +import sync.model.ProgramAccountUpdate; + +@FunctionalInterface +public interface AccountUpdateListener { + + void onAccountUpdate( + ProgramAccountUpdate update + ); +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java new file mode 100644 index 00000000..4c8badce --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/ConnectionListener.java @@ -0,0 +1,12 @@ +package sync.source; + +public interface ConnectionListener { + + void onConnected( + boolean firstConnection + ); + + void onDisconnected( + Throwable cause + ); +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java new file mode 100644 index 00000000..bde598d8 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaRpcClient.java @@ -0,0 +1,737 @@ +package sync.source.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.model.ProgramAccountUpdate; +import sync.model.SnapshotResult; + +import java.io.IOException; +import java.time.Duration; +import java.util.*; + +public final class SolanaRpcClient + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaRpcClient.class + ); + + private static final MediaType JSON = + MediaType.get("application/json"); + + private static final int SIGNATURE_PAGE_SIZE = + 1000; + + private static final int ACCOUNT_BATCH_SIZE = + 100; + + private final String rpcUrl; + private final String programId; + private final String commitment; + private final ObjectMapper mapper = + new ObjectMapper(); + private final OkHttpClient httpClient = + new OkHttpClient.Builder() + .callTimeout(Duration.ofMinutes(2)) + .build(); + + public SolanaRpcClient( + String rpcUrl, + String programId, + String commitment + ) { + this.rpcUrl = rpcUrl; + this.programId = programId; + this.commitment = commitment; + } + + public SnapshotResult loadFullSnapshot() + throws IOException { + + log.info( + "Requesting full snapshot via getProgramAccounts. programId={}", + programId + ); + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 100, + "method", "getProgramAccounts", + "params", List.of( + programId, + Map.of( + "encoding", "base64", + "commitment", commitment, + "withContext", true + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + log.info( + "Full snapshot RPC response received. Parsing account list..." + ); + + JsonNode result = + root.path("result"); + + long snapshotSlot = + result.path("context") + .path("slot") + .asLong(-1); + + if (snapshotSlot < 0) { + throw new IOException( + "Missing context.slot in getProgramAccounts" + ); + } + + JsonNode values = + result.path("value"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getProgramAccounts response" + ); + } + + List accounts = + new ArrayList<>(); + + for (JsonNode item : values) { + + JsonNode account = + item.path("account"); + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + continue; + } + + accounts.add( + parseAccount( + item.path("pubkey") + .asText(), + account, + snapshotSlot + ) + ); + } + + log.info( + "Full snapshot RPC parsed. accounts={} snapshotSlot={}", + accounts.size(), + snapshotSlot + ); + + return new SnapshotResult( + snapshotSlot, + accounts + ); + } + + public SignatureFetchResult getSignaturesForAddressSince( + String address, + String knownSignature + ) throws IOException { + + List signatures = + new ArrayList<>(); + + String before = + null; + + boolean anchorFound = + knownSignature == null; + + while (true) { + + Map options = + new LinkedHashMap<>(); + + options.put( + "limit", + SIGNATURE_PAGE_SIZE + ); + + options.put( + "commitment", + commitment + ); + + if (before != null) { + options.put( + "before", + before + ); + } + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 103, + "method", "getSignaturesForAddress", + "params", List.of( + address, + options + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getSignaturesForAddress response" + ); + } + + if (values.isEmpty()) { + break; + } + + for (JsonNode item : values) { + + String signature = + item.path("signature") + .asText(""); + + if (signature.isBlank()) { + continue; + } + + if (knownSignature != null + && knownSignature.equals(signature)) { + anchorFound = true; + return new SignatureFetchResult( + signatures, + true + ); + } + + Long blockTime = + item.hasNonNull("blockTime") + ? item.get("blockTime").asLong() + : null; + + signatures.add( + new SignatureRecord( + signature, + item.path("slot") + .asLong(-1), + blockTime, + item.hasNonNull("err"), + item.path("err").toString() + ) + ); + } + + JsonNode last = + values.get(values.size() - 1); + + before = + last.path("signature") + .asText(""); + + if (before.isBlank() + || values.size() < SIGNATURE_PAGE_SIZE) { + break; + } + } + + return new SignatureFetchResult( + signatures, + anchorFound + ); + } + + public JsonNode getTransactionJsonParsed( + String signature + ) throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 104, + "method", "getTransaction", + "params", List.of( + signature, + Map.of( + "encoding", + "jsonParsed", + "commitment", + commitment, + "maxSupportedTransactionVersion", + 0 + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode result = + root.get("result"); + + if (result == null + || result.isNull()) { + return null; + } + + return result; + } + + public long getCurrentSlot() + throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 101, + "method", "getSlot", + "params", List.of( + Map.of( + "commitment", + commitment + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + long slot = + root.path("result") + .asLong(-1); + + if (slot < 0) { + throw new IOException( + "Invalid getSlot response" + ); + } + + return slot; + } + + public AccountBatchResult getCurrentAccounts( + Collection addresses, + long recoverySlot + ) throws IOException { + + List updates = + new ArrayList<>(); + + List missingAddresses = + new ArrayList<>(); + + List addressList = + new ArrayList<>(addresses); + + for (int offset = 0; + offset < addressList.size(); + offset += ACCOUNT_BATCH_SIZE) { + + int end = + Math.min( + offset + ACCOUNT_BATCH_SIZE, + addressList.size() + ); + + List batch = + addressList.subList( + offset, + end + ); + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 105, + "method", "getMultipleAccounts", + "params", List.of( + batch, + Map.of( + "encoding", + "base64", + "commitment", + commitment + ) + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result") + .path("value"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getMultipleAccounts response" + ); + } + + if (values.size() != batch.size()) { + throw new IOException( + "Unexpected getMultipleAccounts account count" + ); + } + + for (int i = 0; i < batch.size(); i++) { + + String address = + batch.get(i); + + JsonNode account = + values.get(i); + + if (account == null + || account.isNull()) { + missingAddresses.add(address); + continue; + } + + String owner = + account.path("owner") + .asText(""); + + if (!programId.equals(owner)) { + continue; + } + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + throw new IOException( + "Unexpected account.data format for " + + address + ); + } + + updates.add( + parseAccount( + address, + account, + recoverySlot + ) + ); + } + } + + return new AccountBatchResult( + updates, + missingAddresses + ); + } + + public long getFirstAvailableBlock() + throws IOException { + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 102, + "method", "getFirstAvailableBlock" + ); + + JsonNode root = + executeRpc(payload); + + return root.path("result") + .asLong(-1); + } + + public List getSignaturesAfterSlot( + long fromSlot, + long targetSlot + ) throws IOException { + + List signatures = + new ArrayList<>(); + + String before = + null; + + boolean lowerBoundaryReached = + false; + + while (!lowerBoundaryReached) { + + Map options = + new LinkedHashMap<>(); + + options.put("limit", SIGNATURE_PAGE_SIZE); + options.put("commitment", commitment); + + if (before != null) { + options.put("before", before); + } + + Map payload = + Map.of( + "jsonrpc", "2.0", + "id", 106, + "method", "getSignaturesForAddress", + "params", List.of( + programId, + options + ) + ); + + JsonNode root = + executeRpc(payload); + + JsonNode values = + root.path("result"); + + if (!values.isArray()) { + throw new IOException( + "Unexpected getSignaturesForAddress response" + ); + } + + if (values.isEmpty()) { + break; + } + + for (JsonNode item : values) { + + long slot = + item.path("slot") + .asLong(-1); + + if (slot < 0) { + continue; + } + + if (slot <= fromSlot) { + lowerBoundaryReached = true; + break; + } + + if (slot > targetSlot) { + continue; + } + + String signature = + item.path("signature") + .asText(""); + + if (signature.isBlank()) { + continue; + } + + JsonNode error = + item.get("err"); + + if (error != null + && !error.isNull()) { + continue; + } + + signatures.add( + new SignatureInfo( + signature, + slot + ) + ); + } + + JsonNode last = + values.get(values.size() - 1); + + before = + last.path("signature") + .asText(""); + + if (before.isBlank() + || values.size() < SIGNATURE_PAGE_SIZE) { + break; + } + } + + return signatures; + } + + public Set getTouchedAddresses( + List signatures + ) throws IOException { + + Set addresses = + new LinkedHashSet<>(); + + for (SignatureInfo signatureInfo : signatures) { + + JsonNode transaction = + getTransactionJsonParsed( + signatureInfo.signature() + ); + + if (transaction == null + || transaction.isNull()) { + throw new IOException( + "Transaction unavailable during recovery: " + + signatureInfo.signature() + ); + } + + JsonNode accountKeys = + transaction.path("transaction") + .path("message") + .path("accountKeys"); + + if (!accountKeys.isArray()) { + throw new IOException( + "Missing accountKeys for transaction: " + + signatureInfo.signature() + ); + } + + for (JsonNode keyNode : accountKeys) { + + String pubkey; + + if (keyNode.isTextual()) { + pubkey = keyNode.asText(); + } else { + pubkey = keyNode.path("pubkey") + .asText(""); + } + + if (!pubkey.isBlank()) { + addresses.add(pubkey); + } + } + } + + addresses.remove(programId); + return addresses; + } + + private ProgramAccountUpdate parseAccount( + String address, + JsonNode account, + long slot + ) { + + JsonNode data = + account.path("data"); + + return new ProgramAccountUpdate( + address, + account.path("owner") + .asText(), + account.path("lamports") + .asLong(), + slot, + data.get(0) + .asText(), + account.path("executable") + .asBoolean(false), + account.hasNonNull("rentEpoch") + ? account.get("rentEpoch").asLong() + : null + ); + } + + private JsonNode executeRpc( + Map payload + ) throws IOException { + + Request request = + new Request.Builder() + .url(rpcUrl) + .post( + RequestBody.create( + mapper.writeValueAsBytes(payload), + JSON + ) + ) + .build(); + + try (Response response = + httpClient.newCall(request).execute()) { + + if (!response.isSuccessful()) { + throw new IOException( + "Solana RPC HTTP error: " + response.code() + ); + } + + ResponseBody body = + response.body(); + + if (body == null) { + throw new IOException( + "Solana RPC returned empty body" + ); + } + + JsonNode root = + mapper.readTree( + body.string() + ); + + if (root.has("error")) { + throw new IOException( + "Solana RPC error: " + root.get("error") + ); + } + + return root; + } + } + + @Override + public void close() { + + httpClient.dispatcher() + .executorService() + .shutdown(); + httpClient.connectionPool() + .evictAll(); + } + + public record SignatureRecord( + String signature, + long slot, + Long blockTime, + boolean failed, + String errorJson + ) { + } + + public record SignatureFetchResult( + List signatures, + boolean anchorFound + ) { + } + + public record AccountBatchResult( + List updates, + List missingAddresses + ) { + } + + public record SignatureInfo( + String signature, + long slot + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java new file mode 100644 index 00000000..9320007e --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/source/rpc/SolanaWebSocketClient.java @@ -0,0 +1,542 @@ +package sync.source.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.model.ProgramAccountUpdate; +import sync.source.AccountUpdateListener; +import sync.source.ConnectionListener; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public final class SolanaWebSocketClient + extends WebSocketListener + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaWebSocketClient.class + ); + + private final String websocketUrl; + private final String programId; + private final String commitment; + + private final ObjectMapper mapper = + new ObjectMapper(); + + private final OkHttpClient httpClient; + + private final ScheduledExecutorService scheduler; + + private final AtomicBoolean closed = + new AtomicBoolean(false); + + private final AtomicInteger reconnectAttempt = + new AtomicInteger(0); + + private final AtomicBoolean everSubscribed = + new AtomicBoolean(false); + + private volatile WebSocket webSocket; + + private volatile AccountUpdateListener + accountUpdateListener; + + private volatile ConnectionListener + connectionListener; + + public SolanaWebSocketClient( + String websocketUrl, + String programId, + String commitment + ) { + this.websocketUrl = websocketUrl; + this.programId = programId; + this.commitment = commitment; + + this.httpClient = + new OkHttpClient.Builder() + .readTimeout( + Duration.ZERO + ) + .pingInterval( + Duration.ofSeconds(20) + ) + .build(); + + this.scheduler = + Executors + .newSingleThreadScheduledExecutor( + runnable -> { + + Thread thread = + new Thread( + runnable, + "solana-rpc-reconnect" + ); + + thread.setDaemon( + true + ); + + return thread; + } + ); + } + + public void start( + AccountUpdateListener accountUpdateListener, + ConnectionListener connectionListener + ) { + + this.accountUpdateListener = + accountUpdateListener; + + this.connectionListener = + connectionListener; + + connect(); + } + + private void connect() { + + if (closed.get()) { + return; + } + + log.info( + "Connecting to Solana WebSocket: {}", + websocketUrl + ); + + Request request = + new Request.Builder() + .url( + websocketUrl + ) + .build(); + + this.webSocket = + httpClient.newWebSocket( + request, + this + ); + } + + @Override + public void onOpen( + WebSocket webSocket, + Response response + ) { + + reconnectAttempt.set( + 0 + ); + + log.info( + "WebSocket connected" + ); + + sendProgramSubscribe( + webSocket + ); + } + + private void sendProgramSubscribe( + WebSocket webSocket + ) { + + try { + + Map request = + Map.of( + "jsonrpc", + "2.0", + "id", + 1, + "method", + "programSubscribe", + "params", + List.of( + programId, + Map.of( + "encoding", + "base64", + "commitment", + commitment + ) + ) + ); + + String payload = + mapper.writeValueAsString( + request + ); + + if (!webSocket.send( + payload + )) { + throw new IllegalStateException( + "WebSocket rejected subscription request" + ); + } + + log.info( + "Subscription request sent for program: {}", + programId + ); + + } catch (Exception exception) { + + log.error( + "Failed to send subscription request", + exception + ); + + webSocket.cancel(); + } + } + + @Override + public void onMessage( + WebSocket webSocket, + String text + ) { + + try { + + JsonNode root = + mapper.readTree( + text + ); + + if ( + root.has("id") + && root.has("result") + && root.get("id").asInt() == 1 + ) { + + log.info( + "Subscribed successfully. subscriptionId={}", + root.get("result").asText() + ); + + boolean firstConnection = + everSubscribed + .compareAndSet( + false, + true + ); + + ConnectionListener listener = + connectionListener; + + if (listener != null) { + listener.onConnected( + firstConnection + ); + } + + return; + } + + if (root.has("error")) { + + log.error( + "Solana websocket RPC error: {}", + root.get("error") + ); + + return; + } + + if (!"programNotification" + .equals( + root + .path("method") + .asText() + )) { + return; + } + + ProgramAccountUpdate update = + parseProgramNotification( + root + ); + + log.debug( + "Account update. address={} slot={} base64Chars={}", + update.address(), + update.slot(), + update.dataBase64().length() + ); + + AccountUpdateListener listener = + accountUpdateListener; + + if (listener != null) { + listener.onAccountUpdate( + update + ); + } + + } catch (Exception exception) { + + log.error( + "Failed to process WebSocket message. raw={}", + text, + exception + ); + } + } + + private ProgramAccountUpdate parseProgramNotification( + JsonNode root + ) { + + JsonNode result = + root + .path("params") + .path("result"); + + JsonNode context = + result.path("context"); + + JsonNode value = + result.path("value"); + + JsonNode account = + value.path("account"); + + JsonNode data = + account.path("data"); + + if (!data.isArray() + || data.isEmpty()) { + throw new IllegalArgumentException( + "Unexpected account.data format" + ); + } + + return new ProgramAccountUpdate( + requiredText( + value, + "pubkey" + ), + requiredText( + account, + "owner" + ), + requiredLong( + account, + "lamports" + ), + requiredLong( + context, + "slot" + ), + data.get(0).asText(), + account.path( + "executable" + ).asBoolean(false), + account.hasNonNull( + "rentEpoch" + ) + ? account + .get("rentEpoch") + .asLong() + : null + ); + } + + private String requiredText( + JsonNode node, + String field + ) { + + JsonNode value = + node.get(field); + + if (value == null + || value.isNull() + || value.asText().isBlank()) { + throw new IllegalArgumentException( + "Missing field: " + + field + ); + } + + return value.asText(); + } + + private long requiredLong( + JsonNode node, + String field + ) { + + JsonNode value = + node.get(field); + + if (value == null + || !value.isNumber()) { + throw new IllegalArgumentException( + "Missing numeric field: " + + field + ); + } + + return value.asLong(); + } + + @Override + public void onClosed( + WebSocket webSocket, + int code, + String reason + ) { + + log.warn( + "WebSocket closed. code={} reason={}", + code, + reason + ); + + notifyDisconnected( + new IllegalStateException( + "WebSocket closed: " + + reason + ) + ); + + scheduleReconnect(); + } + + @Override + public void onFailure( + WebSocket webSocket, + Throwable throwable, + Response response + ) { + + log.error( + "WebSocket failure", + throwable + ); + + if (response != null) { + log.error( + "WebSocket HTTP status: {}", + response.code() + ); + } + + notifyDisconnected( + throwable + ); + + scheduleReconnect(); + } + + private void notifyDisconnected( + Throwable cause + ) { + + ConnectionListener listener = + connectionListener; + + if (listener != null) { + + try { + listener.onDisconnected( + cause + ); + } catch (Exception exception) { + log.error( + "Connection listener failed", + exception + ); + } + } + } + + private void scheduleReconnect() { + + if (closed.get()) { + return; + } + + int attempt = + reconnectAttempt + .incrementAndGet(); + + long delaySeconds = + switch ( + Math.min( + attempt, + 5 + ) + ) { + case 1 -> 1; + case 2 -> 2; + case 3 -> 5; + case 4 -> 10; + default -> 30; + }; + + log.warn( + "Reconnect scheduled in {} seconds (attempt {})", + delaySeconds, + attempt + ); + + scheduler.schedule( + this::connect, + delaySeconds, + TimeUnit.SECONDS + ); + } + + public void stop() { + close(); + } + + @Override + public void close() { + + if (!closed.compareAndSet( + false, + true + )) { + return; + } + + WebSocket socket = + this.webSocket; + + if (socket != null) { + socket.close( + 1000, + "Application shutdown" + ); + } + + scheduler.shutdownNow(); + + httpClient + .dispatcher() + .executorService() + .shutdown(); + + httpClient + .connectionPool() + .evictAll(); + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java new file mode 100644 index 00000000..cdbe7d27 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -0,0 +1,1278 @@ +package sync.storage.postgres; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.codec.ShineUsersCodec; + +import java.sql.*; +import java.util.*; + +public final class PostgresStorageRepository + implements AutoCloseable { + + private static final Logger log = + LoggerFactory.getLogger( + PostgresStorageRepository.class + ); + + private final String databaseUrl; + private final String databaseUser; + private final String databasePassword; + private final ObjectMapper mapper; + + public PostgresStorageRepository( + String databaseUrl, + String databaseUser, + String databasePassword, + ObjectMapper mapper + ) throws Exception { + + this.databaseUrl = + databaseUrl; + this.databaseUser = + databaseUser; + this.databasePassword = + databasePassword; + this.mapper = + mapper; + + migrate(); + + log.info( + "PostgreSQL schema ready: {}", + databaseUrl + ); + } + + public synchronized SyncStateSnapshot loadState() + throws Exception { + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT " + + "status, ready, last_poll_at_ms, " + + "last_successful_poll_at_ms, " + + "last_seen_signature, last_seen_slot, " + + "last_relevant_signature, last_relevant_slot, " + + "last_error, economy_config_version, " + + "registration_fee_lamports, " + + "lamports_per_limit_step, " + + "start_bonus_limit, updated_at_ms " + + "FROM solana_sync_state WHERE id = 1" + ); + ResultSet resultSet = + statement.executeQuery()) { + + if (!resultSet.next()) { + return new SyncStateSnapshot( + "EMPTY", + false, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + } + + Integer economyVersion = + getNullableInt( + resultSet, + "economy_config_version" + ); + + ShineUsersCodec.EconomyConfigState economyConfigState = + null; + + if (economyVersion != null) { + economyConfigState = + new ShineUsersCodec.EconomyConfigState( + economyVersion, + resultSet.getLong( + "registration_fee_lamports" + ), + resultSet.getLong( + "lamports_per_limit_step" + ), + resultSet.getLong( + "start_bonus_limit" + ) + ); + } + + return new SyncStateSnapshot( + resultSet.getString("status"), + resultSet.getBoolean("ready"), + getNullableLong( + resultSet, + "last_poll_at_ms" + ), + getNullableLong( + resultSet, + "last_successful_poll_at_ms" + ), + resultSet.getString("last_seen_signature"), + getNullableLong( + resultSet, + "last_seen_slot" + ), + resultSet.getString("last_relevant_signature"), + getNullableLong( + resultSet, + "last_relevant_slot" + ), + resultSet.getString("last_error"), + economyConfigState, + getNullableLong( + resultSet, + "updated_at_ms" + ) + ); + } + } + + public synchronized boolean hasCurrentData() + throws Exception { + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT 1 FROM solana_user_pda_current LIMIT 1" + ); + ResultSet resultSet = + statement.executeQuery()) { + + return resultSet.next(); + } + } + + public synchronized Map getCurrentSnapshots( + Collection pdaAddresses + ) throws Exception { + + Map result = + new LinkedHashMap<>(); + + if (pdaAddresses == null + || pdaAddresses.isEmpty()) { + return result; + } + + String placeholders = + String.join( + ",", + Collections.nCopies( + pdaAddresses.size(), + "?" + ) + ); + + String sql = + "SELECT * FROM solana_user_pda_current " + + "WHERE pda_address IN (" + placeholders + ")"; + + try (Connection connection = + openConnection(); + PreparedStatement statement = + connection.prepareStatement(sql)) { + + int index = 1; + + for (String address : pdaAddresses) { + statement.setString( + index++, + address + ); + } + + try (ResultSet resultSet = + statement.executeQuery()) { + + while (resultSet.next()) { + ShineUsersCodec.UserPdaSnapshot snapshot = + mapSnapshot(resultSet); + result.put( + snapshot.pdaAddress(), + snapshot + ); + } + } + } + + return result; + } + + public synchronized void updateLifecycleState( + String status, + boolean ready, + String lastError, + Long lastPollAtMs, + Long lastSuccessfulPollAtMs + ) throws Exception { + + SyncStateSnapshot current = + loadState(); + + upsertSyncState( + new SyncStateSnapshot( + status, + ready, + lastPollAtMs != null + ? lastPollAtMs + : current.lastPollAtMs(), + lastSuccessfulPollAtMs != null + ? lastSuccessfulPollAtMs + : current.lastSuccessfulPollAtMs(), + current.lastSeenSignature(), + current.lastSeenSlot(), + current.lastRelevantSignature(), + current.lastRelevantSlot(), + lastError, + current.economyConfigState(), + System.currentTimeMillis() + ) + ); + } + + public synchronized void applyHistoryBatch( + List txEntries, + List snapshots, + SyncStateSnapshot newState + ) throws Exception { + + try (Connection connection = + openConnection()) { + + connection.setAutoCommit(false); + + try { + + upsertTxHistory( + connection, + txEntries + ); + + insertHistorySnapshots( + connection, + snapshots + ); + + upsertCurrentSnapshots( + connection, + snapshots + ); + + upsertSyncState( + connection, + newState + ); + + connection.commit(); + + } catch (Exception exception) { + + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(true); + } + } + } + + public synchronized void replaceCurrentFromFullSnapshot( + List snapshots, + SyncStateSnapshot newState + ) throws Exception { + + try (Connection connection = + openConnection()) { + + connection.setAutoCommit(false); + + try (Statement statement = + connection.createStatement()) { + + statement.executeUpdate( + "TRUNCATE TABLE solana_user_pda_current" + ); + + upsertCurrentSnapshots( + connection, + snapshots + ); + + upsertSyncState( + connection, + newState + ); + + connection.commit(); + + } catch (Exception exception) { + + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(true); + } + } + } + + private void upsertTxHistory( + Connection connection, + List txEntries + ) throws Exception { + + if (txEntries == null + || txEntries.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_sync_tx_history (" + + "signature, slot, block_time, tx_kind, " + + "is_relevant, affected_pda_address, affected_login, " + + "raw_summary_json, processed_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (signature) DO UPDATE SET " + + "slot = EXCLUDED.slot, " + + "block_time = EXCLUDED.block_time, " + + "tx_kind = EXCLUDED.tx_kind, " + + "is_relevant = EXCLUDED.is_relevant, " + + "affected_pda_address = EXCLUDED.affected_pda_address, " + + "affected_login = EXCLUDED.affected_login, " + + "raw_summary_json = EXCLUDED.raw_summary_json, " + + "processed_at_ms = EXCLUDED.processed_at_ms"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + for (TxHistoryEntry entry : txEntries) { + + statement.setString( + 1, + entry.signature() + ); + statement.setLong( + 2, + entry.slot() + ); + if (entry.blockTime() == null) { + statement.setNull( + 3, + Types.BIGINT + ); + } else { + statement.setLong( + 3, + entry.blockTime() + ); + } + statement.setString( + 4, + entry.txKind() + ); + statement.setBoolean( + 5, + entry.relevant() + ); + statement.setString( + 6, + entry.affectedPdaAddress() + ); + statement.setString( + 7, + entry.affectedLogin() + ); + statement.setString( + 8, + entry.rawSummaryJson() + ); + statement.setLong( + 9, + entry.processedAtMs() + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void insertHistorySnapshots( + Connection connection, + List snapshots + ) throws Exception { + + if (snapshots == null + || snapshots.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_user_pda_history (" + + "tx_signature, slot, block_time, pda_address, login, " + + "record_number, recovery_key, root_key, client_key, " + + "blockchain_name, blockchain_key, paid_limit_bytes, " + + "used_bytes, last_block_number, last_block_hash, " + + "last_block_signature, arweave_tx_id, is_server, " + + "address_format_type, address_format_version, " + + "server_address, sync_servers_json, access_servers_json, " + + "sessions_mode, sessions_json, trusted_count, " + + "created_at_ms, updated_at_ms, prev_record_hash, " + + "record_signature, raw_data_base64, saved_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (pda_address, record_number) DO NOTHING"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + long nowMs = + System.currentTimeMillis(); + + for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) { + + bindSnapshot( + statement, + snapshot, + nowMs + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void upsertCurrentSnapshots( + Connection connection, + List snapshots + ) throws Exception { + + if (snapshots == null + || snapshots.isEmpty()) { + return; + } + + String sql = + "INSERT INTO solana_user_pda_current (" + + "pda_address, login, record_number, slot, last_tx_signature, " + + "recovery_key, root_key, client_key, blockchain_name, " + + "blockchain_key, paid_limit_bytes, used_bytes, " + + "last_block_number, last_block_hash, last_block_signature, " + + "arweave_tx_id, is_server, address_format_type, " + + "address_format_version, server_address, sync_servers_json, " + + "access_servers_json, sessions_mode, sessions_json, " + + "trusted_count, created_at_ms, updated_at_ms, " + + "prev_record_hash, record_signature, raw_data_base64, " + + "first_seen_at_ms, last_synced_at_ms" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (pda_address) DO UPDATE SET " + + "login = EXCLUDED.login, " + + "record_number = EXCLUDED.record_number, " + + "slot = EXCLUDED.slot, " + + "last_tx_signature = EXCLUDED.last_tx_signature, " + + "recovery_key = EXCLUDED.recovery_key, " + + "root_key = EXCLUDED.root_key, " + + "client_key = EXCLUDED.client_key, " + + "blockchain_name = EXCLUDED.blockchain_name, " + + "blockchain_key = EXCLUDED.blockchain_key, " + + "paid_limit_bytes = EXCLUDED.paid_limit_bytes, " + + "used_bytes = EXCLUDED.used_bytes, " + + "last_block_number = EXCLUDED.last_block_number, " + + "last_block_hash = EXCLUDED.last_block_hash, " + + "last_block_signature = EXCLUDED.last_block_signature, " + + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "is_server = EXCLUDED.is_server, " + + "address_format_type = EXCLUDED.address_format_type, " + + "address_format_version = EXCLUDED.address_format_version, " + + "server_address = EXCLUDED.server_address, " + + "sync_servers_json = EXCLUDED.sync_servers_json, " + + "access_servers_json = EXCLUDED.access_servers_json, " + + "sessions_mode = EXCLUDED.sessions_mode, " + + "sessions_json = EXCLUDED.sessions_json, " + + "trusted_count = EXCLUDED.trusted_count, " + + "created_at_ms = EXCLUDED.created_at_ms, " + + "updated_at_ms = EXCLUDED.updated_at_ms, " + + "prev_record_hash = EXCLUDED.prev_record_hash, " + + "record_signature = EXCLUDED.record_signature, " + + "raw_data_base64 = EXCLUDED.raw_data_base64, " + + "last_synced_at_ms = EXCLUDED.last_synced_at_ms " + + "WHERE EXCLUDED.record_number >= solana_user_pda_current.record_number"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + long nowMs = + System.currentTimeMillis(); + + for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) { + + bindCurrentSnapshot( + statement, + snapshot, + nowMs + ); + statement.addBatch(); + } + + statement.executeBatch(); + } + } + + private void bindSnapshot( + PreparedStatement statement, + ShineUsersCodec.UserPdaSnapshot snapshot, + long nowMs + ) throws Exception { + + statement.setString( + 1, + snapshot.lastTxSignature() + ); + statement.setLong( + 2, + snapshot.slot() + ); + statement.setNull( + 3, + Types.BIGINT + ); + statement.setString( + 4, + snapshot.pdaAddress() + ); + statement.setString( + 5, + snapshot.login() + ); + statement.setInt( + 6, + snapshot.recordNumber() + ); + statement.setString( + 7, + snapshot.recoveryKey() + ); + statement.setString( + 8, + snapshot.rootKey() + ); + statement.setString( + 9, + snapshot.clientKey() + ); + statement.setString( + 10, + snapshot.blockchainName() + ); + statement.setString( + 11, + snapshot.blockchainKey() + ); + statement.setLong( + 12, + snapshot.paidLimitBytes() + ); + statement.setLong( + 13, + snapshot.usedBytes() + ); + statement.setInt( + 14, + snapshot.lastBlockNumber() + ); + statement.setString( + 15, + snapshot.lastBlockHash() + ); + statement.setString( + 16, + snapshot.lastBlockSignature() + ); + statement.setString( + 17, + snapshot.arweaveTxId() + ); + statement.setBoolean( + 18, + snapshot.isServer() + ); + statement.setInt( + 19, + snapshot.addressFormatType() + ); + statement.setInt( + 20, + snapshot.addressFormatVersion() + ); + statement.setString( + 21, + snapshot.serverAddress() + ); + statement.setString( + 22, + writeJson( + snapshot.syncServers() + ) + ); + statement.setString( + 23, + writeJson( + snapshot.accessServers() + ) + ); + statement.setInt( + 24, + snapshot.sessionsMode() + ); + statement.setString( + 25, + writeJson( + snapshot.sessions() + ) + ); + statement.setInt( + 26, + snapshot.trustedCount() + ); + statement.setLong( + 27, + snapshot.createdAtMs() + ); + statement.setLong( + 28, + snapshot.updatedAtMs() + ); + statement.setString( + 29, + snapshot.prevRecordHash() + ); + statement.setString( + 30, + snapshot.recordSignature() + ); + statement.setString( + 31, + snapshot.rawDataBase64() + ); + statement.setLong( + 32, + nowMs + ); + } + + private void bindCurrentSnapshot( + PreparedStatement statement, + ShineUsersCodec.UserPdaSnapshot snapshot, + long nowMs + ) throws Exception { + + statement.setString(1, snapshot.pdaAddress()); + statement.setString(2, snapshot.login()); + statement.setInt(3, snapshot.recordNumber()); + statement.setLong(4, snapshot.slot()); + statement.setString(5, snapshot.lastTxSignature()); + statement.setString(6, snapshot.recoveryKey()); + statement.setString(7, snapshot.rootKey()); + statement.setString(8, snapshot.clientKey()); + statement.setString(9, snapshot.blockchainName()); + statement.setString(10, snapshot.blockchainKey()); + statement.setLong(11, snapshot.paidLimitBytes()); + statement.setLong(12, snapshot.usedBytes()); + statement.setInt(13, snapshot.lastBlockNumber()); + statement.setString(14, snapshot.lastBlockHash()); + statement.setString(15, snapshot.lastBlockSignature()); + statement.setString(16, snapshot.arweaveTxId()); + statement.setBoolean(17, snapshot.isServer()); + statement.setInt(18, snapshot.addressFormatType()); + statement.setInt(19, snapshot.addressFormatVersion()); + statement.setString(20, snapshot.serverAddress()); + statement.setString(21, writeJson(snapshot.syncServers())); + statement.setString(22, writeJson(snapshot.accessServers())); + statement.setInt(23, snapshot.sessionsMode()); + statement.setString(24, writeJson(snapshot.sessions())); + statement.setInt(25, snapshot.trustedCount()); + statement.setLong(26, snapshot.createdAtMs()); + statement.setLong(27, snapshot.updatedAtMs()); + statement.setString(28, snapshot.prevRecordHash()); + statement.setString(29, snapshot.recordSignature()); + statement.setString(30, snapshot.rawDataBase64()); + statement.setLong(31, nowMs); + statement.setLong(32, nowMs); + } + + private ShineUsersCodec.UserPdaSnapshot mapSnapshot( + ResultSet resultSet + ) throws Exception { + + List syncServers = + Arrays.asList( + mapper.readValue( + resultSet.getString("sync_servers_json"), + String[].class + ) + ); + + List accessServers = + Arrays.asList( + mapper.readValue( + resultSet.getString("access_servers_json"), + String[].class + ) + ); + + List sessions = + Arrays.asList( + mapper.readValue( + resultSet.getString("sessions_json"), + ShineUsersCodec.UserSessionSnapshot[].class + ) + ); + + return new ShineUsersCodec.UserPdaSnapshot( + resultSet.getString("pda_address"), + resultSet.getString("login"), + resultSet.getInt("record_number"), + resultSet.getLong("slot"), + resultSet.getString("last_tx_signature"), + resultSet.getString("recovery_key"), + resultSet.getString("root_key"), + resultSet.getString("client_key"), + resultSet.getString("blockchain_name"), + resultSet.getString("blockchain_key"), + resultSet.getLong("paid_limit_bytes"), + resultSet.getLong("used_bytes"), + resultSet.getInt("last_block_number"), + resultSet.getString("last_block_hash"), + resultSet.getString("last_block_signature"), + resultSet.getString("arweave_tx_id"), + resultSet.getBoolean("is_server"), + resultSet.getInt("address_format_type"), + resultSet.getInt("address_format_version"), + resultSet.getString("server_address"), + List.copyOf(syncServers), + List.copyOf(accessServers), + resultSet.getInt("sessions_mode"), + List.copyOf(sessions), + resultSet.getInt("trusted_count"), + resultSet.getLong("created_at_ms"), + resultSet.getLong("updated_at_ms"), + resultSet.getString("prev_record_hash"), + resultSet.getString("record_signature"), + resultSet.getString("raw_data_base64") + ); + } + + private void upsertSyncState( + SyncStateSnapshot snapshot + ) throws Exception { + + try (Connection connection = + openConnection()) { + upsertSyncState( + connection, + snapshot + ); + } + } + + private void upsertSyncState( + Connection connection, + SyncStateSnapshot snapshot + ) throws Exception { + + String sql = + "INSERT INTO solana_sync_state (" + + "id, status, ready, last_poll_at_ms, " + + "last_successful_poll_at_ms, last_seen_signature, " + + "last_seen_slot, last_relevant_signature, " + + "last_relevant_slot, last_error, economy_config_version, " + + "registration_fee_lamports, lamports_per_limit_step, " + + "start_bonus_limit, updated_at_ms" + + ") VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (id) DO UPDATE SET " + + "status = EXCLUDED.status, " + + "ready = EXCLUDED.ready, " + + "last_poll_at_ms = EXCLUDED.last_poll_at_ms, " + + "last_successful_poll_at_ms = EXCLUDED.last_successful_poll_at_ms, " + + "last_seen_signature = EXCLUDED.last_seen_signature, " + + "last_seen_slot = EXCLUDED.last_seen_slot, " + + "last_relevant_signature = EXCLUDED.last_relevant_signature, " + + "last_relevant_slot = EXCLUDED.last_relevant_slot, " + + "last_error = EXCLUDED.last_error, " + + "economy_config_version = EXCLUDED.economy_config_version, " + + "registration_fee_lamports = EXCLUDED.registration_fee_lamports, " + + "lamports_per_limit_step = EXCLUDED.lamports_per_limit_step, " + + "start_bonus_limit = EXCLUDED.start_bonus_limit, " + + "updated_at_ms = EXCLUDED.updated_at_ms"; + + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + + statement.setString(1, snapshot.status()); + statement.setBoolean(2, snapshot.ready()); + bindNullableLong(statement, 3, snapshot.lastPollAtMs()); + bindNullableLong(statement, 4, snapshot.lastSuccessfulPollAtMs()); + statement.setString(5, snapshot.lastSeenSignature()); + bindNullableLong(statement, 6, snapshot.lastSeenSlot()); + statement.setString(7, snapshot.lastRelevantSignature()); + bindNullableLong(statement, 8, snapshot.lastRelevantSlot()); + statement.setString(9, snapshot.lastError()); + + if (snapshot.economyConfigState() == null) { + statement.setNull(10, Types.INTEGER); + statement.setNull(11, Types.BIGINT); + statement.setNull(12, Types.BIGINT); + statement.setNull(13, Types.BIGINT); + } else { + statement.setInt(10, snapshot.economyConfigState().version()); + statement.setLong(11, snapshot.economyConfigState().registrationFeeLamports()); + statement.setLong(12, snapshot.economyConfigState().lamportsPerLimitStep()); + statement.setLong(13, snapshot.economyConfigState().startBonusLimit()); + } + + bindNullableLong(statement, 14, snapshot.updatedAtMs()); + statement.executeUpdate(); + } + } + + private void migrate() + throws Exception { + + try (Connection connection = + openConnection(); + Statement statement = + connection.createStatement()) { + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_sync_state (" + + "id INTEGER PRIMARY KEY CHECK (id = 1), " + + "status TEXT NOT NULL, " + + "ready BOOLEAN NOT NULL DEFAULT FALSE, " + + "last_poll_at_ms BIGINT, " + + "last_successful_poll_at_ms BIGINT, " + + "last_seen_signature TEXT, " + + "last_seen_slot BIGINT, " + + "last_relevant_signature TEXT, " + + "last_relevant_slot BIGINT, " + + "last_error TEXT, " + + "economy_config_version INTEGER, " + + "registration_fee_lamports BIGINT, " + + "lamports_per_limit_step BIGINT, " + + "start_bonus_limit BIGINT, " + + "updated_at_ms BIGINT" + + ")" + ); + + statement.executeUpdate( + "INSERT INTO solana_sync_state (id, status, ready) " + + "VALUES (1, 'EMPTY', FALSE) " + + "ON CONFLICT (id) DO NOTHING" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_sync_tx_history (" + + "signature TEXT PRIMARY KEY, " + + "slot BIGINT NOT NULL, " + + "block_time BIGINT, " + + "tx_kind TEXT NOT NULL, " + + "is_relevant BOOLEAN NOT NULL, " + + "affected_pda_address TEXT, " + + "affected_login TEXT, " + + "raw_summary_json TEXT NOT NULL, " + + "processed_at_ms BIGINT NOT NULL" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_slot " + + "ON solana_sync_tx_history(slot)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_relevant " + + "ON solana_sync_tx_history(is_relevant)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_sync_tx_history_login " + + "ON solana_sync_tx_history(affected_login)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_user_pda_current (" + + "pda_address TEXT PRIMARY KEY, " + + "login TEXT NOT NULL UNIQUE, " + + "record_number INTEGER NOT NULL, " + + "slot BIGINT NOT NULL, " + + "last_tx_signature TEXT NOT NULL, " + + "recovery_key TEXT NOT NULL, " + + "root_key TEXT NOT NULL, " + + "client_key TEXT NOT NULL, " + + "blockchain_name TEXT NOT NULL, " + + "blockchain_key TEXT NOT NULL, " + + "paid_limit_bytes BIGINT NOT NULL, " + + "used_bytes BIGINT NOT NULL, " + + "last_block_number INTEGER NOT NULL, " + + "last_block_hash TEXT NOT NULL, " + + "last_block_signature TEXT NOT NULL, " + + "arweave_tx_id TEXT NOT NULL, " + + "is_server BOOLEAN NOT NULL, " + + "address_format_type INTEGER NOT NULL, " + + "address_format_version INTEGER NOT NULL, " + + "server_address TEXT NOT NULL, " + + "sync_servers_json TEXT NOT NULL, " + + "access_servers_json TEXT NOT NULL, " + + "sessions_mode INTEGER NOT NULL, " + + "sessions_json TEXT NOT NULL, " + + "trusted_count INTEGER NOT NULL, " + + "created_at_ms BIGINT NOT NULL, " + + "updated_at_ms BIGINT NOT NULL, " + + "prev_record_hash TEXT NOT NULL, " + + "record_signature TEXT NOT NULL, " + + "raw_data_base64 TEXT NOT NULL, " + + "first_seen_at_ms BIGINT NOT NULL, " + + "last_synced_at_ms BIGINT NOT NULL" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + + "ON solana_user_pda_current(slot)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS solana_user_pda_history (" + + "id BIGSERIAL PRIMARY KEY, " + + "tx_signature TEXT NOT NULL, " + + "slot BIGINT NOT NULL, " + + "block_time BIGINT, " + + "pda_address TEXT NOT NULL, " + + "login TEXT NOT NULL, " + + "record_number INTEGER NOT NULL, " + + "recovery_key TEXT NOT NULL, " + + "root_key TEXT NOT NULL, " + + "client_key TEXT NOT NULL, " + + "blockchain_name TEXT NOT NULL, " + + "blockchain_key TEXT NOT NULL, " + + "paid_limit_bytes BIGINT NOT NULL, " + + "used_bytes BIGINT NOT NULL, " + + "last_block_number INTEGER NOT NULL, " + + "last_block_hash TEXT NOT NULL, " + + "last_block_signature TEXT NOT NULL, " + + "arweave_tx_id TEXT NOT NULL, " + + "is_server BOOLEAN NOT NULL, " + + "address_format_type INTEGER NOT NULL, " + + "address_format_version INTEGER NOT NULL, " + + "server_address TEXT NOT NULL, " + + "sync_servers_json TEXT NOT NULL, " + + "access_servers_json TEXT NOT NULL, " + + "sessions_mode INTEGER NOT NULL, " + + "sessions_json TEXT NOT NULL, " + + "trusted_count INTEGER NOT NULL, " + + "created_at_ms BIGINT NOT NULL, " + + "updated_at_ms BIGINT NOT NULL, " + + "prev_record_hash TEXT NOT NULL, " + + "record_signature TEXT NOT NULL, " + + "raw_data_base64 TEXT NOT NULL, " + + "saved_at_ms BIGINT NOT NULL, " + + "UNIQUE (pda_address, record_number)" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " + + "ON solana_user_pda_history(login)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_pda_history_slot " + + "ON solana_user_pda_history(slot)" + ); + + statement.executeUpdate( + "CREATE TABLE IF NOT EXISTS user_access_servers_current (" + + "user_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, " + + "server_login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE, " + + "server_url TEXT NOT NULL, " + + "server_client_key TEXT NOT NULL, " + + "user_record_number INTEGER NOT NULL, " + + "user_updated_at_ms BIGINT NOT NULL, " + + "server_record_number INTEGER NOT NULL, " + + "server_updated_at_ms BIGINT NOT NULL, " + + "refreshed_at_ms BIGINT NOT NULL, " + + "PRIMARY KEY (user_login, server_login)" + + ")" + ); + + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_access_servers_user " + + "ON user_access_servers_current(user_login)" + ); + statement.executeUpdate( + "CREATE INDEX IF NOT EXISTS idx_user_access_servers_server " + + "ON user_access_servers_current(server_login)" + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_user(p_user_login TEXT) + RETURNS VOID + LANGUAGE plpgsql + AS $$ + BEGIN + IF p_user_login IS NULL OR btrim(p_user_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(user_login) = LOWER(p_user_login); + + INSERT INTO user_access_servers_current ( + user_login, + server_login, + server_url, + server_client_key, + user_record_number, + user_updated_at_ms, + server_record_number, + server_updated_at_ms, + refreshed_at_ms + ) + SELECT + u.login, + s.login, + s.server_address, + s.client_key, + u.record_number, + u.updated_at_ms, + s.record_number, + s.updated_at_ms, + CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + JOIN solana_user_pda_current s + ON LOWER(s.login) = LOWER(btrim(access_server.login_value)) + AND s.is_server = TRUE + AND btrim(COALESCE(s.server_address, '')) <> '' + WHERE LOWER(u.login) = LOWER(p_user_login) + ON CONFLICT (user_login, server_login) DO UPDATE SET + server_url = EXCLUDED.server_url, + server_client_key = EXCLUDED.server_client_key, + user_record_number = EXCLUDED.user_record_number, + user_updated_at_ms = EXCLUDED.user_updated_at_ms, + server_record_number = EXCLUDED.server_record_number, + server_updated_at_ms = EXCLUDED.server_updated_at_ms, + refreshed_at_ms = EXCLUDED.refreshed_at_ms; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_for_server(p_server_login TEXT) + RETURNS VOID + LANGUAGE plpgsql + AS $$ + DECLARE + affected_user RECORD; + BEGIN + IF p_server_login IS NULL OR btrim(p_server_login) = '' THEN + RETURN; + END IF; + + DELETE FROM user_access_servers_current + WHERE LOWER(server_login) = LOWER(p_server_login); + + FOR affected_user IN + SELECT u.login + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE + WHEN btrim(COALESCE(u.access_servers_json, '')) = '' THEN '[]'::jsonb + ELSE u.access_servers_json::jsonb + END + ) AS access_server(login_value) + WHERE LOWER(btrim(access_server.login_value)) = LOWER(p_server_login) + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION shine_refresh_user_access_servers_all() + RETURNS VOID + LANGUAGE plpgsql + AS $$ + DECLARE + affected_user RECORD; + BEGIN + TRUNCATE TABLE user_access_servers_current; + + FOR affected_user IN + SELECT login + FROM solana_user_pda_current + LOOP + PERFORM shine_refresh_user_access_servers_for_user(affected_user.login); + END LOOP; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_row() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + RETURN OLD; + END IF; + + IF TG_OP = 'UPDATE' AND LOWER(OLD.login) <> LOWER(NEW.login) THEN + PERFORM shine_refresh_user_access_servers_for_user(OLD.login); + PERFORM shine_refresh_user_access_servers_for_server(OLD.login); + END IF; + + PERFORM shine_refresh_user_access_servers_for_user(NEW.login); + PERFORM shine_refresh_user_access_servers_for_server(NEW.login); + RETURN NEW; + END; + $$; + """ + ); + + statement.execute( + """ + CREATE OR REPLACE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + TRUNCATE TABLE user_access_servers_current; + RETURN NULL; + END; + $$; + """ + ); + + statement.executeUpdate( + "DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_row ON solana_user_pda_current" + ); + statement.executeUpdate( + "CREATE TRIGGER trg_refresh_user_access_servers_row " + + "AFTER INSERT OR UPDATE OR DELETE ON solana_user_pda_current " + + "FOR EACH ROW " + + "EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_row()" + ); + + statement.executeUpdate( + "DROP TRIGGER IF EXISTS trg_refresh_user_access_servers_truncate ON solana_user_pda_current" + ); + statement.executeUpdate( + "CREATE TRIGGER trg_refresh_user_access_servers_truncate " + + "AFTER TRUNCATE ON solana_user_pda_current " + + "FOR EACH STATEMENT " + + "EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate()" + ); + + statement.execute("SELECT shine_refresh_user_access_servers_all()"); + } + } + + private Connection openConnection() + throws Exception { + return DriverManager.getConnection( + databaseUrl, + databaseUser, + databasePassword + ); + } + + private static void bindNullableLong( + PreparedStatement statement, + int index, + Long value + ) throws SQLException { + if (value == null) { + statement.setNull(index, Types.BIGINT); + } else { + statement.setLong(index, value); + } + } + + private static Long getNullableLong( + ResultSet resultSet, + String column + ) throws SQLException { + long value = resultSet.getLong(column); + return resultSet.wasNull() ? null : value; + } + + private static Integer getNullableInt( + ResultSet resultSet, + String column + ) throws SQLException { + int value = resultSet.getInt(column); + return resultSet.wasNull() ? null : value; + } + + private String writeJson( + Object value + ) throws JsonProcessingException { + return mapper.writeValueAsString(value); + } + + @Override + public void close() { + } + + public record TxHistoryEntry( + String signature, + long slot, + Long blockTime, + String txKind, + boolean relevant, + String affectedPdaAddress, + String affectedLogin, + String rawSummaryJson, + long processedAtMs + ) { + } + + public record SyncStateSnapshot( + String status, + boolean ready, + Long lastPollAtMs, + Long lastSuccessfulPollAtMs, + String lastSeenSignature, + Long lastSeenSlot, + String lastRelevantSignature, + Long lastRelevantSlot, + String lastError, + ShineUsersCodec.EconomyConfigState economyConfigState, + Long updatedAtMs + ) { + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java new file mode 100644 index 00000000..7b985555 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/Base58Util.java @@ -0,0 +1,224 @@ +package sync.util; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public final class Base58Util { + + private static final char[] ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + .toCharArray(); + + private static final int[] INDEXES = + new int[128]; + + static { + Arrays.fill( + INDEXES, + -1 + ); + + for (int i = 0; i < ALPHABET.length; i++) { + INDEXES[ALPHABET[i]] = i; + } + } + + private Base58Util() { + } + + public static String encode( + byte[] input + ) { + + if (input == null + || input.length == 0) { + return ""; + } + + byte[] copy = + Arrays.copyOf( + input, + input.length + ); + + int zeros = + 0; + + while (zeros < copy.length + && copy[zeros] == 0) { + zeros++; + } + + byte[] encoded = + new byte[copy.length * 2]; + + int outputStart = + encoded.length; + + int inputStart = + zeros; + + while (inputStart < copy.length) { + + int remainder = + divMod58( + copy, + inputStart + ); + + if (copy[inputStart] == 0) { + inputStart++; + } + + encoded[--outputStart] = + (byte) ALPHABET[remainder]; + } + + while (outputStart < encoded.length + && encoded[outputStart] == ALPHABET[0]) { + outputStart++; + } + + while (--zeros >= 0) { + encoded[--outputStart] = + (byte) ALPHABET[0]; + } + + return new String( + encoded, + outputStart, + encoded.length - outputStart, + StandardCharsets.US_ASCII + ); + } + + public static byte[] decode( + String input + ) { + + if (input == null + || input.isBlank()) { + return new byte[0]; + } + + char[] chars = + input.trim() + .toCharArray(); + + byte[] input58 = + new byte[chars.length]; + + for (int i = 0; i < chars.length; i++) { + + char c = chars[i]; + + if (c >= INDEXES.length + || INDEXES[c] < 0) { + throw new IllegalArgumentException( + "Invalid Base58 character: " + c + ); + } + + input58[i] = + (byte) INDEXES[c]; + } + + int zeros = + 0; + + while (zeros < input58.length + && input58[zeros] == 0) { + zeros++; + } + + byte[] decoded = + new byte[chars.length]; + + int outputStart = + decoded.length; + + int inputStart = + zeros; + + while (inputStart < input58.length) { + + int remainder = + divMod256( + input58, + inputStart + ); + + if (input58[inputStart] == 0) { + inputStart++; + } + + decoded[--outputStart] = + (byte) remainder; + } + + while (outputStart < decoded.length + && decoded[outputStart] == 0) { + outputStart++; + } + + return Arrays.copyOfRange( + decoded, + outputStart - zeros, + decoded.length + ); + } + + private static int divMod58( + byte[] number, + int startAt + ) { + + int remainder = + 0; + + for (int i = startAt; i < number.length; i++) { + + int digit256 = + number[i] & 0xFF; + + int temp = + remainder * 256 + + digit256; + + number[i] = + (byte) (temp / 58); + + remainder = + temp % 58; + } + + return remainder; + } + + private static int divMod256( + byte[] number58, + int startAt + ) { + + int remainder = + 0; + + for (int i = startAt; i < number58.length; i++) { + + int digit58 = + number58[i] & 0xFF; + + int temp = + remainder * 58 + + digit58; + + number58[i] = + (byte) (temp / 256); + + remainder = + temp % 256; + } + + return remainder; + } +} diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java new file mode 100644 index 00000000..af065063 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java @@ -0,0 +1,95 @@ +package sync.util; + +import org.bouncycastle.jcajce.provider.digest.SHA256; +import org.bouncycastle.math.ec.rfc8032.Ed25519; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public final class SolanaPdaUtil { + + private static final byte[] PROGRAM_DERIVED_ADDRESS_DOMAIN = + "ProgramDerivedAddress" + .getBytes(StandardCharsets.UTF_8); + + private SolanaPdaUtil() { + } + + public static String findProgramAddress( + List seeds, + String programIdBase58 + ) { + + byte[] programId = + Base58Util.decode( + programIdBase58 + ); + + if (programId.length != 32) { + throw new IllegalArgumentException( + "Program id must decode to 32 bytes" + ); + } + + for (int bump = 255; bump >= 0; bump--) { + + List attemptSeeds = + new ArrayList<>( + seeds + ); + + attemptSeeds.add( + new byte[]{(byte) bump} + ); + + byte[] address = + createProgramAddress( + attemptSeeds, + programId + ); + + if (!isOnCurve(address)) { + return Base58Util.encode( + address + ); + } + } + + throw new IllegalStateException( + "Unable to find viable program address" + ); + } + + private static byte[] createProgramAddress( + List seeds, + byte[] programId + ) { + + SHA256.Digest digest = + new SHA256.Digest(); + + for (byte[] seed : seeds) { + digest.update(seed); + } + + digest.update(programId); + digest.update(PROGRAM_DERIVED_ADDRESS_DOMAIN); + + return digest.digest(); + } + + private static boolean isOnCurve( + byte[] publicKey + ) { + + if (publicKey.length != 32) { + return false; + } + + return Ed25519.validatePublicKeyFull( + publicKey, + 0 + ); + } +} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index ddf7f7e2..0b3deebe 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -4,21 +4,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; import server.logic.ws_protocol.JSON.entyties.Net_Response; -import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import shine.db.dao.BlockchainResyncCleanupDAO; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.SyncServersDAO; -import shine.db.dao.UserCreateDAO; -import shine.db.dao.SolanaUsersDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserPdaCurrentEntry; import shine.db.entities.SyncServerEntry; import server.sync.BlockchainResyncGuard; import utils.files.FileStoreUtil; import utils.blockchain.BlockchainNameUtil; -import utils.config.AppConfig; import java.util.concurrent.locks.ReentrantLock; import java.util.List; @@ -53,11 +51,9 @@ public final class PeriodicBlockchainSyncService { private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler(); private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); - private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance(); private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance(); private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance(); - private static final SolanaUsersDAO SOLANA_USERS_DAO = SolanaUsersDAO.getInstance(); - private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled"; + private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance(); private PeriodicBlockchainSyncService() {} @@ -354,18 +350,20 @@ public final class PeriodicBlockchainSyncService { private static boolean ensureLocalChainExists(SyncServerEntry partner, String blockchainName) { try { - if (STATE_DAO.getByBlockchainName(blockchainName) != null) { + BlockchainStateEntry existingState = STATE_DAO.getByBlockchainName(blockchainName); + if (hasValidBlockchainKey(existingState)) { return true; } + + if (ensureLocalChainStateFromCurrentPostgresSnapshot(blockchainName, existingState)) { + return true; + } + String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); if (login == null || login.isBlank()) { return false; } - if (AppConfig.getInstance().getBoolean(CONFIG_IMPORT_PROFILE_FROM_PARTNER, false)) { - return importUserProfileFromPartner(partner, login); - } - SolanaUserPdaImportService.findOrImportByLogin(login); - return STATE_DAO.getByBlockchainName(blockchainName) != null; + return false; } catch (Exception e) { log.warn("Periodic blockchain sync: failed to ensure local chain exists for blockchainName={} reason={}", blockchainName, String.valueOf(e)); @@ -373,65 +371,38 @@ public final class PeriodicBlockchainSyncService { } } - private static boolean importUserProfileFromPartner(SyncServerEntry partner, String login) throws Exception { - if (partner == null || partner.getServerAddress() == null || partner.getServerAddress().isBlank()) { + private static boolean ensureLocalChainStateFromCurrentPostgresSnapshot(String blockchainName, + BlockchainStateEntry existingState) throws Exception { + SolanaUserPdaCurrentEntry currentUser = SOLANA_USER_PDA_CURRENT_DAO.getByBlockchainName(blockchainName); + if (currentUser == null) { return false; } - RemoteBlockchainSyncClient.RemoteSyncUserProfile profile = - REMOTE.getSyncUserProfile(partner.getServerAddress(), login); - if (profile == null) { - log.warn("Periodic blockchain sync: partner has no sync profile for login={} partner={}", - login, normalize(partner.getLogin())); - return false; - } + BlockchainStateEntry state = new BlockchainStateEntry(); + state.setBlockchainName(currentUser.getBlockchainName()); + state.setLogin(currentUser.getLogin()); + state.setBlockchainKey(currentUser.getBlockchainKey()); + state.setSizeLimit(currentUser.getPaidLimitBytes() > 0 ? currentUser.getPaidLimitBytes() : 100_000L); + state.setFileSizeBytes(existingState == null ? 0L : existingState.getFileSizeBytes()); + state.setLastBlockNumber(existingState == null ? -1 : existingState.getLastBlockNumber()); + state.setLastBlockHash(existingState == null ? null : existingState.getLastBlockHash()); + state.setUpdatedAtMs(System.currentTimeMillis()); - long now = System.currentTimeMillis(); - long sizeLimit = profile.blockchainSizeLimitBytes() > 0 ? profile.blockchainSizeLimitBytes() : 100_000L; - BlockchainStateEntry state = buildStateFromProfile(profile, sizeLimit, now); - - if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { + if (existingState == null) { STATE_DAO.insertIfMissing(state); - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; + } else { + STATE_DAO.upsert(state); } - boolean inserted = USER_CREATE_DAO.insertUserWithBlockchain( - profile.login(), - profile.blockchainName(), - profile.solanaKey(), - profile.blockchainKey(), - profile.clientKey(), - sizeLimit, - now - ); - - if (inserted) { - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; - } - - // Если пользователь уже успел существовать локально, но chain_state отсутствует, - // добиваем только state и не пытаемся пересоздать identity. - if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { - STATE_DAO.insertIfMissing(state); - return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; - } - - return false; + return hasValidBlockchainKey(STATE_DAO.getByBlockchainName(blockchainName)); } - private static BlockchainStateEntry buildStateFromProfile(RemoteBlockchainSyncClient.RemoteSyncUserProfile profile, - long sizeLimit, - long nowMs) { - BlockchainStateEntry state = new BlockchainStateEntry(); - state.setBlockchainName(profile.blockchainName()); - state.setLogin(profile.login()); - state.setBlockchainKey(profile.blockchainKey()); - state.setSizeLimit(sizeLimit); - state.setFileSizeBytes(0L); - state.setLastBlockNumber(-1); - state.setLastBlockHash(null); - state.setUpdatedAtMs(nowMs); - return state; + private static boolean hasValidBlockchainKey(BlockchainStateEntry state) { + if (state == null) { + return false; + } + byte[] keyBytes = state.getBlockchainKeyBytes(); + return keyBytes != null && keyBytes.length == 32; } private static String normalize(String value) { diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java new file mode 100644 index 00000000..94b6c626 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicDmSyncService.java @@ -0,0 +1,185 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport; +import shine.db.dao.DmSyncPeerStateDAO; +import shine.db.dao.UserAccessServersCurrentDAO; +import shine.db.entities.DmSyncPeerStateEntry; +import shine.db.entities.UserAccessServerRouteEntry; +import utils.config.AppConfig; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя. + */ +public final class PeriodicDmSyncService { + private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class); + private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login"; + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + + private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance(); + private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance(); + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "periodic-dm-sync"); + t.setDaemon(true); + return t; + } + }); + + private PeriodicDmSyncService() {} + + public static void startOrLog() { + if (!isEnabled()) { + log.info("Periodic DM sync disabled by dm.sync.enabled=false"); + return; + } + if (!STARTED.compareAndSet(false, true)) { + return; + } + long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L); + long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L); + EXECUTOR.scheduleWithFixedDelay( + PeriodicDmSyncService::runCycleSafe, + initialDelaySec, + TimeUnit.HOURS.toSeconds(periodHours), + TimeUnit.SECONDS + ); + log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours); + } + + private static void runCycleSafe() { + try { + runCycle(); + } catch (Exception e) { + log.error("Periodic DM sync failed unexpectedly", e); + } + } + + private static void runCycle() throws Exception { + String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG)); + if (ownServerLogin == null) { + log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG); + return; + } + + List ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin); + Set owners = new LinkedHashSet<>(ownersRaw); + if (owners.isEmpty()) { + log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin); + return; + } + + int syncedPeers = 0; + int appliedEvents = 0; + for (String ownerLogin : owners) { + List routes = ACCESS_DAO.listByUserLogin(ownerLogin); + for (UserAccessServerRouteEntry route : routes) { + if (route == null) continue; + String remoteLogin = normalize(route.getServerLogin()); + String remoteUrl = route.getServerUrl(); + if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue; + if (remoteLogin.equals(ownServerLogin)) continue; + + try { + appliedEvents += syncOwnerFromRemote(ownerLogin, route); + syncedPeers++; + } catch (Exception e) { + STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e)); + log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}", + ownerLogin, route.getServerLogin(), String.valueOf(e)); + } + } + } + log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}", + owners.size(), syncedPeers, appliedEvents); + } + + private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception { + int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L); + int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L); + int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L); + + DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl()); + long cursorStoredAtMs = state.getCursorStoredAtMs(); + String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey(); + int applied = 0; + boolean bootstrapCompleted = false; + + for (int page = 0; page < maxPages; page++) { + RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch( + route.getServerUrl(), + ownerLogin, + cursorStoredAtMs, + cursorMessageKey, + limit, + maxBytes + ); + + for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) { + if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue; + DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64()); + if (result.status().applied()) { + applied++; + } + } + + cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs()); + cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey(); + bootstrapCompleted = !batch.hasMore(); + STATE_DAO.updateSuccess( + ownerLogin, + route.getServerLogin(), + route.getServerUrl(), + cursorStoredAtMs, + cursorMessageKey, + bootstrapCompleted + ); + + if (!batch.hasMore() || batch.items().isEmpty()) { + break; + } + } + + if (!bootstrapCompleted) { + log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}", + ownerLogin, route.getServerLogin(), maxPages); + } + return applied; + } + + private static boolean isEnabled() { + String raw = AppConfig.getInstance().getParam("dm.sync.enabled"); + return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim()); + } + + private static long configLong(String key, long defaultValue, long min, long max) { + String raw = AppConfig.getInstance().getParam(key); + if (raw == null || raw.isBlank()) return defaultValue; + try { + long parsed = Long.parseLong(raw.trim()); + return Math.max(min, Math.min(max, parsed)); + } catch (Exception ignored) { + return defaultValue; + } + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(Locale.ROOT); + return s.isEmpty() ? null : s; + } +} diff --git a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java new file mode 100644 index 00000000..491bbb1d --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java @@ -0,0 +1,85 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sync.service.SolanaUsersSyncService; +import utils.config.AppConfig; + +public final class SolanaUsersSyncStartupService { + + private static final Logger log = + LoggerFactory.getLogger( + SolanaUsersSyncStartupService.class + ); + + private static volatile SolanaUsersSyncService service; + + private SolanaUsersSyncStartupService() { + } + + public static synchronized void startOrThrow() + throws Exception { + + if (service != null) { + return; + } + + AppConfig serverConfig = + AppConfig.getInstance(); + + if (!sync.config.AppConfig.isEnabled(serverConfig)) { + log.info( + "Solana users sync is disabled. Param {} is false or absent.", + sync.config.AppConfig.ENABLED_KEY + ); + return; + } + + sync.config.AppConfig syncConfig = + sync.config.AppConfig.fromServerConfig( + serverConfig + ); + + service = + new SolanaUsersSyncService( + syncConfig + ); + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + SolanaUsersSyncStartupService::closeQuietly, + "solana-users-sync-shutdown" + ) + ); + + log.info( + "Starting Solana users sync before remaining server startup..." + ); + + service.start(); + service.awaitReady(); + + log.info( + "Solana users sync is READY. Server startup may continue." + ); + } + + public static synchronized void closeQuietly() { + + if (service == null) { + return; + } + + try { + service.close(); + } catch (Exception exception) { + log.warn( + "Failed to close Solana users sync service cleanly", + exception + ); + } finally { + service = null; + } + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index d318b2e2..42a9aff2 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -8,6 +8,8 @@ import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; +import server.sync.PeriodicDmSyncService; +import server.sync.SolanaUsersSyncStartupService; import server.sync.SyncServersBootstrapService; import utils.config.AppConfig; @@ -49,6 +51,11 @@ public final class WsServer { throw e; } + // ============================================================ + // 1.0) Синхронизация пользовательских Solana PDA + // ============================================================ + SolanaUsersSyncStartupService.startOrThrow(); + // ============================================================ // 1) Настройки порта // ============================================================ @@ -96,6 +103,7 @@ public final class WsServer { server.start(); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); + PeriodicDmSyncService.startOrLog(); server.join(); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index b910adbd..17aa12c4 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,8 +1,18 @@ server.1port=7070 -db.path=data/shine.sqlite +db.url= +db.user= +db.password= server.SHiNE.login=shineupme solana.cluster=mainnet-beta solana.rpcUrl=https://api.mainnet-beta.solana.com +solana.users.sync.enabled=false +solana.users.sync.rpcUrl= +solana.users.sync.wsUrl= +solana.users.sync.programId=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 +solana.users.sync.databaseUrl= +solana.users.sync.dbUser= +solana.users.sync.dbPassword= +solana.users.sync.pollIntervalSeconds=300 # ------------------------------------------------------------ # Межсерверная синхронизация: как создавать локальную запись пользователя, @@ -10,7 +20,7 @@ solana.rpcUrl=https://api.mainnet-beta.solana.com # false - брать профиль пользователя напрямую из Solana PDA (обычный режим). # true - не ходить в Solana RPC, а запрашивать у сервера-партнёра специальный # sync-профиль пользователя и по нему локально создавать -# solana_users + blockchain_state. +# current user runtime-проекцию + blockchain_state. # Эта настройка нужна как временный обход лимитов Solana RPC (например 429), # чтобы чистый сервер мог восстановить цепочки от партнёра без зависимости # от внешнего Solana endpoint. @@ -23,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false # Если какое-то значение не задано, сервер вернёт пустую строку. # ------------------------------------------------------------ server.version=${projectVersion} + +# Межсерверная догоняющая синхронизация личных сообщений. +dm.sync.enabled=true +dm.sync.initialDelaySeconds=60 +dm.sync.periodHours=6 +dm.sync.batchLimit=500 +dm.sync.batchMaxBytes=3000000 +dm.sync.maxPagesPerPeer=50 server.info.url= server.info.physicalRegion= server.info.description= diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java b/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java deleted file mode 100644 index cd132733..00000000 --- a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java +++ /dev/null @@ -1,365 +0,0 @@ -package test.it.cases; - -import test.it.utils.TestConfig; -import test.it.utils.TestIds; -import test.it.utils.json.JsonBuilders; -import test.it.utils.json.JsonParsers; -import test.it.utils.log.TestResult; -import test.it.utils.ws.WsSession; - -import java.time.Duration; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.fail; - -/** - * IT_01_AddUser - * Создаёт 3 пользователей: TestUser1/2/3 (200 OK или 409 USER_ALREADY_EXISTS). - * - * Обновление: - * - теперь AddUser может вернуть 409 не только USER_ALREADY_EXISTS, - * но и BLOCKCHAIN_ALREADY_EXISTS / BLOCKCHAIN_STATE_ALREADY_EXISTS. - * - дополнительно проверяем GetUser (status=200 всегда). - * - добавлен SearchUsers: поиск по префиксу (первые 3 символа). - */ -public class IT_01_AddUser { - - public static void main(String[] args) { - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_01_AddUser"); - - Duration t = Duration.ofSeconds(5); - - try (WsSession ws = WsSession.open()) { - - checkPingAndServerInfo(r, ws, t); - - r.ok("AddUser USER1: " + TestConfig.LOGIN()); - String resp1 = ws.call("AddUser#USER1", JsonBuilders.addUser(TestConfig.LOGIN()), t); - checkAddUser200or409(r, resp1); - checkGetUserMustExist(r, ws, TestConfig.LOGIN(), t); - - r.ok("AddUser USER2: " + TestConfig.LOGIN2()); - String resp2 = ws.call("AddUser#USER2", JsonBuilders.addUser(TestConfig.LOGIN2()), t); - checkAddUser200or409(r, resp2); - checkGetUserMustExist(r, ws, TestConfig.LOGIN2(), t); - - r.ok("AddUser USER3: " + TestConfig.LOGIN3()); - String resp3 = ws.call("AddUser#USER3", JsonBuilders.addUser(TestConfig.LOGIN3()), t); - checkAddUser200or409(r, resp3); - checkGetUserMustExist(r, ws, TestConfig.LOGIN3(), t); - - // Доп: проверяем case-insensitive поиск в GetUser - String mixed = mixCase(TestConfig.LOGIN()); - r.ok("GetUser case-insensitive: запрос=" + mixed + " (должен найти " + TestConfig.LOGIN() + ")"); - checkGetUserMustExist(r, ws, mixed, t); - - // Доп: проверяем "не существует" (но status=200) - String missing = "NoSuchUser_987654321"; - r.ok("GetUser missing: " + missing); - checkGetUserMustNotExist(r, ws, missing, t); - - // SearchUsers: один раз ищем по первым трём символам логина USER1 - String prefix3 = first3(TestConfig.LOGIN()); - String prefix3Mixed = mixCase(prefix3); - r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")"); - checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t); - - checkNegativeRequests(r, ws, t); - - } catch (Throwable e) { - r.fail("IT_01_AddUser упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - private static void checkPingAndServerInfo(TestResult r, WsSession ws, Duration t) { - String pingResp = ws.call("Ping", JsonBuilders.ping(System.currentTimeMillis()), t); - if (JsonParsers.status(pingResp) != 200 || !Boolean.TRUE.equals(JsonParsers.ok(pingResp))) { - r.fail("Ping: ожидали status=200 и ok=true, resp=" + pingResp); - fail("Ping unexpected response"); - } - - Long serverTs = JsonParsers.pingTs(pingResp); - if (serverTs == null || serverTs <= 0) { - r.fail("Ping: сервер не вернул ts, resp=" + pingResp); - fail("Ping missing ts"); - } - r.ok("Ping: ok, serverTs=" + serverTs); - - String infoResp = ws.call("GetServerInfo", JsonBuilders.getServerInfo(), t); - if (JsonParsers.status(infoResp) != 200 || !Boolean.TRUE.equals(JsonParsers.ok(infoResp))) { - r.fail("GetServerInfo: ожидали status=200 и ok=true, resp=" + infoResp); - fail("GetServerInfo unexpected response"); - } - if (!JsonParsers.payloadIsObject(infoResp)) { - r.fail("GetServerInfo: payload должен быть объектом, resp=" + infoResp); - fail("GetServerInfo payload is not object"); - } - - r.ok("GetServerInfo: ok, url='" + safe(JsonParsers.payloadText(infoResp, "url")) - + "', version='" + safe(JsonParsers.payloadText(infoResp, "version")) - + "', physicalRegion='" + safe(JsonParsers.payloadText(infoResp, "physicalRegion")) - + "', description='" + safe(JsonParsers.payloadText(infoResp, "description")) - + "', origin='" + safe(JsonParsers.payloadText(infoResp, "origin")) - + "', extraInfo='" + safe(JsonParsers.payloadText(infoResp, "extraInfo")) + "'"); - } - - private static void checkAddUser200or409(TestResult r, String resp) { - int st = JsonParsers.status(resp); - if (st == 200) { - r.ok("AddUser: status=200 (создан)"); - return; - } - if (st == 409) { - String code = JsonParsers.errorCode(resp); - - // раньше был только USER_ALREADY_EXISTS, теперь добавились ещё варианты - if ("USER_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 USER_ALREADY_EXISTS (уже был)"); - return; - } - if ("BLOCKCHAIN_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_ALREADY_EXISTS (blockchainName уже занят)"); - return; - } - if ("BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_STATE_ALREADY_EXISTS (blockchain_state уже есть)"); - return; - } - - r.fail("AddUser: status=409 но code=" + code + ", resp=" + resp); - fail("AddUser unexpected 409 code=" + code); - } - r.fail("AddUser: неожиданный status=" + st + ", resp=" + resp); - fail("AddUser unexpected status=" + st); - } - - private static void checkGetUserMustExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser: ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null || !exists) { - r.fail("GetUser: ожидали exists=true, resp=" + resp); - fail("GetUser expected exists=true"); - } - - // Проверяем, что сервер возвращает данные - String login = JsonParsers.userLogin(resp); - String blockchainName = JsonParsers.userBlockchainName(resp); - String solanaKey = JsonParsers.userSolanaKey(resp); - String blockchainKey = JsonParsers.userBlockchainKey(resp); - String clientKey = JsonParsers.userClientKey(resp); - - if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(clientKey)) { - r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); - fail("GetUser returned incomplete user data"); - } - - // ВАЖНО: - // Поиск делается без учета регистра, но login/blockchainName должны вернуться как в БД. - // Для тех логинов, которые мы создаем в тесте, это ровно TestConfig.LOGIN*(). - // Поэтому если запрос был смешанный регистр — сравниваем не с loginQuery, а с "каноничным" логином из конфига. - String canonical = canonicalLogin(loginQuery); - if (canonical != null) { - if (!login.equals(canonical)) { - r.fail("GetUser: login должен вернуться как в БД. expected=" + canonical + ", got=" + login + ", resp=" + resp); - fail("GetUser wrong login case"); - } - - String expectedBch = TestConfig.getBlockchainName(canonical); - if (!blockchainName.equals(expectedBch)) { - r.fail("GetUser: blockchainName должен вернуться как в БД. expected=" + expectedBch + ", got=" + blockchainName + ", resp=" + resp); - fail("GetUser wrong blockchainName"); - } - - // ключи должны совпадать с теми, что AddUser использует при регистрации - String expSol = TestConfig.solanaPublicKeyB64(canonical); - String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); - String expDev = TestConfig.clientPublicKeyB64(canonical); - - if (!solanaKey.equals(expSol)) { - r.fail("GetUser: solanaKey mismatch, resp=" + resp); - fail("GetUser solanaKey mismatch"); - } - if (!blockchainKey.equals(expBchKey)) { - r.fail("GetUser: blockchainKey mismatch, resp=" + resp); - fail("GetUser blockchainKey mismatch"); - } - if (!clientKey.equals(expDev)) { - r.fail("GetUser: clientKey mismatch, resp=" + resp); - fail("GetUser clientKey mismatch"); - } - } - - r.ok("GetUser: exists=true, login=" + login + ", blockchainName=" + blockchainName); - } - - private static void checkGetUserMustNotExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser(not exist): ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser(not exist) unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null) { - r.fail("GetUser(not exist): payload.exists отсутствует, resp=" + resp); - fail("GetUser(not exist) missing exists"); - } - if (exists) { - r.fail("GetUser(not exist): ожидали exists=false, resp=" + resp); - fail("GetUser(not exist) expected exists=false"); - } - - r.ok("GetUser: exists=false (ok)"); - } - - private static void checkSearchUsersMustContain(TestResult r, WsSession ws, String prefix, String expectedLogin, Duration t) { - String resp = ws.call("SearchUsers#" + prefix, JsonBuilders.searchUsers(prefix), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("SearchUsers: ожидали status=200, получили " + st + ", resp=" + resp); - fail("SearchUsers unexpected status=" + st); - } - - List logins = JsonParsers.searchLogins(resp); - if (logins == null || logins.isEmpty()) { - r.fail("SearchUsers: ожидали непустой список, resp=" + resp); - fail("SearchUsers expected non-empty list"); - } - - // ВАЖНО: ожидаемый логин должен быть в ответе в регистре БД (каноничный expectedLogin) - boolean found = false; - for (String s : logins) { - if (expectedLogin.equals(s)) { - found = true; - break; - } - } - if (!found) { - r.fail("SearchUsers: ожидаемый логин не найден. expected=" + expectedLogin + ", got=" + logins + ", resp=" + resp); - fail("SearchUsers expected login not found"); - } - - r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin); - } - - private static void checkNegativeRequests(TestResult r, WsSession ws, Duration t) { - String badAddUserReqId = TestIds.next("bad-adduser"); - String badAddUser = """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": %d - } - } - """.formatted( - badAddUserReqId, - TestConfig.BCH_NAME(), - TestConfig.SOLANA_PUBKEY_B64(), - TestConfig.BLOCKCHAIN_PUBKEY_B64(), - TestConfig.DEVICE_PUBKEY_B64(), - TestConfig.TEST_BCH_LIMIT - ); - String badAddUserResp = ws.call("AddUser#NEGATIVE", badAddUser, t); - assertErrorFormat(badAddUserResp, "AddUser", badAddUserReqId, "BAD_FIELDS"); - r.ok("Negative AddUser: error format OK"); - - String badGetUserReqId = TestIds.next("bad-getuser"); - String badGetUser = """ - { - "op": "GetUser", - "requestId": "%s", - "payload": { - "login": "" - } - } - """.formatted(badGetUserReqId); - String badGetUserResp = ws.call("GetUser#NEGATIVE", badGetUser, t); - assertErrorFormat(badGetUserResp, "GetUser", badGetUserReqId, "BAD_FIELDS"); - r.ok("Negative GetUser: error format OK"); - - String badSearchReqId = TestIds.next("bad-searchusers"); - String badSearch = """ - { - "op": "SearchUsers", - "requestId": "%s", - "payload": { - "prefix": "" - } - } - """.formatted(badSearchReqId); - String badSearchResp = ws.call("SearchUsers#NEGATIVE", badSearch, t); - assertErrorFormat(badSearchResp, "SearchUsers", badSearchReqId, "BAD_FIELDS"); - r.ok("Negative SearchUsers: error format OK"); - } - - private static void assertErrorFormat(String resp, String op, String requestId, String code) { - int status = JsonParsers.status(resp); - if (status >= 200 && status < 300) fail("Expected non-2xx status: " + resp); - if (!Boolean.FALSE.equals(JsonParsers.ok(resp))) fail("Expected ok=false: " + resp); - if (!op.equals(JsonParsers.op(resp))) fail("Unexpected op: " + resp); - if (!requestId.equals(JsonParsers.requestId(resp))) fail("Unexpected requestId: " + resp); - if (!code.equals(JsonParsers.errorCode(resp))) fail("Unexpected error code: " + resp); - if (!JsonParsers.payloadIsObject(resp)) fail("payload must be object: " + resp); - if (JsonParsers.payloadSize(resp) != 0) fail("error payload must be empty object: " + resp); - if (isBlank(JsonParsers.message(resp))) fail("error message must be present: " + resp); - } - - private static String canonicalLogin(String anyCaseLogin) { - if (anyCaseLogin == null) return null; - String x = anyCaseLogin.trim(); - if (x.isEmpty()) return null; - - // Привязка только к нашим тестовым логинам, чтобы не гадать. - if (x.equalsIgnoreCase(TestConfig.LOGIN())) return TestConfig.LOGIN(); - if (x.equalsIgnoreCase(TestConfig.LOGIN2())) return TestConfig.LOGIN2(); - if (x.equalsIgnoreCase(TestConfig.LOGIN3())) return TestConfig.LOGIN3(); - - return null; - } - - private static String mixCase(String s) { - if (s == null) return null; - String x = s.trim(); - if (x.length() < 2) return x; - // простой "микс" без рандома, чтобы тест был детерминированный - return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); - } - - private static String first3(String s) { - if (s == null) return ""; - String x = s.trim(); - if (x.length() <= 3) return x; - return x.substring(0, 3); - } - - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); - } - - private static String safe(String s) { - return s == null ? "" : s; - } -} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java index adde6f78..35a261e3 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java @@ -32,8 +32,7 @@ public class IT_02_Sessions { private static final String LOGIN = TestConfig.LOGIN(); public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java index d40f4b6f..b767688c 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_03_AddBlock_NoAuth.java @@ -34,8 +34,7 @@ import static org.junit.jupiter.api.Assertions.*; public class IT_03_AddBlock_NoAuth { public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующих пользователей в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java index 3f050f82..74bcd7a6 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java @@ -28,8 +28,7 @@ public class IT_04_UserParams_NoAuth { private static final ObjectMapper M = new ObjectMapper(); public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } @@ -283,4 +282,4 @@ public class IT_04_UserParams_NoAuth { return null; } } -} \ No newline at end of file +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java b/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java index 80055668..f45208c5 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_06_ChannelsApi.java @@ -57,11 +57,12 @@ public class IT_06_ChannelsApi { JsonNode first = messages.get(0); int blockNumber = first.path("messageRef").path("blockNumber").asInt(-1); String blockHash = first.path("messageRef").path("blockHash").asText(""); + String messageBlockchainName = first.path("authorBlockchainName").asText(""); - if (blockNumber > 0 && !blockHash.isBlank()) { + if (!messageBlockchainName.isBlank() && blockNumber >= 0 && !blockHash.isBlank()) { String threadResp = ws.call( "GetMessageThread", - JsonBuilders.getMessageThread(bchName, blockNumber, blockHash, 20, 2, 50), + JsonBuilders.getMessageThread(messageBlockchainName, blockNumber, blockHash, 20, 2, 50), t ); check200(r, threadResp, "GetMessageThread"); @@ -73,7 +74,7 @@ public class IT_06_ChannelsApi { } r.ok("GetMessageThread: payload shape OK"); } else { - r.ok("GetMessageThread: пропущено, у первого сообщения нет корректного ref"); + r.ok("GetMessageThread: пропущено, у первого сообщения нет полного messageRef/authorBlockchainName"); } } else { r.ok("GetMessageThread: пропущено, в канале нет сообщений"); diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java index dcaaff39..381e3d1c 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -7,8 +7,7 @@ import test.it.utils.log.TestLog; import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import utils.crypto.Ed25519Util; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; +import shine.db.dao.CurrentUsersDAO; import utils.crypto.HashSHA256Util; import java.time.Duration; @@ -22,7 +21,7 @@ public class IT_07_EspPairing { private static final String LOGIN = TestConfig.LOGIN(); public static void main(String[] args) { - TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД"); + TestLog.info("Standalone: этот тест требует заранее существующего пользователя в актуальном PDA snapshot"); String summary = run(); System.out.println(summary); } @@ -208,16 +207,10 @@ public class IT_07_EspPairing { } private static void ensureUserSeeded() throws Exception { - if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) { + if (CurrentUsersDAO.getInstance().existsByLogin(LOGIN)) { return; } - SolanaUserEntry entry = new SolanaUserEntry(); - entry.setLogin(LOGIN); - entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); - entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); - entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); - entry.setClientKey(TestConfig.clientPublicKeyB64(LOGIN)); - SolanaUsersDAO.getInstance().insert(entry); + throw new IllegalStateException("Тестовый пользователь отсутствует в актуальном PDA snapshot: " + LOGIN); } private static String derivePairingHash(String login, String password) { diff --git a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java index 9b6bcd0a..b08e3cb9 100644 --- a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java +++ b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java @@ -7,8 +7,6 @@ import blockchain.body.TextBody; import blockchain.body.UserParamBody; import test.it.blockchain.AddBlockSender; import test.it.blockchain.ChainState; -import test.it.utils.TestIds; -import test.it.utils.json.JsonParsers; import test.it.utils.log.TestResult; import test.it.utils.ws.WsSession; import utils.crypto.Ed25519Util; @@ -25,7 +23,8 @@ import java.util.Objects; /** * Вспомогательные функции для массового заполнения тестовой соц-сети через API. - * Вся запись состояния делается только через AddUser / AddBlock. + * Пользователи должны уже существовать в актуальном PDA snapshot сервера. + * Вся запись блокчейн-состояния делается через AddBlock. */ public final class SeedDataPopulationHelper { @@ -52,7 +51,6 @@ public final class SeedDataPopulationHelper { } public void createUsersAndHeaders(List users) { - for (UserSpec user : users) createUserViaApi(user.login); for (UserSpec user : users) initHeader(user.login); } @@ -159,48 +157,6 @@ public final class SeedDataPopulationHelper { ), timeout); } - private void createUserViaApi(String login) { - String requestId = TestIds.next("seed_adduser"); - String req = """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "%s", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": 50000000 - } - } - """.formatted( - requestId, - login, - bch(login), - keys.solanaPublicB64, - keys.blockchainPublicB64, - keys.clientPublicB64 - ); - - String resp = ws.call("AddUser#" + login, req, timeout); - int status = JsonParsers.status(resp); - if (status == 200) { - result.ok("AddUser " + login + ": created"); - return; - } - if (status == 409) { - String code = JsonParsers.errorCode(resp); - if ("USER_ALREADY_EXISTS".equals(code) - || "BLOCKCHAIN_ALREADY_EXISTS".equals(code) - || "BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { - result.ok("AddUser " + login + ": already exists (" + code + ")"); - return; - } - } - throw new IllegalStateException("AddUser failed for " + login + ": status=" + status + ", resp=" + resp); - } - private void initHeader(String login) { ChainState state = new ChainState(); AddBlockSender sender = new AddBlockSender(ws, state, login, bch(login), keys.blockchainPrivate32); diff --git a/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java b/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java index 97d5d11c..40569b8e 100644 --- a/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java +++ b/SHiNE-server/src/test/java/test/it/cases/Seed_TestDataPopulation.java @@ -12,7 +12,8 @@ import static org.junit.jupiter.api.Assertions.fail; * Seed_TestDataPopulation * * Заполняет тестовую соц-сеть через API: - * - пользователи + HEADER через AddUser/AddBlock; + * - пользователи должны уже существовать в актуальном PDA snapshot; + * - HEADER и остальные блоки пишутся через AddBlock; * - параметры профиля (имя, фамилия, пол, адрес, web, phone, official, shine) через AddBlock USER_PARAM; * - связи (close friend/contact/follow/parent/child/sibling) через AddBlock CONNECTION; * - посты через AddBlock TEXT_POST. @@ -34,7 +35,7 @@ public class Seed_TestDataPopulation { SeedDataPopulationHelper seed = new SeedDataPopulationHelper(ws, result, timeout, PASSWORD); // ----------------------------------------------------------------- - // 1) Пользователи и профили + // 1) Заголовки и профили // ----------------------------------------------------------------- List users = buildUsers(); seed.createUsersAndHeaders(users); diff --git a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java index 9c1c9ce8..ae9eca17 100644 --- a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java @@ -1,6 +1,5 @@ package test.it.runner; -import test.it.cases.IT_01_AddUser; import test.it.cases.IT_00_TechnicalRequests; import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; @@ -44,9 +43,6 @@ public class IT_RunAllMain { String s0 = IT_00_TechnicalRequests.run(); summaries.add(s0); if (s0.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s1 = IT_01_AddUser.run(); summaries.add(s1); - if (s1.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s2 = IT_02_Sessions.run(); summaries.add(s2); if (s2.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } diff --git a/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java b/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java index a7c7516f..b0f29901 100644 --- a/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java +++ b/SHiNE-server/src/test/java/test/it/suite/IT_00_Suite.java @@ -2,7 +2,6 @@ package test.it.suite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; -import test.it.cases.IT_01_AddUser; import test.it.cases.IT_02_Sessions; import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.Seed_TestDataPopulation; @@ -15,7 +14,6 @@ import test.it.cases.Seed_TestDataPopulation; */ @Suite @SelectClasses({ - IT_01_AddUser.class, IT_02_Sessions.class, IT_03_AddBlock_NoAuth.class, Seed_TestDataPopulation.class diff --git a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java index 5554a2c2..f62395e8 100644 --- a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java +++ b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java @@ -126,12 +126,12 @@ public final class TestConfig { public static String BCH_NAME2() { return getBlockchainName(LOGIN2()); } public static String BCH_NAME3() { return getBlockchainName(LOGIN3()); } - /** solanaKey для AddUser: публичный ключ Solana-пользователя */ + /** публичный ключ Solana-пользователя */ public static String SOLANA_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN()); } public static String SOLANA2_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN2()); } public static String SOLANA3_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN3()); } - /** blockchainKey для AddUser: публичный ключ блокчейна */ + /** публичный ключ блокчейна */ public static String BLOCKCHAIN_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN()); } public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); } public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); } diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index 07683c2d..20ce116b 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -11,40 +11,6 @@ import java.util.Base64; public final class JsonBuilders { private JsonBuilders() {} - // ---------------- AddUser ---------------- - - public static String addUser(String login) { - String requestId = TestIds.next("adduser"); - String blockchainName = TestConfig.getBlockchainName(login); - - String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login); - String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login); - String clientKeyB64 = TestConfig.clientPublicKeyB64(login); - - return """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "%s", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "clientKey": "%s", - "bchLimit": %d - } - } - """.formatted( - requestId, - login, - blockchainName, - solanaKeyB64, - blockchainKeyB64, - clientKeyB64, - TestConfig.TEST_BCH_LIMIT - ); - } - // ---------------- GetUser ---------------- public static String getUser(String login) { diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..42e7a809 --- /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 +- PostgreSQL JDBC (`org.postgresql:postgresql`) - BSD-2-Clause +- 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/README.md b/TODO/README.md index 5475728f..6a13506f 100644 --- a/TODO/README.md +++ b/TODO/README.md @@ -49,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/medium/2026-07-22_переход_с_sqlite_на_postgresql.md b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md new file mode 100644 index 00000000..72c097a1 --- /dev/null +++ b/TODO/medium/2026-07-22_переход_с_sqlite_на_postgresql.md @@ -0,0 +1,57 @@ +# Переход с SQLite на PostgreSQL + +## Зачем + +Переход runtime-сервера на `PostgreSQL` уже выполнен, но после него остались хвосты в документации, именах, комментариях и части прямых SQL-запросов. + +Этот TODO теперь нужен не для самого перехода, а для доведения проекта до полностью консистентного состояния после ухода от `SQLite`. + +## Что сделать + +- Дочистить документацию, где ещё описан `SQLite` как текущий runtime. +- Убрать или переименовать legacy-названия и комментарии, которые уже не соответствуют PostgreSQL runtime. +- Постепенно перенести оставшиеся прямые SQL-запросы из хэндлеров в DAO/service. +- Проверить case-insensitive сравнения, уникальные ограничения и индексы уже в чисто PostgreSQL модели. +- Отдельно пройтись по TODO/служебным документам и убрать ссылки на удалённые SQLite-классы как на актуальный код. + +## Что уже есть в коде + +- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту. +- Основная серверная логика уже разделена по модулям. +- Runtime-сервер уже работает только с `PostgreSQL`. +- Пустая БД инициализируется автоматически через `schema_v1`. + +## Откуда продолжать + +- Продолжать с зачистки legacy-документации и комментариев. +- Затем добрать оставшиеся прямые SQL-запросы вне DAO. +- После этого можно отдельно решать вопрос косметического переименования `*V2`, `DbController` и других переходных сущностей. + +## Что потом обновить + +- Серверную документацию по БД и миграциям. +- Инструкции по локальному запуску сервера. +- Скрипты деплоя и настройки окружения. + +## Что временно отключено и что вернуть потом + +- В серверном runtime временно снята проверка `channelName must not contain only digits` + в `SHiNE-server/shine-server-db/src/main/java/shine/db/channels/ChannelNameRules.java`. +- Причина: на боевой истории уже есть блоки с числовыми именами каналов, и сервер + должен уметь с нуля восстановить `blockchain_state` и `.bch`, подтягивая старые + блоки от других sync-серверов. +- Что осталось как текущее поведение: + - UI по-прежнему не даёт создать новый канал только из цифр; + - сервер принимает такие имена, чтобы не ломать replay старых блоков. +- Что нужно сделать отдельным следующим шагом: + - вернуть серверное продуктовое правило для новых каналов; + - сделать это совместимо со старой историей, чтобы импорт/реплей существующих + блоков не падал на старых числовых channel name. +- Какие документы обновить при возврате: + - `docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md`; + - UI/серверные документы по правилам имён каналов, если появится отдельная спецификация. +- С какого сценария продолжать: + - повторить cold start тест на `t2`: пустая PostgreSQL schema, удалённые `.bch`, + новый запуск, ожидание полной синхронизации от `t1`/`t3`. +- Последняя полная рабочая точка с этим временным компромиссом: + - ветка `migration-postgres`, коммит будет создан после этой записи. diff --git a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md index e4b1bcb9..2b04642a 100644 --- a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md +++ b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md @@ -11,7 +11,7 @@ ## Что именно потом сделать -- удалить временную миграцию `migrateToV11()` из [SqliteDbController.java](/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java); +- найти историческое место, где была добавлена временная миграция `migrateToV11()`, и убрать её остатки из runtime-логики/документации; - удалить helper `clearLegacySignedMessagesForDmV11(...)`; - поднять версию схемы дальше обычным образом уже без destructive-cleanup; - при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории. 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 4c15330c..a6f9ae24 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.331 -server.version=1.2.303 +client.version=1.5.0 +server.version=1.4.5 diff --git a/build.gradle b/build.gradle index 8014732a..28a0756f 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,8 @@ tasks.jar { tasks.processResources { filteringCharset = 'UTF-8' + inputs.file(appVersionFile) + inputs.property('projectVersion', project.version.toString()) filesMatching('application.properties') { expand(projectVersion: project.version.toString()) } @@ -81,6 +83,7 @@ dependencies { implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов + implementation project(':shine-server-solana-users-sync') 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/AGENT_BOT_CODER_LOCAL_SYSTEMD.md b/deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md index d458841c..8a4c0fc1 100644 --- a/deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md +++ b/deploy/AGENT_BOT_CODER_LOCAL_SYSTEMD.md @@ -2,9 +2,9 @@ ## Где находится сервис -- Папка сервиса: `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` ## Предусловия @@ -18,7 +18,13 @@ Из корня репозитория: ```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 ``` Скрипт: diff --git a/deploy/POSTGRESQL_SERVERS_STANDARD.md b/deploy/POSTGRESQL_SERVERS_STANDARD.md new file mode 100644 index 00000000..911abef0 --- /dev/null +++ b/deploy/POSTGRESQL_SERVERS_STANDARD.md @@ -0,0 +1,223 @@ +# PostgreSQL для серверов SHiNE + +Этот документ фиксирует целевой стандарт PostgreSQL, который должен использоваться на всех серверных контурах SHiNE после ухода от SQLite. + +Важно: + +- целевой стандарт для SHiNE: `PostgreSQL 18.x`; +- текущий факт на production `shineup.me` и `server2.shineup.me` на дату `2026-07-28`: `PostgreSQL 18.4`; +- текущий факт на test/devnet `t1` / `t2` / `t3` / `t4` на дату `2026-07-28`: `PostgreSQL 18.4`. + +## Цель + +На каждом сервере SHiNE должна быть отдельная локальная PostgreSQL-база: + +- запускается в Docker; +- использует `postgres:18`; +- хранит данные не внутри контейнера, а в примонтированной папке хоста; +- доступна только локально на сервере и из Docker-сети; +- не публикуется в интернет; +- автоматически перезапускается после reboot/crash; +- имеет healthcheck; +- использует отдельную БД приложения и отдельного пользователя приложения; +- пароли и секреты хранятся только в локальном `.env`/override-конфиге на сервере и не коммитятся в git. + +## Базовый стандарт + +- Версия PostgreSQL: `18.x` +- Docker image: `postgres:18` +- Имена по умолчанию: + - контейнер/сервис: `shine-postgres` + - база приложения: `shine_server_db` + - пользователь приложения: `shine_server` +- Политика перезапуска: `unless-stopped` +- Проверка готовности: `pg_isready` + +## Сетевой доступ + +PostgreSQL не должна быть доступна из интернета. + +Разрешённые варианты: + +- публиковать порт только на loopback: + - `127.0.0.1:5432:5432` +- либо не публиковать порт вообще, если клиент тоже живёт в Docker и ходит только по внутренней сети + +Запрещено: + +- `0.0.0.0:5432:5432` +- открытие `5432/tcp` через внешний firewall/NAT/public ingress + +Рекомендация по умолчанию для SHiNE: + +- использовать `127.0.0.1:5432:5432` + +Это даёт: + +- локальную диагностику через `psql` на самом сервере; +- отсутствие внешнего доступа из интернета; +- совместимость с приложением, если оно работает не в Docker. + +## Хранение данных + +Данные PostgreSQL должны храниться в локальной папке хоста, а не во внутреннем Docker volume контейнера. + +Причины: + +- проще бэкапить; +- проще переносить между серверами; +- проще контролировать место хранения; +- одинаковая схема для dev/test/production; +- ниже риск потерять данные при пересоздании контейнера. + +Рекомендуемая схема каталогов на сервере: + +Production: + +```text +/home/player/SHiNE/postgres/data +``` + +Test/devnet: + +```text +/home/player/tX/postgres/data +``` + +Если на одном сервере появится несколько баз SHiNE-сервисов, раскладывать их по отдельным каталогам: + +```text +/home/player/SHiNE/postgres/data +/home/player/SHiNE/postgres/analytics_db +/home/player/SHiNE/postgres/other_service_db +``` + +## Пользователи и права + +Нужны два уровня доступа: + +- системный `postgres` superuser для администрирования; +- рабочий пользователь приложения `shine_server` для подключения самого SHiNE server. + +Требования: + +- приложение не должно работать под `postgres`; +- `shine_server` не должен быть `superuser`; +- `shine_server` должен владеть своей БД `shine_server_db`; +- для миграций по умолчанию использовать пользователя приложения, если ему хватает прав; +- административные операции выполнять отдельно под `postgres`. + +## Секреты + +В git нельзя хранить: + +- `.env` с реальными паролями; +- connection strings с паролями; +- SQL-файлы с зашитыми production/test паролями. + +Хранить на сервере локально, например: + +```text +/home/player/SHiNE/postgres/.env +``` + +или в другом root-only каталоге секретов хоста. + +Минимально нужны: + +- пароль `postgres` +- пароль `shine_server` + +Допустимо, но не рекомендуется, временно использовать одинаковый пароль для локального dev. Для production и постоянных test-серверов лучше разные пароли. + +## Пример целевого compose + +Ниже пример целевой схемы, которую можно брать за основу для серверов: + +```yaml +services: + shine-postgres: + image: postgres:18 + container_name: shine-postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_SUPERUSER_DB} + POSTGRES_USER: ${POSTGRES_SUPERUSER} + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD} + ports: + - "127.0.0.1:5432:5432" + volumes: + - ${SHINE_POSTGRES_DATA_DIR}:/var/lib/postgresql + - ./initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_SUPERUSER}" -d "$${POSTGRES_SUPERUSER_DB}" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s +``` + +## Рекомендуемые переменные окружения + +```dotenv +POSTGRES_SUPERUSER_DB=postgres +POSTGRES_SUPERUSER=postgres +POSTGRES_SUPERUSER_PASSWORD=change_me +SHINE_APP_DB=shine_server_db +SHINE_APP_USER=shine_server +SHINE_APP_PASSWORD=change_me_too +SHINE_POSTGRES_DATA_DIR=/home/player/SHiNE/postgres/data +``` + +Для test/devnet путь адаптировать под конкретный контур: + +```dotenv +SHINE_POSTGRES_DATA_DIR=/home/player/t2/postgres/data +``` + +Важно для `postgres:18+`: + +- bind-mount нужно делать на `/var/lib/postgresql`, а не на `/var/lib/postgresql/data`; +- если смонтировать старый путь `/var/lib/postgresql/data`, контейнер `postgres:18` может уйти в restart-loop ещё до инициализации БД; +- после первого старта PostgreSQL сам создаст внутри host-каталога структуру вида `18/docker`. + +## Подключение приложения + +SHiNE server должен подключаться именно к: + +- host: `127.0.0.1` +- port: `5432` +- db: `shine_server_db` +- user: `shine_server` + +Не подключать серверное приложение под `postgres`, кроме одноразовых административных операций вручную. + +## Бэкапы + +Так как данные лежат в bind-mount каталоге хоста, сама папка с данными должна попадать в серверную backup-стратегию. + +Но для PostgreSQL предпочтителен не только файловый backup, а как минимум один из вариантов: + +- регулярный `pg_dump` +- либо полноценный backup-скрипт с остановкой приложения/согласованным snapshot + +Минимальное требование: + +- перед серьёзными миграциями иметь свежий backup БД; +- перед production rollout новой серверной версии иметь проверяемый backup. + +## Для будущей миграции SHiNE + +При переводе нового серверного контура SHiNE с SQLite на PostgreSQL считать обязательным: + +- сначала поднять PostgreSQL по этому стандарту; +- затем добавить серверные конфиги подключения; +- затем прогнать миграции схемы; +- только потом переключать runtime приложения на PostgreSQL. + +Для новых серверов этот документ использовать как обязательный шаблон. +На `2026-07-28` и production, и test/devnet контуры SHiNE уже работают на `PostgreSQL 18.4`. diff --git a/deploy/PRODUCTION_SERVERS.md b/deploy/PRODUCTION_SERVERS.md index 0d9b82d9..f9152cc9 100644 --- a/deploy/PRODUCTION_SERVERS.md +++ b/deploy/PRODUCTION_SERVERS.md @@ -2,6 +2,13 @@ Production-контуров два. В документах и скриптах использовать домены, а не IP: физический VPS можно заменить без изменения deploy-логики. +Текущее фактическое состояние на `2026-07-28`: + +- `shineup.me` и `server2.shineup.me` оба уже переведены на `PostgreSQL 18.4`; +- оба production используют локальный Docker-контейнер `shine-postgres`; +- оба production работают без `SQLite`; +- оба production используют Helius mainnet RPC/WS для Solana users sync и runtime Solana RPC. + ## Основной production - Домен: `shineup.me` @@ -16,6 +23,17 @@ Production-контуров два. В документах и скриптах - Caddy site: `shineup.me` - WebSocket: `/ws` -> `127.0.0.1:7070` - Solana cluster: `mainnet-beta` +- Solana RPC/WS: Helius mainnet +- Runtime DB: PostgreSQL в Docker +- PostgreSQL container: `shine-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- PostgreSQL data dir: `/home/player/SHiNE/postgres` +- PostgreSQL app DB: `shine_main_db` +- PostgreSQL app user: `shine_main` +- Web push: отдельный production VAPID key для `shineup.me` +- TURN для звонков: `turn1.shineup.me`, `turn2.shineup.me` +- SQLite runtime больше не используется Deploy: @@ -38,6 +56,17 @@ bash deploy/scripts/production_shineupme_ui.sh - Caddy site: `server2.shineup.me` - WebSocket: `/ws` -> `127.0.0.1:7070` - Solana cluster: `mainnet-beta` +- Solana RPC/WS: Helius mainnet +- Runtime DB: PostgreSQL в Docker +- PostgreSQL container: `shine-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- PostgreSQL data dir: `/home/player/SHiNE/postgres` +- PostgreSQL app DB: `shine_server2_db` +- PostgreSQL app user: `shine_server2` +- Web push: отдельный production VAPID key для `server2.shineup.me` +- TURN для звонков: `turn1.shineup.me`, `turn3.shineup.me` +- SQLite runtime больше не используется Deploy: diff --git a/deploy/README.md b/deploy/README.md index 78bd95ba..19bd61ea 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,11 +15,21 @@ - Второй production: `server2.shineup.me`. - Test/devnet стенд: `t1.shineup.me`, `t2.shineup.me`, `t3.shineup.me`, `t4.shineup.me`. +Краткое текущее состояние на `2026-07-28`: + +- оба production уже на `PostgreSQL 18.4` в отдельных Docker-контейнерах `shine-postgres`; +- весь test/devnet стенд `t1`-`t4` тоже уже на `PostgreSQL 18.4`; +- тестовые контуры используют общий Docker-контейнер `shine-test-postgres`; +- production использует Helius mainnet, test/devnet использует Helius devnet; +- актуальные подробности по всем шести серверам смотреть в `PRODUCTION_SERVERS.md` и `TEST_SERVERS.md`. + ## Основные документы - `PRODUCTION_SERVERS.md` — production-контуры. - `TEST_SERVERS.md` — test/devnet-контуры. - `TURN_SERVERS.md` — TURN-серверы. +- `POSTGRESQL_SERVERS_STANDARD.md` — целевой стандарт PostgreSQL для всех серверов SHiNE. +- `SOLANA_USERS_SYNC_SERVER_SETUP.md` — интеграция синхронизации пользовательских Solana PDA в основной сервер. - `CONFIGURE_TURN_IN_SHINE.md` — как подключить TURN к SHiNE backend. - `SETUP_SERVER_FROM_ZERO.md` — настройка SHiNE-сервера и UI с нуля. - `SETUP_TURN_SERVER.md` — настройка TURN через Caddy/DNS/TLS. diff --git a/deploy/SETUP_SERVER_FROM_ZERO.md b/deploy/SETUP_SERVER_FROM_ZERO.md index de4b6f6d..22dd7c13 100644 --- a/deploy/SETUP_SERVER_FROM_ZERO.md +++ b/deploy/SETUP_SERVER_FROM_ZERO.md @@ -41,14 +41,19 @@ mkdir -p /home/player/tX/UI Каждый сервер должен иметь локальный внешний конфиг в рабочей директории сервиса. +Актуальные production-значения для `shineup.me` и `server2.shineup.me` смотреть в `deploy/PRODUCTION_SERVERS.md`. +Ниже именно шаблон для нового контура, а не снимок текущего production. + Production пример: ```properties server.port=7070 -server.SHiNE.login=shineupme -db.path=data/shine.sqlite +server.SHiNE.login= +db.url=jdbc:postgresql://127.0.0.1:5432/ +db.user= +db.password=CHANGE_ME server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html -server.info.url=https://shineup.me +server.info.url=https:// server.info.origin=production solana.cluster=mainnet-beta ``` @@ -57,15 +62,23 @@ Test/devnet пример: ```properties server.port=7102 -server.SHiNE.login=server_t2 -db.path=data/shine.sqlite +server.SHiNE.login= +db.url=jdbc:postgresql://127.0.0.1:5432/ +db.user= +db.password=CHANGE_ME 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 +solana.rpcUrl=https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 ``` +Рекомендуемый образ PostgreSQL для новых контуров: `postgres:18`. +Для `postgres:18+` bind-mount нужно делать на `/var/lib/postgresql`, а не на старый путь `/var/lib/postgresql/data`. + +Если указан `db.url` и в выбранной БД ещё нет таблицы `db_schema_version`, +сервер сам создаст runtime-схему PostgreSQL при первом старте. + ## 5. Caddy Минимальный site block: diff --git a/deploy/SETUP_TURN_SERVER.md b/deploy/SETUP_TURN_SERVER.md index cb2d291f..d45a625f 100644 --- a/deploy/SETUP_TURN_SERVER.md +++ b/deploy/SETUP_TURN_SERVER.md @@ -25,6 +25,9 @@ sudo apt install -y coturn caddy sudo bash deploy/scripts/setup_turn_coturn.sh --realm turn1.shineup.me --secret CHANGE_ME_LONG_RANDOM_SECRET ``` +По умолчанию этот скрипт настраивает relay-диапазон `49000-53999`. +Это стандарт SHiNE: 5000 UDP-портов под TURN relay. + ## 3. coturn Секреты не хранить в git. Настраивать на сервере через `/etc/turnserver.conf` или отдельный secret/override. @@ -44,6 +47,14 @@ no-multicast-peers no-cli ``` +Для SHiNE держать relay-range большим сразу, чтобы не упираться в `create_relay_ioa_sockets: no available ports`. +Стандартный диапазон: + +```conf +min-port=49000 +max-port=53999 +``` + Включить сервис: ```bash @@ -83,3 +94,4 @@ sudo ss -lntup | grep -E ':(3478|5349)' - TURN credentials и shared secret не коммитить. - Если TURN домен переносится на другой VPS, менять DNS, а не код приложения. - После изменения TURN-адресов обновить серверные/UI настройки, где они используются. +- Не сужать relay-range ниже 5000 UDP-портов без явной причины: для SHiNE это уже приводило к отказам звонков на проде `24 июля 2026`. diff --git a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md new file mode 100644 index 00000000..b1f121ac --- /dev/null +++ b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md @@ -0,0 +1,106 @@ +# Интеграция Solana users sync в сервер SHiNE + +Этот документ фиксирует серверную конфигурацию модуля синхронизации `shine_users` +и базовую инициализацию новой PostgreSQL runtime-схемы сервера. + +## Что уже есть + +- основной сервер запускает `SolanaUsersSyncStartupService` до продолжения startup; +- модуль синхронизации держит актуальными таблицы: + - `solana_sync_state` + - `solana_sync_tx_history` + - `solana_user_pda_current` + - `user_access_servers_current` + - `solana_user_pda_history` +- источник истины по пользовательским PDA: `solana_user_pda_current`. +- `user_access_servers_current` — это вторичная локальная проекция для быстрого роутинга DM по access servers; + она автоматически пересобирается из `solana_user_pda_current`, включая backfill для уже существующих пользователей. + +## Что должно быть настроено в `application.properties` + +Для текущих production-значений `shineup.me` и `server2.shineup.me` смотреть `deploy/PRODUCTION_SERVERS.md`. +Ниже универсальный пример для нового контура. + +```properties +solana.users.sync.enabled=true +solana.users.sync.rpcUrl=https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 +solana.users.sync.wsUrl=wss://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4 +solana.users.sync.databaseUrl=jdbc:postgresql://127.0.0.1:5432/ +solana.users.sync.dbUser= +solana.users.sync.dbPassword=CHANGE_ME +solana.users.sync.pollIntervalSeconds=300 +``` + +Замечания: + +- `solana.users.sync.enabled=true` обязателен, иначе сервер пропустит startup sync. +- `solana.users.sync.databaseUrl` должен указывать на ту же PostgreSQL БД, где создана серверная runtime-схема. +- `solana.users.sync.wsUrl` задаётся явно, автоматически из `rpcUrl` не строится. + +## Как создать пустую PostgreSQL runtime БД + +SQL-скрипт инициализации лежит в: + +```text +SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +``` + +Пример запуска: + +```bash +psql \ + "postgresql://:CHANGE_ME@127.0.0.1:5432/" \ + -f SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +``` + +Скрипт: + +- создаёт таблицу версии схемы `db_schema_version`; +- ставит `schema_version = 2`; +- создаёт таблицы sync-модуля Solana users; +- создаёт server runtime tables; +- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`; +- не создаёт удалённые legacy-таблицы старого runtime для пользователей и DM; +- использует `signed_messages` как единственную таблицу серверных DM. + +## Как поднять PostgreSQL в Docker + +Шаблоны лежат в: + +```text +deploy/postgres/docker-compose.yml.example +deploy/postgres/.env.example +``` + +Минимальная последовательность: + +```bash +mkdir -p /home/player/SHiNE/postgres +cp deploy/postgres/.env.example /home/player/SHiNE/postgres/.env +cp deploy/postgres/docker-compose.yml.example /home/player/SHiNE/postgres/docker-compose.yml +cd /home/player/SHiNE/postgres +docker compose up -d +``` + +Шаблон `deploy/postgres/docker-compose.yml.example` рассчитан на `postgres:18`. +Для `postgres:18+` он монтирует host-каталог в `/var/lib/postgresql`, это важно для корректного старта контейнера. + +После старта контейнера: + +```bash +cp /path/to/SHiNE-product/application.properties ./application.properties +# задать db.url/db.user/db.password и запустить сервер +``` + +Сложность тут низкая: + +- сам Docker Postgres поднимается просто; +- сервер сам создаёт runtime schema v1, если БД пустая и в ней нет `db_schema_version`; +- основная аккуратность нужна в паролях, bind-mount каталоге и backup; +- для SHiNE важно не открывать `5432` наружу, только `127.0.0.1:5432`. + +## Что пока остаётся как есть + +- `sync_servers` сервер по-прежнему загружает из server PDA в Solana; +- runtime-сервер уже работает только с PostgreSQL; +- дальнейшим отдельным шагом остаются зачистка legacy-документации, переименования и перенос оставшихся прямых SQL-запросов в DAO/service. diff --git a/deploy/TEST_SERVERS.md b/deploy/TEST_SERVERS.md index 95af47d1..164f737c 100644 --- a/deploy/TEST_SERVERS.md +++ b/deploy/TEST_SERVERS.md @@ -9,21 +9,34 @@ Это не production. Все deploy-цели задаются через домены. +Текущее фактическое состояние на `2026-07-28`: + +- все четыре тестовых контура `t1` / `t2` / `t3` / `t4` уже работают без `SQLite`; +- все четыре контура используют `PostgreSQL 18.4`; +- PostgreSQL на test-хосте общий для всех четырёх контуров и запущен в Docker-контейнере `shine-test-postgres`; +- все четыре контура используют один и тот же Helius devnet RPC/WS endpoint; +- во всех четырёх UI включён web push client-код и встроен один и тот же test `VAPID` public key. + ## Общие параметры - SSH: `player@tX.shineup.me` - Solana cluster: `devnet` -- Solana RPC: `https://api.devnet.solana.com` +- Solana RPC: `https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4` +- Solana WS: `wss://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4` - Caddy config: `/etc/caddy/Caddyfile` +- Общий PostgreSQL container: `shine-test-postgres` +- PostgreSQL image: `postgres:18` +- PostgreSQL engine version на `2026-07-28`: `18.4` +- Web push в UI: общий test `VAPID` public key `BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI` ## Инстансы -| Домен | Логин сервера | Сервер | 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` | +| Домен | Логин сервера | Сервер | UI | Порт | systemd | PostgreSQL DB | PostgreSQL user | +|---|---|---|---|---|---|---|---| +| `t1.shineup.me` | `server_t1` | `/home/player/t1/server` | `/home/player/t1/UI` | `7101` | `shine-t1.service` | `shine_t1_db` | `shine_t1` | +| `t2.shineup.me` | `server_t2` | `/home/player/t2/server` | `/home/player/t2/UI` | `7102` | `shine-t2.service` | `shine_t2_db` | `shine_t2` | +| `t3.shineup.me` | `server_t3` | `/home/player/t3/server` | `/home/player/t3/UI` | `7103` | `shine-t3.service` | `shine_t3_db` | `shine_t3` | +| `t4.shineup.me` | `server_t4` | `/home/player/t4/server` | `/home/player/t4/UI` | `7104` | `shine-t4.service` | `shine_t4_db` | `shine_t4` | ## Deploy @@ -54,8 +67,15 @@ curl -I https://t4.shineup.me ```bash sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 +sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' ``` ## Operational-нюанс -Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов. Если после деплоя какой-то инстанс не подтянул `sync_servers`, рестартовать сервисы по одному с паузой. +Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс не подтянул `sync_servers` или завис на startup sync, рестартовать сервисы по одному с паузой. + +Отдельный нюанс по web push: + +- у `t1`, `t2`, `t3`, `t4` в UI уже есть одинаковый встроенный `VAPID` public key; +- то есть `t3` и `t4` используют тот же web push клиентский код, что и `t1` / `t2`; +- если позже понадобится разделить push-ключи по тестовым контурам, это нужно делать отдельным UI deploy с новым `DEPLOY_WEBPUSH_VAPID_PUBLIC`. diff --git a/deploy/TURN_SERVERS.md b/deploy/TURN_SERVERS.md index 3107cb69..8555456d 100644 --- a/deploy/TURN_SERVERS.md +++ b/deploy/TURN_SERVERS.md @@ -17,6 +17,7 @@ TURN нужен для WebRTC-звонков, когда прямое peer-to-pe - DNS каждого `turnX.shineup.me` указывает на актуальный физический сервер TURN. - TLS/HTTPS и вспомогательная маршрутизация обслуживаются через Caddy, если на сервере есть web endpoint для проверки. - `coturn` слушает TURN/STUN порты согласно локальному `turnserver.conf`. +- Стандартный relay-range SHiNE: `49000-53999` (`5000` UDP-портов). - Секреты TURN не хранить в git. ## Что проверять diff --git a/deploy/backup/scheme/shineup.me/scripts/backup_full.sh b/deploy/backup/scheme/shineup.me/scripts/backup_full.sh index ee236052..4e2913e6 100755 --- a/deploy/backup/scheme/shineup.me/scripts/backup_full.sh +++ b/deploy/backup/scheme/shineup.me/scripts/backup_full.sh @@ -10,6 +10,32 @@ 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" @@ -21,17 +47,21 @@ rsync -aH --delete \ --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/" +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] Копирую системные конфиги" -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" +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] Создаю манифест" { diff --git a/deploy/postgres/.env.example b/deploy/postgres/.env.example new file mode 100644 index 00000000..3b7b701c --- /dev/null +++ b/deploy/postgres/.env.example @@ -0,0 +1,7 @@ +POSTGRES_SUPERUSER_DB=postgres +POSTGRES_SUPERUSER=postgres +POSTGRES_SUPERUSER_PASSWORD=CHANGE_ME_SUPERUSER +SHINE_APP_DB=shine_server_db +SHINE_APP_USER=shine_server +SHINE_APP_PASSWORD=CHANGE_ME_APP +SHINE_POSTGRES_DATA_DIR=/home/player/SHiNE/postgres/data diff --git a/deploy/postgres/docker-compose.yml.example b/deploy/postgres/docker-compose.yml.example new file mode 100644 index 00000000..509e5ba1 --- /dev/null +++ b/deploy/postgres/docker-compose.yml.example @@ -0,0 +1,23 @@ +services: + shine-postgres: + image: postgres:18 + container_name: shine-postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_SUPERUSER_DB} + POSTGRES_USER: ${POSTGRES_SUPERUSER} + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD} + ports: + - "127.0.0.1:5432:5432" + volumes: + - ${SHINE_POSTGRES_DATA_DIR}:/var/lib/postgresql + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_SUPERUSER}" -d "$${POSTGRES_SUPERUSER_DB}" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s diff --git a/deploy/scripts/deploy_server.sh b/deploy/scripts/deploy_server.sh index e3647e03..f34dba41 100755 --- a/deploy/scripts/deploy_server.sh +++ b/deploy/scripts/deploy_server.sh @@ -57,8 +57,6 @@ WorkingDirectory=$REMOTE_SERVER_DIR ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT -jar $REMOTE_SERVER_DIR/shine-server.jar Restart=always RestartSec=3 -StandardOutput=append:$REMOTE_LOGS_DIR/app.log -StandardError=append:$REMOTE_LOGS_DIR/app.log [Install] WantedBy=multi-user.target @@ -72,7 +70,6 @@ ssh "$REMOTE_HOST" "set -euo pipefail; \ sudo mv -f '/tmp/$REMOTE_SERVICE_NAME.service' '/etc/systemd/system/$REMOTE_SERVICE_NAME.service'; \ sudo chown root:root '/etc/systemd/system/$REMOTE_SERVICE_NAME.service'; \ sudo chown -R player:player '$REMOTE_SERVER_DIR'; \ - touch '$REMOTE_LOGS_DIR/app.log'; \ sudo systemctl daemon-reload; \ sudo systemctl enable '$REMOTE_SERVICE_NAME'; \ sudo systemctl restart '$REMOTE_SERVICE_NAME'; \ diff --git a/deploy/scripts/deploy_ui.sh b/deploy/scripts/deploy_ui.sh index f1924068..279e2605 100755 --- a/deploy/scripts/deploy_ui.sh +++ b/deploy/scripts/deploy_ui.sh @@ -15,6 +15,7 @@ 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_WEBPUSH_VAPID_PUBLIC="${DEPLOY_WEBPUSH_VAPID_PUBLIC:-}" REQUIRE_BACKUP="${REQUIRE_BACKUP:-0}" BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/backup/archive}" VERSION_FILE="$ROOT_DIR/VERSION.properties" @@ -72,7 +73,7 @@ if [[ -f "$DEPLOY_CONFIG_FILE" ]]; then if [[ -n "$DEPLOY_SOLANA_CLUSTER_NORMALIZED" ]]; then perl -0pi -e "s/export const defaultSolanaCluster = '.*?';/export const defaultSolanaCluster = '$DEPLOY_SOLANA_CLUSTER_NORMALIZED';/s" "$DEPLOY_CONFIG_FILE" fi - if [[ -n "$DEPLOY_SOLANA_ENDPOINT" ]]; then +if [[ -n "$DEPLOY_SOLANA_ENDPOINT" ]]; then perl -0pi -e "s|export const defaultSolanaEndpoint = '.*?';|export const defaultSolanaEndpoint = '$DEPLOY_SOLANA_ENDPOINT';|s" "$DEPLOY_CONFIG_FILE" fi fi @@ -85,6 +86,10 @@ fi perl -0pi -e 's/window\.__SHINE_BUILD_HASH__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_BUILD_HASH__ = '\''$ENV{BUILD_VERSION}'\'';/' "$INDEX_FILE" perl -0pi -e 's/window\.__SHINE_CLIENT_VERSION__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_CLIENT_VERSION__ = '\''$ENV{CLIENT_VERSION}'\'';/' "$INDEX_FILE" +if [[ -n "$DEPLOY_WEBPUSH_VAPID_PUBLIC" ]]; then + export DEPLOY_WEBPUSH_VAPID_PUBLIC + perl -0pi -e 's/window\.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__\s*=\s*'\''[^'\'']*'\'';/window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__ = '\''$ENV{DEPLOY_WEBPUSH_VAPID_PUBLIC}'\'';/' "$INDEX_FILE" +fi echo "==> Checking SSH connectivity to $REMOTE_HOST" ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null diff --git a/deploy/scripts/production_server2_ui.sh b/deploy/scripts/production_server2_ui.sh index aa69af7f..d2ef55fd 100755 --- a/deploy/scripts/production_server2_ui.sh +++ b/deploy/scripts/production_server2_ui.sh @@ -8,7 +8,8 @@ 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" \ +DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \ +DEPLOY_WEBPUSH_VAPID_PUBLIC="BGI4ZG5ADm8MrUG0EQi2G0ZxoqTbZZyfUOzt3O-HrM6z4-zFPUpGGIGcA7Tx3N-O0r5Pie0ndGSdx6kRU2X2x4g" \ TARGET_URL="https://server2.shineup.me" \ REQUIRE_BACKUP="1" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/production_shineupme_ui.sh b/deploy/scripts/production_shineupme_ui.sh index 37fc0474..136ff2f8 100755 --- a/deploy/scripts/production_shineupme_ui.sh +++ b/deploy/scripts/production_shineupme_ui.sh @@ -8,7 +8,8 @@ EXPECTED_CADDY_SITE="shineup.me" \ DEPLOY_SERVER_LOGIN="shineupme" \ DEPLOY_SERVER_ADDRESS="shineup.me" \ DEPLOY_SOLANA_CLUSTER="mainnet" \ -DEPLOY_SOLANA_ENDPOINT="https://solana-rpc.publicnode.com" \ +DEPLOY_SOLANA_ENDPOINT="https://public.rpc.solanavibestation.com/" \ +DEPLOY_WEBPUSH_VAPID_PUBLIC="BEX12-68cSJVYvWSlwgLpd8TqITZrxxh8n20EulUK0OK4u5uWCsuu-iam86zVGYoZJTl1QKxlikGYhB-GLKSsxQ" \ TARGET_URL="https://shineup.me" \ REQUIRE_BACKUP="1" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/setup_turn_coturn.sh b/deploy/scripts/setup_turn_coturn.sh index 20494924..a3d7c68b 100755 --- a/deploy/scripts/setup_turn_coturn.sh +++ b/deploy/scripts/setup_turn_coturn.sh @@ -9,8 +9,8 @@ set -euo pipefail SECRET="" REALM="turn1.shineup.me" -MIN_PORT="49160" -MAX_PORT="49200" +MIN_PORT="49000" +MAX_PORT="53999" while [[ $# -gt 0 ]]; do case "$1" in @@ -23,11 +23,11 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --min-port) - MIN_PORT="${2:-49160}" + MIN_PORT="${2:-49000}" shift 2 ;; --max-port) - MAX_PORT="${2:-49200}" + MAX_PORT="${2:-53999}" shift 2 ;; *) @@ -87,6 +87,8 @@ systemctl --no-pager --full status coturn echo echo "coturn настроен." +echo "Стандартный relay-range SHiNE: ${MIN_PORT}-${MAX_PORT}." +echo "Рекомендуемый размер диапазона: не меньше 5000 UDP-портов." echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp" echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:" echo " -Dcall.ice.turn.sharedSecret=${SECRET}" diff --git a/deploy/scripts/test_t1_ui.sh b/deploy/scripts/test_t1_ui.sh index c097b1fc..1a45b40f 100755 --- a/deploy/scripts/test_t1_ui.sh +++ b/deploy/scripts/test_t1_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t1.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t1" \ DEPLOY_SERVER_ADDRESS="t1.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t1.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t2_ui.sh b/deploy/scripts/test_t2_ui.sh index fbeeaf61..345ba0d2 100755 --- a/deploy/scripts/test_t2_ui.sh +++ b/deploy/scripts/test_t2_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t2.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t2" \ DEPLOY_SERVER_ADDRESS="t2.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t2.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t3_ui.sh b/deploy/scripts/test_t3_ui.sh index fd76ef8e..108d4f5b 100755 --- a/deploy/scripts/test_t3_ui.sh +++ b/deploy/scripts/test_t3_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t3.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t3" \ DEPLOY_SERVER_ADDRESS="t3.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t3.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/deploy/scripts/test_t4_ui.sh b/deploy/scripts/test_t4_ui.sh index 415f1124..7fb11657 100755 --- a/deploy/scripts/test_t4_ui.sh +++ b/deploy/scripts/test_t4_ui.sh @@ -8,6 +8,6 @@ EXPECTED_CADDY_SITE="t4.shineup.me" \ DEPLOY_SERVER_LOGIN="server_t4" \ DEPLOY_SERVER_ADDRESS="t4.shineup.me" \ DEPLOY_SOLANA_CLUSTER="devnet" \ -DEPLOY_SOLANA_ENDPOINT="https://api.devnet.solana.com" \ +DEPLOY_SOLANA_ENDPOINT="https://devnet.helius-rpc.com/?api-key=0614c894-52d8-4ddc-bbbc-0947ce5ec3a4" \ TARGET_URL="https://t4.shineup.me" \ bash "$(dirname "$0")/deploy_ui.sh" diff --git a/docs/API/01_User_Registration_API.md b/docs/API/01_User_Registration_API.md index 1a1bb59c..ba411334 100644 --- a/docs/API/01_User_Registration_API.md +++ b/docs/API/01_User_Registration_API.md @@ -2,13 +2,12 @@ Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями. -Сейчас здесь три метода: +Сейчас здесь два метода: -- `AddUser` — операция отключена (регистрация только через Solana); - `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных; -- `SearchUsers` — dev/test поиск логинов по префиксу. +- `SearchUsers` — dev/test поиск логинов по префиксу, при необходимости с фильтром только по server PDA. -Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет. +Регистрация выполняется только через Solana. ## Статус документа @@ -16,53 +15,7 @@ Текущая регистрация пользователя и текущая проверка, существует пользователь или нет, пока реализованы как серверные dev/test операции. В будущем и регистрация, и проверка identity должны идти напрямую через Solana. ---- - -## 1. Операция `AddUser` - -### Назначение - -Операция отключена. Используется только как явный ответ клиентам старых версий. - -### Запрос - -```json -{ - "op": "AddUser", - "requestId": "reg-001", - "payload": { - "login": "anya", - "blockchainName": "anya-001", - "solanaKey": "BASE64_32_PUBLIC_KEY", - "blockchainKey": "BASE64_32_PUBLIC_KEY", - "clientKey": "BASE64_32_PUBLIC_KEY", - "bchLimit": 1000000 - } -} -``` - -### Пример ответа - -```json -{ - "op": "AddUser", - "requestId": "reg-001", - "status": 410, - "ok": false, - "error": "ADD_USER_DISABLED", - "message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.", - "payload": { - } -} -``` - -### Специфические коды ошибок `AddUser` - -- `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow. - ---- - -## 2. Операция `GetUser` +## 1. Операция `GetUser` ### Назначение @@ -153,11 +106,12 @@ --- -## 3. Операция `SearchUsers` +## 2. Операция `SearchUsers` ### Назначение Поиск пользователей по префиксу логина. Операция зарегистрирована в серверном API и используется как вспомогательная dev/test операция. +При необходимости можно включить фильтр только по server PDA. ### Запрос @@ -166,11 +120,16 @@ "op": "SearchUsers", "requestId": "search-001", "payload": { - "prefix": "an" + "prefix": "an", + "isServer": true } } ``` +- `prefix` — обязательный префикс логина. +- `isServer` — необязательный boolean-флаг; если `true`, сервер вернёт только логины, + у которых в `solana_user_pda_current` стоит `is_server = true`. + ### Успешный ответ ```json @@ -195,7 +154,6 @@ ## 4. Короткое резюме -- `AddUser` — отключен (`410 / ADD_USER_DISABLED`). - `GetUser` — проверка существования пользователя на сервере. - `SearchUsers` — временный поиск пользователей по префиксу. - Регистрация выполняется только через Solana. diff --git a/docs/API/02_Authentication_API.md b/docs/API/02_Authentication_API.md index 735b4b2d..470434b4 100644 --- a/docs/API/02_Authentication_API.md +++ b/docs/API/02_Authentication_API.md @@ -138,7 +138,7 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} Перед проверкой подписи сервер должен: -1. взять актуальный `solana_users.client_key`; +1. взять актуальный `solana_user_pda_current.client_key`; 2. сравнить его с `payload.clientKey`; 3. только потом проверять подпись. diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index b0ec501e..c156605f 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -88,6 +88,7 @@ - `limit_exceeded` - `chain_resync_in_progress` — цепочка временно заблокирована полным resync - `repost_disabled` — репосты временно отключены до будущей реализации +- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` - `internal_error` ## 5. Какие блоки реально можно добавлять через `AddBlock` @@ -104,6 +105,7 @@ - `TEXT_REPLY (20)` - `TEXT_EDIT_REPLY (21)` - `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` + - `TEXT_CHANNEL_META (70)` — скрытый технический снимок профиля канала 3. **REACTION (type=2)** - `REACTION_LIKE (1)` @@ -133,7 +135,57 @@ 5. **USER_PARAM (type=4)** - `USER_PARAM_TEXT_TEXT (1)` -## 6. Хватает ли функций сейчас +## 6. Практические payload-форматы для каналов и вложений + +`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. + +### Вложения в сообщениях + +Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`. + +Пример текстового содержимого body: + +```text + + +Текст сообщения +``` + +Пример вложения с отдельным preview-файлом: + +```text + +``` + +Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. + +### Создание публичного канала с профилем + +Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст: + +```text + + +Описание канала +``` + +Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`. + +### Изменение профиля канала + +Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=70)`. + +Текстовое содержимое body использует тот же формат полного снимка профиля: + +```text + + +Новое описание канала +``` + +Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `SHiNE:avatar` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`. + +## 7. Хватает ли функций сейчас Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**. @@ -147,7 +199,7 @@ - нет API списка подписок с серверными счётчиками непрочитанного; - нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC. -## 7. Рекомендации по клиенту при записи блоков +## 8. Рекомендации по клиенту при записи блоков 1. Перед отправкой держать локальный `lastNumber/lastHash`. 2. При `bad_prev_hash` или `bad_block_number`: @@ -157,7 +209,7 @@ 4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост. -## 8. USER_PARAM для «личных данных» +## 9. USER_PARAM для «личных данных» Да, на текущем API это можно добавить **без изменения серверного кода**: @@ -183,7 +235,7 @@ --- -## 9. `GetBlockchainBlock` +## 10. `GetBlockchainBlock` ### Назначение diff --git a/docs/API/05_Technical_Requests_API.md b/docs/API/05_Technical_Requests_API.md index 79604bc6..d458d04a 100644 --- a/docs/API/05_Technical_Requests_API.md +++ b/docs/API/05_Technical_Requests_API.md @@ -19,7 +19,7 @@ - `Ping` нужен для регулярной проверки, что соединение всё ещё живо; - `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. - `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону. -- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC. +- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальную runtime-проекцию пользователя и `blockchain_state` без прямого обращения в Solana. - `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост. Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. @@ -216,7 +216,7 @@ - `clientKey` - `blockchainSizeLimitBytes` -После этого принимающий сервер может локально создать записи в `solana_users` и `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`. +После этого принимающий сервер может локально создать runtime-проекцию пользователя и запись в `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`. Этот запрос доступен без авторизации и предназначен именно для server-to-server sync. diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 6008cc59..ad6c30d3 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -53,6 +53,12 @@ "ownerLogin": "Alice", "ownerBlockchainName": "alice-001", "channelName": "0", + "displayName": "Мой канал", + "channelDescription": "Короткое описание", + "avaAr": "ArweaveTxId...", + "avaSha256": "0123...", + "avaSize": 248193, + "metaUpdatedAtMs": 1760000000000, "personal": true, "channelRoot": { "blockNumber": 0, "blockHash": "..." } }, @@ -141,8 +147,42 @@ "ownerLogin": "Bob", "ownerBlockchainName": "bob-001", "channelName": "news", + "displayName": "Новости Bob", + "channelDescription": "Канал о новостях", + "avaAr": "ArweaveTxId...", + "avaSha256": "0123...", + "avaSize": 248193, + "metaUpdatedAtMs": 1760000000000, "channelRoot": { "blockNumber": 123, "blockHash": "..." } }, + "metaEvents": [ + { + "kind": "created", + "messageRef": { "blockNumber": 123, "blockHash": "..." }, + "authorLogin": "Bob", + "authorBlockchainName": "bob-001", + "lineStep": 1, + "createdAtMs": 1760000000000, + "title": "news", + "description": "", + "avaAr": "", + "avaSha256": "", + "avaSize": 0 + }, + { + "kind": "profile_updated", + "messageRef": { "blockNumber": 130, "blockHash": "..." }, + "authorLogin": "Bob", + "authorBlockchainName": "bob-001", + "lineStep": 2, + "createdAtMs": 1760000500000, + "title": "Новости Bob", + "description": "Канал о новостях", + "avaAr": "ArweaveTxId...", + "avaSha256": "0123...", + "avaSize": 248193 + } + ], "messages": [ { "messageRef": { "blockNumber": 140, "blockHash": "..." }, diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 2fad3d51..69a01682 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -12,7 +12,6 @@ | Операция | Раздел документации | Кратко | | --- | --- | --- | -| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | @@ -62,6 +61,8 @@ | `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | | `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | | `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | +| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору | +| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | | `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию | diff --git a/docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md index 2a12d0a1..f576bb2e 100644 --- a/docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -10,7 +10,8 @@ Важно: - legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; -- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`. +- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`; +- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI. ## 1. `UpsertPushToken` @@ -139,11 +140,14 @@ "op": "ReceiveIncomingMessage", "requestId": "dm-in-001", "payload": { - "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK" + "incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику. + ## 5. `DeleteMessage` Принимает один signed DM-блок `type=5` или `type=6`. @@ -176,7 +180,134 @@ } ``` -## 7. `AckSessionDelivery` +## 7. `GetDirectMessages` + +Требует авторизации. Возвращает историю диалога с конкретным собеседником страницами. + +Важно: + +- начиная с 22 июля 2026 года сервер больше не высылает старую DM-историю автоматически при логине; +- после подключения клиент должен сам запросить первую страницу диалога; +- новые realtime-сообщения по-прежнему приходят событием `SignedMessageArrived`. +- в `GetDirectMessages` сервер отдаёт только обычные chat-сообщения (`type=1/2`), без read-receipt и delete/tombstone. + +### Запрос + +```json +{ + "op": "GetDirectMessages", + "requestId": "dm-history-001", + "payload": { + "peerLogin": "bob", + "limit": 50, + "beforeTimeMs": 1774700000123, + "beforeMessageKey": "alice|bob|1774700000123|123456789|1" + } +} +``` + +`beforeTimeMs` и `beforeMessageKey` необязательны. Если их нет, сервер вернёт самую новую страницу. + +### Успешный ответ + +```json +{ + "op": "GetDirectMessages", + "requestId": "dm-history-001", + "status": 200, + "ok": true, + "payload": { + "login": "alice", + "peerLogin": "bob", + "limit": 50, + "hasMore": true, + "nextBeforeTimeMs": 1774699999000, + "nextBeforeMessageKey": "alice|bob|1774699999000|123456780|2", + "messages": [ + { + "messageKey": "alice|bob|1774700000123|123456789|1", + "baseKey": "alice|bob|1774700000123|123456789", + "fromLogin": "alice", + "toLogin": "bob", + "messageType": 1, + "timeMs": 1774700000123, + "nonce": 123456789, + "revisionTimeMs": 0, + "reencryptedAtMs": 0, + "createdAtMs": 1774700001123, + "readAtMs": 1774700001456, + "blobB64": "BASE64_SIGNED_BLOCK" + } + ] + } +} +``` + +Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа. + +## 8. `DmSyncBatch` + +Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`. + +### Запрос + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "payload": { + "ownerLogin": "alice", + "afterStoredAtMs": 1774700000000, + "afterMessageKey": "alice|bob|1774699999000|123456780|2", + "limit": 500, + "maxBytes": 3000000 + } +} +``` + +`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`. + +### Успешный ответ + +```json +{ + "op": "DmSyncBatch", + "requestId": "dm-sync-001", + "status": 200, + "ok": true, + "payload": { + "ownerLogin": "alice", + "limit": 500, + "rawBytes": 84512, + "hasMore": true, + "nextStoredAtMs": 1774700100000, + "nextMessageKey": "alice|bob|1774700000123|123456789|1", + "items": [ + { + "messageKey": "alice|bob|1774700000123|123456789|1", + "baseKey": "alice|bob|1774700000123|123456789", + "targetLogin": "alice", + "fromLogin": "bob", + "toLogin": "alice", + "messageType": 1, + "timeMs": 1774700000123, + "storedAtMs": 1774700100000, + "blobB64": "BASE64_SIGNED_BLOCK" + } + ] + } +} +``` + +События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`. + +Ошибки: + +- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin` +- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя +- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login` + +## 9. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. @@ -192,7 +323,7 @@ } ``` -## 8. Событие `SignedMessageArrived` +## 10. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. @@ -217,15 +348,15 @@ Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. -## 9. `CallInviteBroadcast` +## 11. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 10. `CallSignalToSession` +## 12. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 11. Замечания +## 13. Замечания - все DM-типы `1..8` используют `SHiNE_DM` - `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE diff --git a/docs/API/14_Test_Free_Avatar_Upload_API.md b/docs/API/14_Test_Free_Avatar_Upload_API.md index 29438ff6..7fb55656 100644 --- a/docs/API/14_Test_Free_Avatar_Upload_API.md +++ b/docs/API/14_Test_Free_Avatar_Upload_API.md @@ -9,7 +9,7 @@ - загрузка идёт через **серверный Arweave-кошелёк**; - лимит на пользователя: по умолчанию `3` загрузки за всё время; -- лимит хранится в SQLite-таблице `test_free_avatar_uploads`; +- лимит хранится в PostgreSQL-таблице `test_free_avatar_uploads`; - если лимит исчерпан, сервер возвращает понятную ошибку; - загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`. diff --git a/docs/Blockchain/01_Channel_Types_and_CreateChannel.md b/docs/Blockchain/01_Channel_Types_and_CreateChannel.md index 0322cdaf..742a7948 100644 --- a/docs/Blockchain/01_Channel_Types_and_CreateChannel.md +++ b/docs/Blockchain/01_Channel_Types_and_CreateChannel.md @@ -9,6 +9,23 @@ Payload включает: 4. `channelType` (`uint16`, 2 байта); 5. `channelTypeVersion` (`uint16`, 2 байта). +### `channelDescription` + +`channelDescription` хранится как `uint16 length + UTF-8`, максимум `2048` байт. + +Для публичных каналов (`channelType=1`) поле является начальным снимком профиля канала и использует тот же текстовый meta-формат, что `TEXT_CHANNEL_META`: + +```text + + +Описание канала. +``` + +Если `title` отсутствует, человекочитаемым именем считается техническое `channelName`. +Если `avatar` отсутствует, канал создаётся без аватара. + +Новые UI-клиенты создают публичный канал одним блоком `TECH_CREATE_CHANNEL`; отдельный `TEXT_CHANNEL_META` при создании не пишется. `TEXT_CHANNEL_META` используется для последующих изменений профиля канала. + ## 2. Типы каналов - `0` — `stories` (root-канал пользователя). - `1` — публичный канал. diff --git a/docs/Blockchain/02_Channel_Commands.md b/docs/Blockchain/02_Channel_Commands.md index 2fe6c3be..417040f9 100644 --- a/docs/Blockchain/02_Channel_Commands.md +++ b/docs/Blockchain/02_Channel_Commands.md @@ -1,33 +1,19 @@ # Командные сообщения каналов -## 1. Общий префикс -Командные сообщения распознаются по префиксу: +Команды в обычных `TEXT_POST` сообщениях каналов больше не используются для изменения профиля канала. -`/.` +## Удалённая команда `/.desc` -Пример: -- `/.desc Новый комментарий канала` +Команда `/.desc ` больше не поддерживается и не применяется сервером. -## 2. Поддерживаемые команды +Описание, человекочитаемое имя и аватар канала меняются только через скрытый технический блок: -### Для всех типов каналов (`0`, `1`, `100`, `200`) -- `/.desc ` — смена описания канала. +- `msg_type=1` +- `subType=70` +- `TEXT_CHANNEL_META` -Примечание: -- Описание канала в чтении определяется последней командой `/.desc` в линии канала. -- Если `/.desc` не было, используется описание из `CreateChannel`. +Спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). -### Дополнительно для `type=200` -- `/.add ` -- `/.remove ` +## Зарезервированные команды type=200 -Формат аргументов фиксирован: через пробел. - -## 3. Текущая модель применения -- Команды передаются как обычные `TEXT_POST` сообщения. -- Сервер уже применяет `/.desc` при вычислении актуального описания канала. -- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации. - -## 4. Статус для MVP -- В текущем UI каналы `type=100` и `type=200` не используются. -- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии. +Команды `/.add` и `/.remove` для групповых каналов `type=200` остаются зарезервированными для будущей модели участников. В обычном UI текущего этапа они не используются. diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index fdf58f25..63d68d65 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -27,6 +27,14 @@ TEXT-тип хранит сообщения и редактирования. - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются); - временно отключён для записи через `AddBlock` до будущей реализации репостов. +6. `subType=70` — `TEXT_CHANNEL_META` + - скрытый технический снимок профиля канала; + - содержит line-поля + текст с тегами `SHiNE:title`/`SHiNE:avatar` и описанием; + - не отображается как обычное сообщение ленты; + - применяется сервером к текущему состоянию канала. + +Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md). + ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. @@ -36,3 +44,43 @@ TEXT-тип хранит сообщения и редактирования. - Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. - Такой edit трактуется как логическое удаление содержимого сообщения. - Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. + +## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`) + +Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`. + +Подробная спецификация: [15_TEXT_Attachments.md](./15_TEXT_Attachments.md). + +Бинарный формат `TextBody` не меняется: вложения являются частью UTF-8 текста. + +Один блок вложения: + +```text + +``` + +Для файлов с отдельным превью клиент может использовать расширенный вариант: + +```text + +``` + +Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст. + +Обязательные поля: + +- `v=1` или `v=2` +- `name` - имя файла, закодированное через `encodeURIComponent` +- `size` - размер файла в байтах +- `sha256` - SHA-256 исходного файла в hex +- `ar` - короткий Arweave Transaction ID, без полного URL +- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256` + +Правила клиента: + +- если сообщение начинается с одного или нескольких валидных `` блоков, новый клиент скрывает эти блоки и показывает карточки вложений; +- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных; +- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст; +- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок. + +Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`. diff --git a/docs/Blockchain/15_TEXT_Attachments.md b/docs/Blockchain/15_TEXT_Attachments.md new file mode 100644 index 00000000..9500a5c3 --- /dev/null +++ b/docs/Blockchain/15_TEXT_Attachments.md @@ -0,0 +1,124 @@ +# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`) + +Документ фиксирует текущий формат вложений в текстовых блоках SHiNE. + +## Область применения + +Формат применяется к UTF-8 полю `text` в TEXT-блоках: + +- `TEXT_POST` +- `TEXT_REPLY` +- `TEXT_EDIT_POST` +- `TEXT_EDIT_REPLY` + +Бинарный формат `TextBody` не меняется. Вложения являются техническим префиксом внутри обычного текста. + +## Общий вид + +Один attach-блок: + +```text + +``` + +Блок с превью: + +```text + +``` + +Несколько вложений идут подряд в самом начале текста: + +```text + + +Текст сообщения +``` + +После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение. + +Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы. + +## Поля + +Обязательные поля: + +- `v=1` - версия формата attach-блока; +- `name` - имя файла, закодированное через `encodeURIComponent`; +- `size` - размер файла в байтах; +- `sha256` - SHA-256 исходного файла в hex, 64 символа; +- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL. + +Дополнительные поля для `v=2`: + +- `previewAr` - короткий Arweave Transaction ID файла-превью; +- `previewSha256` - SHA-256 файла-превью в hex. + +## Хранение файла + +Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки: + +- имя файла; +- размер; +- SHA-256; +- Arweave `txId`. + +Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`. + +## Создание вложения в UI + +UI поддерживает три сценария: + +- загрузить новый файл в Arweave выбранным Arweave-кошельком; +- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`; +- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии. + +Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла: + +- основной видеофайл; +- отдельное изображение-превью. + +После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`. + +При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью. + +Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна. +В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения. +Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением. +Если история очищена, уже созданные сообщения не меняются: в блокчейне остаются attach-блоки с `txId`. + +## Отображение + +Новый клиент при чтении сообщения: + +1. читает валидные attach-блоки только из начала текста; +2. скрывает технические attach-строки; +3. отображает вложения над пользовательским текстом; +4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`; +5. определяет тип вложения по расширению имени файла: + - изображение показывает как ограниченное по размеру превью; + - видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию; + - обычный файл показывает как карточку с именем, расширением, размером и скачиванием; +6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок; +7. при перелистывании останавливает воспроизводящееся видео; +8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. + +Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок: + +- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут; +- если сообщение старше 20 минут: файл считается недоступным. + +Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст. + +## Редактирование + +При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`. + +## Совместимость и ограничения + +- `ar` допускает только короткий Arweave `txId`, полный URL не используется; +- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла; +- старые блоки `v=1` без превью остаются валидными и читаются без изменений; +- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны; +- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями; +- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки. diff --git a/docs/Blockchain/16_TEXT_Channel_Meta.md b/docs/Blockchain/16_TEXT_Channel_Meta.md new file mode 100644 index 00000000..51220e23 --- /dev/null +++ b/docs/Blockchain/16_TEXT_Channel_Meta.md @@ -0,0 +1,61 @@ +# TEXT_CHANNEL_META: профиль канала + +`TEXT_CHANNEL_META` — скрытый технический `TEXT`-блок для полного снимка профиля канала после создания. + +## Блок + +- `msg_type = 1` +- `subType = 70` +- `msgVersion = 1` +- body использует тот же бинарный формат line-текста, что и `TEXT_POST`: line-поля + `textLen` + UTF-8 текст. + +Блок пишется только в линию канала владельца. В обычной ленте сообщений он не отображается как пользовательский пост. + +Первый снимок профиля публичного канала хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же текстовым форматом. Отдельный `TEXT_CHANNEL_META` при создании нового публичного канала UI не пишет. + +## Текстовый формат + +В начале текста могут идти технические теги, после них обычный текст описания: + +```text + + +Описание канала. +``` + +Поддерживаемые теги: + +- `` — человекочитаемое имя канала. +- `` — аватар канала в Arweave. + +## Правила + +- Каждый `TEXT_CHANNEL_META` задаёт полный снимок состояния профиля канала. +- Старые meta-блоки остаются в блокчейне как история, но текущее состояние берётся из последнего валидного meta-блока. +- Если `title` отсутствует, используется техническое имя канала. +- Если `avatar` отсутствует, аватар считается сброшенным. +- Если описание после тегов отсутствует, описание считается пустым. +- `meta_updated_at_ms` равен времени самого валидного meta-блока. + +## Ограничения + +- Длина `title` — максимум 50 Unicode code points. +- В `title` запрещены `<`, `>`, `;`, табуляция, перевод строки и `\0`. +- Длина описания — максимум 250 Unicode code points. +- `avatar.ar` — Arweave transaction id из 43 символов. +- `avatar.sha256` — 64 hex-символа. +- `avatar.size` — положительный размер файла в байтах. + +Если meta-блок невалиден, сервер не применяет его целиком. + +## UI + +- При создании публичного канала UI записывает человекочитаемое имя, аватар и описание в `TECH_CREATE_CHANNEL.channelDescription`. +- При создании канала UI показывает системную карточку `Создан канал ...`. +- При записи `TEXT_CHANNEL_META` UI показывает системную карточку `Изменён профиль канала`. +- У системных карточек нет лайка, ответа, репоста, редактирования и других действий обычного сообщения. +- По клику на системную карточку показываются аватар, человекочитаемое имя и описание на момент этого события. + +## Замена старых команд + +Команда `/.desc` больше не используется и не применяется сервером. Описание канала меняется только через `TEXT_CHANNEL_META`. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 25fabfeb..e9b5a317 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,56 @@ # История изменений документации блокчейна +## 2026-08-04 12:00:00 +0400 +- Базовый коммит-ориентир: `391b18a`. +- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`. +- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением. +- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью. + +## 2026-07-31 17:22:07 +0400 +- Базовый коммит-ориентир: `05bd9f5`. +- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён. + +## 2026-07-31 17:13:26 +0400 +- Базовый коммит-ориентир: `60ab476`. +- Уточнён UI журнала загрузок Arweave: статус доступности перенесён в начало второй строки плитки, верхняя панель закреплена как fixed-полоса вне прокрутки, в меню добавлен пункт `Добавить файл`, меню закрывается при клике вне него; формат attach-блока не изменён. + +## 2026-07-31 17:06:55 +0400 +- Базовый коммит-ориентир: `bcae285`. +- Уточнён UI журнала загрузок Arweave: плитки принудительно не растягиваются по высоте, а контейнер журнала запрещает горизонтальную прокрутку; формат attach-блока не изменён. + +## 2026-07-31 16:58:00 +0400 +- Базовый коммит-ориентир: `cd67176`. +- Уточнён UI журнала загрузок Arweave: экран называется `Загрузка файлов`, верхняя панель стала компактной и закреплённой сверху, плитки журнала тёмные и уменьшены по высоте; формат attach-блока не изменён. + +## 2026-07-31 13:48:57 +0400 +- Базовый коммит-ориентир: `98eba54`. +- Уточнено UI-поведение журнала загрузок Arweave: экран называется `Загрузить файлы в блокчейн`, использует компактные плитки и локальный флаг `Не добавлен в SHiNE` для файлов, которые загружены заранее, но ещё не отправлены во вложении сообщения. +- Зафиксировано, что флаг хранится только в клиентском sessionStorage и снимается после успешной отправки сообщения с выбранным вложением; формат attach-блока не изменён. + +## 2026-07-31 13:12:24 +0400 +- Базовый коммит-ориентир: `c530627`. +- Для UI-вложений добавлен отдельный экран журнала загрузок Arweave в настройках: загрузка файла без создания сообщения, плитки истории с `txId`, размером, датой и статусом доступности через gateway. +- Уточнено, что журнал хранится только в `sessionStorage` текущего браузера и не влияет на уже записанные attach-блоки в блокчейне. + +## 2026-07-31 12:47:41 +0400 +- Базовый коммит-ориентир: `f961947`. +- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов. +- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков. +- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов. + +## 2026-07-30 11:12:47 +0400 +- Базовый коммит-ориентир: `e9e6628`. +- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`. +- В `docs/Blockchain/README.md` добавлена ссылка на документ вложений. +- В UI порядок отображения сообщения с вложениями закреплён как: вложения сверху, пользовательский текст снизу. + +## 2026-07-30 10:31:26 +0400 +- Базовый коммит-ориентир: `c7684d6`. +- Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксирован клиентский формат вложений `SHiNE:attach v=1`. +- Бинарный `TextBody` не изменён: вложения записываются техническими строками в начале UTF-8 `text`. +- Вложение хранит `name`, `size`, `sha256` и короткий Arweave `txId` в поле `ar`; имя файла кодируется через `encodeURIComponent`. +- В `docs/Blockchain/11_TEXT_Blocks.md` добавлены правила совместимости, отображения и допустимости пустого пользовательского текста при наличии вложений. + ## 2026-07-14 20:43:43 +0400 - Базовый коммит-ориентир: `118be41`. - В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта: @@ -68,6 +119,27 @@ - В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации. - В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`. +## 2026-08-01 01:45:29 +0400 +- Базовый коммит-ориентир: `24afcba`. +- Добавлен новый `TEXT`-подтип `TEXT_CHANNEL_META (subType=70)` для скрытого технического снимка профиля канала. +- Зафиксирован формат тегов `` и ``; описание хранится хвостовым UTF-8 текстом. +- UI создания канала может сразу после `TECH_CREATE_CHANNEL` записать первый `TEXT_CHANNEL_META` с человекочитаемым именем, описанием и аватаром. +- Команда `/.desc` удалена из актуальной модели изменения описания канала; профиль канала меняется только через `TEXT_CHANNEL_META`. +- API чтения каналов расширен полями `displayName`, `avaAr`, `avaSha256`, `avaSize`, `metaUpdatedAtMs` и списком `metaEvents`. + +## 2026-08-01 02:18:00 +0400 +- Базовый коммит-ориентир: `8125d1a`. +- Увеличен лимит `CreateChannelBody.channelDescription` до `2048` UTF-8 байт. +- Для публичных каналов начальный профиль канала теперь хранится прямо в `TECH_CREATE_CHANNEL.channelDescription` тем же meta-форматом, что `TEXT_CHANNEL_META`. +- UI создания публичного канала больше не пишет отдельный `TEXT_CHANNEL_META` сразу после создания; отдельный meta-блок используется только для последующих изменений профиля. +- Bootstrap `channel_names_state` при старте сервера теперь применяет последующие `TEXT_CHANNEL_META` поверх начального профиля из `TECH_CREATE_CHANNEL`. + +## 2026-07-31 17:40:31 +0400 +- Базовый коммит-ориентир: `d640dd6`. +- Формат блокчейна не менялся. +- В UI каналов добавлен технический просмотр данных сообщения из уже полученного ответа сервера: цепочка автора, номер записи, хэш, тип, время, текст, public key/подпись при наличии и сырой JSON-блок. +- Удалённые сообщения в UI теперь отображаются компактной строкой с переходом в историю версий. + ## 2026-05-21 19:05:00 +0300 - Базовый коммит-ориентир: `5344c42`. - Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`: diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index 302df73f..438eb4ff 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -3,25 +3,29 @@ Этот каталог описывает только текущий рабочий формат протокола для MVP. ## Основные документы -1. [01_Common_Block_Format.md](./01_Common_Block_Format.md) +1. [01_Common_Block_Format.md](./01_Common_Block_Format.md) Единый бинарный формат блока (Frame v0), подпись, базовые проверки. -2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md) +2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md) Виды цепочек и правила line-полей. -3. [10_TECH_Blocks.md](./10_TECH_Blocks.md) +3. [10_TECH_Blocks.md](./10_TECH_Blocks.md) Системные блоки (`msg_type=0`). -4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md) +4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md) Текстовые блоки (`msg_type=1`). -5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md) +5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md) Реакции (`msg_type=2`). -6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md) +6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md) Социальные связи (`msg_type=3`). -7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) +7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) Параметры пользователя (`msg_type=4`). -8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) +8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md) + Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений. +9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) + Скрытый `TEXT_CHANNEL_META` для профиля канала. +10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) Типы каналов и формат `CreateChannelBody`. -9. [02_Channel_Commands.md](./02_Channel_Commands.md) +11. [02_Channel_Commands.md](./02_Channel_Commands.md) Команды в текстовых сообщениях каналов. -10. [CHANGELOG.md](./CHANGELOG.md) +12. [CHANGELOG.md](./CHANGELOG.md) Журнал изменений документации. ## Смежная документация diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index d4413ed8..07d831b5 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -60,7 +60,7 @@ - `ListBlockchainHeads` — список heads всех локальных цепочек партнёра; - `GetBlockchainBlock` — чтение одного конкретного блока партнёра; -- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания `solana_users + blockchain_state` без обращения в Solana RPC. +- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC. ### 4.3 Как сейчас работает periodic sync @@ -73,7 +73,7 @@ - локальное состояние; 3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`; 4. каждый скачанный блок локально применяется через существующий `AddBlock`; -5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + blockchain_state`. +5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`. 6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync: - цепочка помечается in-memory как `resync in progress`; - создаётся marker-file в `data/`; @@ -110,7 +110,7 @@ Full resync запускается только тогда, когда: Важно: - full resync не делает умный rollback по одному блоку; -- full resync не трогает DM-таблицы и `solana_users`; +- full resync не трогает DM-таблицы и current users слой; - висячие cross-chain ссылки считаются допустимым поведением системы. ### 4.5 Как работает обычный `AddBlock` и его recovery @@ -150,7 +150,7 @@ Full resync запускается только тогда, когда: - из `blockchainName` извлекался `login`; - сервер вызывал import пользователя из Solana PDA; -- по данным PDA локально создавались `solana_users + blockchain_state`. +- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`. На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`. @@ -159,7 +159,7 @@ Full resync запускается только тогда, когда: - настройка `sync.importUserProfileFromPartner.enabled=true` - в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync; - вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра. -- если локальный `solana_users` уже существует, sync восстанавливает только `blockchain_state` и не трогает identity-слой. +- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer. Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 5effcafd..3798f1bb 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -103,7 +103,7 @@ Для DM-проверки сервер использует: -- локальную `solana_users` как кэш; +- локальную runtime-проекцию пользователей как кэш; - Solana PDA как источник истины по `clientKey` и `access_servers`. Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: @@ -446,11 +446,14 @@ Request: "op": "ReceiveIncomingMessage", "requestId": "req-456", "payload": { - "incomingBlobB64": "..." + "incomingBlobB64": "...", + "sourceServerLogin": "server-a" } } ``` +`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику. + ### 10.3. `DeleteMessage` Назначение: @@ -509,6 +512,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. Клиентская сторона @@ -530,7 +562,50 @@ UI-следствие для клиента: Идемпотентность обязательна. -### 11.3. Ошибки доставки +### 11.3. Догоняющая синхронизация истории + +Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция: + +- `DmSyncBatch` + +Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору: + +- `ownerLogin`; +- `afterStoredAtMs`; +- `afterMessageKey`; +- `limit`, максимум `500`; +- `maxBytes`, ограничение суммарного размера raw-блоков пачки. + +Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю: + +- контентные копии и read-receipt по `target_login`; +- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`. + +Порядок пачки: + +- `created_at_ms ASC`; +- `message_key ASC`. + +Курсор хранится локально для пары: + +- пользователь; +- удалённый access-сервер. + +При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками. + +При применении событий, полученных через `DmSyncBatch`, сервер: + +- проверяет формат `SHiNE_DM`; +- проверяет подпись; +- применяет существующие правила ревизий, read-receipt и tombstone; +- не отправляет realtime/push-уведомления клиентам; +- не запускает повторный fan-out, чтобы не создавать циклы. + +Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов. + +В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`. + +### 11.4. Ошибки доставки Если часть серверов временно недоступна: @@ -542,14 +617,17 @@ UI-следствие для клиента: Основная таблица остаётся: -- `signed_messages_v2` +- `signed_messages` В ней должны сохраняться: - обычные контентные DM; +- read-receipt DM; - tombstone одного сообщения; - tombstone удаления переписки. +Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие. + Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы: - защищать от повторного приёма старых версий; @@ -558,6 +636,12 @@ UI-следствие для клиента: Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. +Для догоняющей межсерверной синхронизации дополнительно используются: + +- индекс по `target_login`, `created_at_ms`, `message_key`; +- отдельные индексы по delete-событиям для `from_login` и `to_login`; +- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`. + ## 13. Что обязательно должно измениться в коде относительно v0.5 - сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; @@ -578,4 +662,4 @@ UI-следствие для клиента: - хранение отдельного `keyId` шифрования в DM; - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; -- физическая полная реализация DM federation в текущем коде. +- межсерверная авторизация `DmSyncBatch`. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 538cb19f..98b75add 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -327,3 +327,5 @@ ReadReceiptBody_v1_0 ## 13. Примечание о поддержке В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. + +Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`. diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md new file mode 100644 index 00000000..132922e6 --- /dev/null +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -0,0 +1,559 @@ +# Модуль синхронизации `shine_users` из Solana + +## Назначение + +Этот модуль нужен для локальной серверной синхронизации всех пользовательских PDA программы `shine_users`. + +Цель модуля: + +- при старте получить актуальное состояние всех пользовательских PDA; +- дальше держать локальную копию в актуальном состоянии; +- хранить историю просмотренных транзакций синхронизации; +- хранить историю всех версий пользовательских PDA; +- уметь после рестарта продолжать синхронизацию без потери изменений; +- в будущем без большого переписывания встраиваться в основной SHiNE-сервер и блокировать дальнейший startup до входа в состояние `READY`. + +На текущем этапе модуль должен запускаться как отдельный Java-процесс со своим `main`, но внутренняя структура должна быть такой, чтобы потом его можно было перенести в сервер как обычный lifecycle-сервис. + +На дату `2026-07-24` в основном сервере SHiNE уже есть серверная интеграция startup-уровня: + +- сервер запускает sync-модуль до продолжения собственного startup; +- server startup ждёт входа модуля в состояние `READY`; +- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`. +- для быстрого локального маршрутизационного lookup серверов доступа поддерживается вторичная проекция + `user_access_servers_current`, которая автоматически пересобирается из `solana_user_pda_current`. + +--- + +## Базовая идея + +Модуль использует два источника данных: + +1. realtime-поток через Solana WebSocket `programSubscribe` для программы `shine_users`; +2. историю транзакций через `getSignaturesForAddress(economy_config_pda)`. + +Ключевая договорённость: + +- каждая транзакция, которая создаёт или обновляет пользовательский `user_pda`, обязательно читает `users_economy_config_pda`; +- значит, история по `users_economy_config_pda` является полным журналом всех релевантных `create/update user_pda` транзакций; +- дополнительные транзакции, которые тоже читают или меняют `users_economy_config_pda`, допустимы и не мешают: модуль должен уметь распознавать, что такая транзакция не относится к изменению пользовательского PDA. + +`users_economy_config_pda` вычисляется из: + +- `program_id = SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` +- seed = `shine_users_economy_config` + +Источник в коде контракта: + +- `settings::USERS_ECONOMY_CONFIG_SEED = b"shine_users_economy_config"` +- `create_user_pda` и `update_user_pda` принимают `users_economy_config_pda` как обязательный аккаунт. + +--- + +## Что считается состоянием готовности + +Модуль должен иметь внутреннее состояние готовности `READY`. + +В `READY` он входит только после того, как: + +1. установлено websocket-подключение; +2. выполнена начальная актуализация истории; +3. локальная таблица актуальных PDA приведена в консистентное состояние; +4. таблица состояния синхронизации обновлена. + +Пока модуль не вошёл в `READY`, будущий сервер при встраивании не должен продолжать собственный startup. + +Для этого модуль должен поддерживать: + +- `start()` +- `awaitReady()` +- `isReady()` +- `close()` + +Для отдельного процесса `main` допустимо также печатать явный лог о входе в `READY`, но главным механизмом для будущего сервера должен быть Java API, а не парсинг логов. + +--- + +## Почему нельзя опираться только на WebSocket + +WebSocket `programSubscribe` хорош для realtime, но недостаточен как единственный источник истины: + +- если websocket временно оборвался, часть событий может быть пропущена; +- если соединение формально живо, но какое-то событие было потеряно, это не всегда можно заметить сразу; +- если в интервале не было транзакций, websocket по определению ничего не пришлёт. + +Поэтому нужен второй защитный контур: + +- периодический аудит истории по `users_economy_config_pda`; +- запуск такого аудита раз в 5 минут. + +--- + +## Общий lifecycle модуля + +### 1. Старт процесса + +При старте процесса модуль: + +1. читает конфиг; +2. инициализирует локальную PostgreSQL БД; +3. создаёт RPC-клиент; +4. создаёт WebSocket-клиент; +5. создаёт coordinator/service слой; +6. запускает realtime-подписку; +7. после подтверждённой подписки выполняет начальную актуализацию истории; +8. после успешной актуализации выставляет `READY`. + +### 2. Работа в фоне + +После входа в `READY` модуль одновременно: + +- принимает realtime-обновления по websocket; +- раз в 5 минут запускает проверку истории через RPC; +- пишет журнал транзакций; +- пишет историю версий пользовательских PDA; +- обновляет актуальный снимок пользовательских PDA. + +### 3. Реконнект + +Если websocket оборвался: + +1. запускается reconnect; +2. после успешного переподключения выполняется повторная актуализация истории; +3. модуль снова возвращается в нормальный режим. + +### 4. Остановка + +При остановке: + +- закрывается websocket; +- останавливаются фоновые scheduler/worker потоки; +- закрывается RPC-клиент; +- закрывается БД. + +--- + +## Источник истории: только по сигнатурам + +Основной checkpoint модуля должен строиться по сигнатурам транзакций, а не по времени. + +Хранить нужно: + +- последнюю просмотренную сигнатуру; +- последний просмотренный слот; +- последнюю релевантную сигнатуру; +- время последней успешной актуализации. + +Время хранится только как вспомогательная диагностика. Продолжение истории должно идти по сигнатурам и слотам. + +--- + +## Поведение начальной актуализации + +### Сценарий A: локальная БД пустая + +Если локальная БД ещё не содержит синхронизированных данных: + +1. выполняется первичная загрузка пользовательских PDA; +2. после этого фиксируется начальная точка истории; +3. модуль переходит в `READY`. + +Предпочтительный вариант: + +- использовать историю транзакций по `users_economy_config_pda` как основной механизм синхронизации; +- full snapshot всех program accounts остаётся аварийным fallback, а не штатным путём. + +### Сценарий B: локальная БД уже есть + +Если БД не пуста: + +1. берётся последняя обработанная сигнатура; +2. через `getSignaturesForAddress(users_economy_config_pda)` вытягивается история после неё; +3. новые транзакции разбираются и применяются; +4. после этого модуль входит в `READY`. + +### Сценарий C: новых транзакций не было + +Если после последней сигнатуры новых транзакций нет: + +- это не ошибка; +- модуль всё равно обновляет `last_poll_at` и `last_successful_poll_at`; +- фиксирует, что актуализация успешно проверена; +- может переходить в `READY`. + +--- + +## Периодическая актуализация раз в 5 минут + +Каждые 5 минут модуль должен: + +1. вызвать `getSignaturesForAddress(users_economy_config_pda)`; +2. получить новые сигнатуры после последней сохранённой точки; +3. сохранить все найденные транзакции в журнал истории; +4. выделить релевантные транзакции `create/update user_pda`; +5. для релевантных транзакций извлечь адрес PDA и логин; +6. подтянуть актуальное состояние затронутых PDA; +7. обновить: + - `solana_user_pda_current` + - `user_access_servers_current` + - `solana_user_pda_history` + - `solana_sync_state` + +Если новых транзакций нет: + +- модуль ничего не меняет в зеркале PDA; +- но помечает, что polling выполнен успешно и состояние истории актуализировано. + +--- + +## Что делать с нерелевантными транзакциями + +История должна храниться полностью, включая нерелевантные транзакции. + +Примеры нерелевантных транзакций: + +- `update_users_economy_config` +- служебные транзакции, где `economy_config_pda` присутствовал, но пользовательский `user_pda` не менялся + +Такие транзакции: + +- сохраняются в `solana_sync_tx_history`; +- помечаются `is_relevant = 0`; +- не приводят к обновлению пользовательских PDA. + +Это важно для аудита и отладки. + +--- + +## Как распознавать тип транзакции + +Для каждой транзакции из истории нужно определить её тип. + +Минимальный набор типов: + +- `create_user_pda` +- `update_user_pda` +- `update_users_economy_config` +- `init_users_economy_config` +- `other` + +Также для каждой транзакции нужно определять: + +- `is_relevant = 1`, если транзакция создаёт или обновляет пользовательский `user_pda`; +- `is_relevant = 0`, если это транзакция истории, но она не меняет пользовательские PDA. + +Для релевантных транзакций нужно дополнительно извлекать: + +- `affected_pda_address` +- `affected_login` + +Если транзакция затрагивает несколько пользовательских PDA, архитектура должна не запрещать хранить несколько связей, но на первом этапе можно исходить из одной пользовательской записи на одну транзакцию, если это соответствует текущему контракту. + +--- + +## Локальные таблицы + +Модуль должен использовать три основные таблицы. + +### 1. `solana_sync_state` + +Одна строка состояния синхронизации. + +Назначение: + +- хранить текущий checkpoint истории; +- хранить время последней успешной актуализации; +- хранить технический статус синка. + +Пример полей: + +- `id INTEGER PRIMARY KEY CHECK (id = 1)` +- `status TEXT NOT NULL` +- `ready INTEGER NOT NULL DEFAULT 0` +- `last_poll_at_ms INTEGER` +- `last_successful_poll_at_ms INTEGER` +- `last_seen_signature TEXT` +- `last_seen_slot INTEGER` +- `last_relevant_signature TEXT` +- `last_relevant_slot INTEGER` +- `last_error TEXT` +- `updated_at_ms INTEGER NOT NULL` + +### 2. `solana_sync_tx_history` + +Append-only журнал всех просмотренных транзакций по `users_economy_config_pda`. + +Назначение: + +- хранить полную историю опроса; +- фиксировать, какие tx были релевантны; +- хранить связь tx -> пользовательский PDA / login; +- упрощать аудит и диагностику. + +Пример полей: + +- `signature TEXT PRIMARY KEY` +- `slot INTEGER NOT NULL` +- `block_time INTEGER` +- `tx_kind TEXT NOT NULL` +- `is_relevant INTEGER NOT NULL` +- `affected_pda_address TEXT` +- `affected_login TEXT` +- `processed_at_ms INTEGER NOT NULL` +- `raw_summary_json TEXT NOT NULL` + +Индексы: + +- по `slot` +- по `is_relevant` +- по `affected_login` +- по `affected_pda_address` + +### 3. `solana_user_pda_current` + +Текущее актуальное состояние каждого пользовательского PDA. + +Назначение: + +- быстрый lookup текущих данных пользователя; +- будущая интеграция с сервером; +- опорная таблица для поиска текущих ключей и полей PDA. + +Пример полей: + +- `pda_address TEXT PRIMARY KEY` +- `login TEXT NOT NULL` +- `record_number INTEGER NOT NULL` +- `slot INTEGER NOT NULL` +- `last_tx_signature TEXT NOT NULL` +- `blockchain_name TEXT NOT NULL` +- `blockchain_key TEXT NOT NULL` +- `client_key TEXT NOT NULL` +- `paid_limit_bytes INTEGER NOT NULL` +- `used_bytes INTEGER NOT NULL` +- `last_block_number INTEGER NOT NULL` +- `last_block_hash TEXT` +- `arweave_tx_id TEXT` +- `is_server INTEGER NOT NULL DEFAULT 0` +- `server_address TEXT` +- `sync_servers_json TEXT NOT NULL DEFAULT '[]'` +- `access_servers_json TEXT NOT NULL DEFAULT '[]'` +- `sessions_json TEXT NOT NULL DEFAULT '[]'` +- `trusted_count INTEGER NOT NULL DEFAULT 0` +- `created_at_ms INTEGER NOT NULL` +- `updated_at_ms INTEGER NOT NULL` +- `raw_data_base64 TEXT NOT NULL` +- `first_seen_at_ms INTEGER NOT NULL` +- `last_synced_at_ms INTEGER NOT NULL` + +Индексы: + +- уникальный индекс на `login` +- индекс на `slot` +- индекс на `last_tx_signature` + +### 4. `solana_user_pda_history` + +Append-only история всех версий пользовательских PDA. + +Назначение: + +- хранить все старые публичные ключи и прочие поля прошлых версий; +- позволять видеть, когда и какая версия записи была актуальна; +- позволять разбирать изменения пользователя во времени. + +Пример полей: + +- `id INTEGER PRIMARY KEY` +- `tx_signature TEXT NOT NULL` +- `slot INTEGER NOT NULL` +- `block_time INTEGER` +- `pda_address TEXT NOT NULL` +- `login TEXT NOT NULL` +- `record_number INTEGER NOT NULL` +- `blockchain_name TEXT NOT NULL` +- `blockchain_key TEXT NOT NULL` +- `client_key TEXT NOT NULL` +- `paid_limit_bytes INTEGER NOT NULL` +- `used_bytes INTEGER NOT NULL` +- `last_block_number INTEGER NOT NULL` +- `last_block_hash TEXT` +- `arweave_tx_id TEXT` +- `is_server INTEGER NOT NULL DEFAULT 0` +- `server_address TEXT` +- `sync_servers_json TEXT NOT NULL DEFAULT '[]'` +- `access_servers_json TEXT NOT NULL DEFAULT '[]'` +- `sessions_json TEXT NOT NULL DEFAULT '[]'` +- `trusted_count INTEGER NOT NULL DEFAULT 0` +- `created_at_ms INTEGER NOT NULL` +- `updated_at_ms INTEGER NOT NULL` +- `raw_data_base64 TEXT NOT NULL` +- `saved_at_ms INTEGER NOT NULL` + +Индексы: + +- уникальный индекс на `(pda_address, record_number)` +- индекс на `login` +- индекс на `slot` +- индекс на `tx_signature` + +--- + +## Зачем нужны и `current`, и `history` + +Нужны обе таблицы: + +- `solana_user_pda_current` хранит только последнюю актуальную версию и удобна для быстрых запросов; +- `solana_user_pda_history` хранит все версии и нужна для расследований и просмотра старых публичных ключей. + +При обработке новой релевантной транзакции: + +1. новая версия всегда добавляется в `solana_user_pda_history`; +2. актуальная строка в `solana_user_pda_current` вставляется или обновляется. + +--- + +## Что именно считается “историей пользователя” + +История нужна не для секретных паролей, а для публичных данных PDA. + +В частности, история должна позволять видеть старые значения: + +- `client_key` +- `blockchain_key` +- `sessions` +- `access_servers` +- `sync_servers` +- `server_address` +- других публичных полей PDA + +Секретные приватные ключи или настоящие пользовательские пароли этот модуль не хранит и хранить не должен. + +--- + +## Логирование + +Модуль должен использовать нормальный logger, а не `System.out.println`. + +Требования к логированию: + +- совместимость с будущей интеграцией в общие логи сервера; +- явные уровни `INFO`, `WARN`, `ERROR`, `DEBUG`; +- короткие, но диагностичные сообщения; +- каждый важный переход состояния должен логироваться. + +Что обязательно логировать: + +- старт процесса; +- чтение конфига; +- вычисление `users_economy_config_pda`; +- старт websocket-подписки; +- успешную подписку; +- начало initial sync; +- окончание initial sync; +- вход в `READY`; +- periodic poll; +- число найденных сигнатур; +- число релевантных tx; +- число обновлённых PDA; +- реконнекты websocket; +- ошибки RPC/WS; +- переход в `FAILED`, если он будет. + +--- + +## Конфигурация + +В конфиге должны остаться только обязательные для постоянной эксплуатации параметры: + +```text +SOLANA_RPC_URL= +SOLANA_WS_URL= +SOLANA_PROGRAM_ID=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6 + +DATABASE_URL=jdbc:postgresql://127.0.0.1:5432/shine_server_db +PGUSER=shine_server +PGPASSWORD= +SYNC_POLL_INTERVAL_SECONDS=300 +``` + +Дополнительно: + +- `SOLANA_COMMITMENT` не нужен как параметр; +- commitment должен быть зафиксирован в коде как `confirmed`; +- `HELIUS_API_KEY` не нужен; +- `SOLANA_NETWORK` не нужен. + +--- + +## Поведение при ошибках + +### Ошибка periodic poll + +Если periodic poll не удался: + +- это логируется как `WARN` или `ERROR`; +- `solana_sync_state.last_error` обновляется; +- модуль не должен сразу завершаться, если reconnect/retry ещё возможны. + +### Ошибка initial sync + +Если initial sync не удался: + +- модуль не должен выставлять `READY`; +- отдельный процесс должен завершаться с ошибкой или оставаться в `FAILED`, в зависимости от выбранного runtime-режима; +- при встраивании в сервер основной startup должен считаться неуспешным. + +### Аварийный fallback + +Даже если основная логика опирается на историю по `users_economy_config_pda`, аварийный full snapshot всех `user_pda` можно оставить как последний защитный fallback на случай повреждённого или неполного RPC-ответа. + +Но это должен быть именно крайний защитный сценарий, а не штатный рабочий путь. + +--- + +## Почему хранить всю tx-историю полезно + +Полный журнал `solana_sync_tx_history` нужен не только для самого синка, но и для эксплуатации: + +- видно, что именно вернул RPC; +- видно, какие tx были признаны релевантными; +- видно, какие tx были проигнорированы и почему; +- можно поднимать старые кейсы без повторного запроса в Solana; +- упрощается аудит и отладка после инцидентов. + +--- + +## Что должно получиться в итоге + +В результате модуль должен работать так: + +1. стартует как отдельный Java-процесс; +2. подписывается на realtime-обновления `shine_users`; +3. делает начальную актуализацию через историю `users_economy_config_pda`; +4. входит в `READY`; +5. раз в 5 минут делает дополнительный audit истории; +6. хранит: + - состояние синка; + - полную историю просмотренных транзакций; + - актуальное зеркало всех пользовательских PDA; + - историю всех версий пользовательских PDA; +7. после будущего переноса в SHiNE-сервер может использоваться как блокирующий startup-модуль: + - сначала синхронизируется Solana; + - потом сервер продолжает запуск остальных подсистем. + +--- + +## Следующий шаг реализации + +После утверждения этого документа модуль нужно доработать в коде: + +1. упростить конфиг; +2. заменить текущее логирование на logger; +3. выделить lifecycle-сервис с `awaitReady()`; +4. реализовать вычисление `users_economy_config_pda`; +5. реализовать polling истории по сигнатурам; +6. добавить runtime-таблицы PostgreSQL; +7. добавить запись в `current` и `history`; +8. добавить periodic guard раз в 5 минут; +9. сохранить отдельный `main` для запуска как процесса. diff --git a/docs/libs/shine-server-bd/DOC.md b/docs/libs/shine-server-bd/DOC.md index aae2dfaa..ad2c2483 100644 --- a/docs/libs/shine-server-bd/DOC.md +++ b/docs/libs/shine-server-bd/DOC.md @@ -1,17 +1,12 @@ shine-server-bd — это библиотека реалезующая всю работу с БД: -хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый SqliteDbController для соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java. +хранит пользователей/сессии/параметры/кэш IP→гео и данные блокчейна (состояние + блоки), предоставляя единый PostgreSQL runtime-контроллер соединений, набор DAO под каждую таблицу (Singleton, методы с Connection для транзакций и без Connection — сами открывают/закрывают), и простые entity-модели как контейнеры данных для маппинга ResultSet↔Java. Логика структуры классов (в двух словах): -shine.db.SqliteDbController — один вход в БД: читает db.path, при отсутствии файла создаёт БД, выдаёт новые Connection и настраивает PRAGMA. -shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы). +shine.db.DbController / shine.db.PostgresDbController — вход в runtime БД: читает `db.url/db.user/db.password`, подключается только к PostgreSQL и выдаёт новые `Connection`. +shine.db.DatabaseInitializer — проверяет наличие `db_schema_version` и при пустой БД автоматически накатывает `postgres/schema_v1.sql`. shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()). -shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO: - -UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit). -// Временное решение позволяющее регистрировать новых пользователей -// атомарно и добавляет запись и в solana_users и в BlockchainState - +shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, CurrentUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO, SignedMessagesDAO; плюс сервисные DAO под recovery/resync. diff --git a/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md b/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md new file mode 100644 index 00000000..1f7e787a --- /dev/null +++ b/docs/libs/shine-server-bd/POSTGRES_RUNTIME_SCHEMA_V1.md @@ -0,0 +1,91 @@ +# PostgreSQL runtime schema v1 + +Дата фиксации: `2026-07-24` + +## Назначение + +Это целевая серверная runtime-схема PostgreSQL для SHiNE без опоры на SQLite. + +Схема `v1` нужна как стартовая точка большого механического переноса DAO и runtime-запросов +с существующей SQLite-логики на PostgreSQL. + +## Ключевые решения + +- Источник истины по пользователям: `solana_user_pda_current`. +- Legacy-таблицы старого runtime для пользователей и личных сообщений в новой схеме не создаются. +- Основная таблица серверных личных сообщений: `signed_messages`. +- Таблица `blockchain_state` сохраняется как runtime-state таблица сервера: + она не является identity-слоем и не мигрируется как legacy SQLite data. +- Триггеры по `blocks` сохраняются и переписываются под PostgreSQL. + +## Таблицы sync-модуля Solana users + +- `solana_sync_state` +- `solana_sync_tx_history` +- `solana_user_pda_current` +- `solana_user_pda_history` + +## Таблицы server runtime + +- `db_schema_version` +- `active_sessions` +- `esp_pairing_settings` +- `esp_pairing_requests` +- `users_params` +- `ip_geo_cache` +- `test_free_avatar_uploads` +- `sync_servers` +- `blockchain_state` +- `blocks` +- `connections_state` +- `message_stats` +- `reactions_state` +- `channel_names_state` +- `chat200_state` +- `chat200_members_state` +- `user_push_tokens` +- `signed_direct_message_replay` +- `signed_direct_messages_history` +- `signed_messages` +- `signed_message_session_delivery` + +## Триггеры + +Схема `v1` уже включает PostgreSQL-версии триггеров: + +- `trg_blocks_line_integrity_bi` +- `trg_blocks_connection_state_ai` +- `trg_blocks_message_stats_like_ai` +- `trg_blocks_message_stats_reply_ai` +- `trg_blocks_edit_apply_ai` + +## Что не входит в v1 + +- полная зачистка legacy-документации, старых названий и TODO-хвостов; +- переименование Java DAO/классов `*V2` в runtime-коде; +- перенос прямых SQL-запросов из хэндлеров в DAO/service; +- переключение всего runtime-кода на новый `DbProvider`. + +Это отдельные механические шаги поверх уже утверждённой схемы. + +## Совместимость со старыми блоками каналов + +В runtime-сервере сознательно нет жёсткой серверной проверки +`channelName must not contain only digits`. + +Причина: в уже существующей истории блокчейна есть каналы с числовыми именами, +и при холодном восстановлении сервера с пустой БД и без `.bch` такие блоки должны +успешно переигрываться от других sync-серверов. + +Сейчас правило "новый публичный канал не должен состоять только из цифр" остаётся +на уровне UI/продуктовых требований и должно быть позже возвращено на сервере +отдельным совместимым способом, который не ломает replay исторических блоков. + +## Инициализация пустой БД + +Если сервер подключается к PostgreSQL через `db.url=jdbc:postgresql:...` и в выбранной БД ещё нет таблицы `db_schema_version`, +он сам автоматически накатывает `schema_v1.sql` из classpath-ресурса: + +- ресурс: `shine-server-db/src/main/resources/postgres/schema_v1.sql` +- признак пустой схемы: отсутствует `db_schema_version` +- стартовая версия схемы: `1` diff --git a/docs/libs/shine-server-config/doc.md b/docs/libs/shine-server-config/doc.md index e2f49003..929add26 100644 --- a/docs/libs/shine-server-config/doc.md +++ b/docs/libs/shine-server-config/doc.md @@ -4,4 +4,6 @@ shine-server-config Настройки: server.port=7070 — порт запуска сервера -db.path=data/shine.sqlite — путь к SQLite базе данных \ No newline at end of file +db.url=jdbc:postgresql://127.0.0.1:5432/shine_server_db — JDBC URL PostgreSQL +db.user=shine_server — пользователь PostgreSQL +db.password=... — пароль пользователя PostgreSQL diff --git a/docs/libs/shine-server-protocol/doc.md b/docs/libs/shine-server-protocol/doc.md index 9156ecbb..3bb3562d 100644 --- a/docs/libs/shine-server-protocol/doc.md +++ b/docs/libs/shine-server-protocol/doc.md @@ -42,11 +42,11 @@ auth/ — авторизация и сессии blockchain/ — AddBlock -tempToTest/ — AddUser (временный, потом уйдёт в блокчейн-логику) +tempToTest/ — временные dev/test операции (`GetUser`, `SearchUsers`) ConnectionContext Состояние одного WebSocket-подключения (login, session, authStatus). ActiveConnectionsRegistry Глобальный реестр активных авторизованных соединений -(нужно для закрытия других сессий). \ No newline at end of file +(нужно для закрытия других сессий). diff --git a/settings.gradle b/settings.gradle index 32854cbf..008cebfb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,6 +8,7 @@ include 'shine-server-blockchain' include 'shine-server-db' include 'shine-server-net-protocol' include 'shine-server-net-server' +include 'shine-server-solana-users-sync' project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log') project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config') @@ -17,3 +18,4 @@ project(':shine-server-blockchain').projectDir = file('SHiNE-server/shine-server project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db') project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol') project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server') +project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync') 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/THIRD_PARTY_NOTICES.md b/shine-UI/THIRD_PARTY_NOTICES.md deleted file mode 100644 index 9024ac9e..00000000 --- a/shine-UI/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,9 +0,0 @@ -# Third-party notices - -## Telegram Animated Emojis - -Animated emoji previews in SHiNE use the public catalog from Tarikul-Islam-Anik. - -Source: https://github.com/Tarikul-Islam-Anik/Telegram-Animated-Emojis - -The repository README states that the media files are sourced from Emojipedia and that rights to the Telegram media belong to Telegram.org. Review those rights before public distribution. diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/1st Place Medal.png b/shine-UI/assets/emoji/telegram-previews/Activity/1st Place Medal.png deleted file mode 100644 index 4011e3c7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/1st Place Medal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/2nd Place Medal.png b/shine-UI/assets/emoji/telegram-previews/Activity/2nd Place Medal.png deleted file mode 100644 index cfc1e745..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/2nd Place Medal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/3rd Place Medal.png b/shine-UI/assets/emoji/telegram-previews/Activity/3rd Place Medal.png deleted file mode 100644 index 67250745..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/3rd Place Medal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Admission Tickets.png b/shine-UI/assets/emoji/telegram-previews/Activity/Admission Tickets.png deleted file mode 100644 index fbb0742b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Admission Tickets.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Artist Palette.png b/shine-UI/assets/emoji/telegram-previews/Activity/Artist Palette.png deleted file mode 100644 index 9359d65a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Artist Palette.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Balloon.png b/shine-UI/assets/emoji/telegram-previews/Activity/Balloon.png deleted file mode 100644 index 206456ea..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Balloon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Basketball.png b/shine-UI/assets/emoji/telegram-previews/Activity/Basketball.png deleted file mode 100644 index 3c7ede13..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Basketball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Christmas Tree.png b/shine-UI/assets/emoji/telegram-previews/Activity/Christmas Tree.png deleted file mode 100644 index 145d6767..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Christmas Tree.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Confetti Ball.png b/shine-UI/assets/emoji/telegram-previews/Activity/Confetti Ball.png deleted file mode 100644 index 74bdc9fc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Confetti Ball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Crystal Ball.png b/shine-UI/assets/emoji/telegram-previews/Activity/Crystal Ball.png deleted file mode 100644 index 2f37c178..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Crystal Ball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Firecracker.png b/shine-UI/assets/emoji/telegram-previews/Activity/Firecracker.png deleted file mode 100644 index 7d4f3567..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Firecracker.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Fireworks.png b/shine-UI/assets/emoji/telegram-previews/Activity/Fireworks.png deleted file mode 100644 index b4997c5f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Fireworks.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Jack O Lantern.png b/shine-UI/assets/emoji/telegram-previews/Activity/Jack O Lantern.png deleted file mode 100644 index 50942f96..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Jack O Lantern.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Magic Wand.png b/shine-UI/assets/emoji/telegram-previews/Activity/Magic Wand.png deleted file mode 100644 index e649a3cb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Magic Wand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Military Medal.png b/shine-UI/assets/emoji/telegram-previews/Activity/Military Medal.png deleted file mode 100644 index ea076825..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Military Medal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Mirror Ball.png b/shine-UI/assets/emoji/telegram-previews/Activity/Mirror Ball.png deleted file mode 100644 index e5f4a167..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Mirror Ball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Party Popper.png b/shine-UI/assets/emoji/telegram-previews/Activity/Party Popper.png deleted file mode 100644 index f89078d6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Party Popper.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Performing Arts.png b/shine-UI/assets/emoji/telegram-previews/Activity/Performing Arts.png deleted file mode 100644 index edbf6d7e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Performing Arts.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Soccer Ball.png b/shine-UI/assets/emoji/telegram-previews/Activity/Soccer Ball.png deleted file mode 100644 index 2e2c169b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Soccer Ball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Sparkler.png b/shine-UI/assets/emoji/telegram-previews/Activity/Sparkler.png deleted file mode 100644 index 7e875aea..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Sparkler.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Sparkles.png b/shine-UI/assets/emoji/telegram-previews/Activity/Sparkles.png deleted file mode 100644 index 8d36557d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Sparkles.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Sports Medal.png b/shine-UI/assets/emoji/telegram-previews/Activity/Sports Medal.png deleted file mode 100644 index 501604a7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Sports Medal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Ticket.png b/shine-UI/assets/emoji/telegram-previews/Activity/Ticket.png deleted file mode 100644 index b58c63e1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Ticket.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Trophy.png b/shine-UI/assets/emoji/telegram-previews/Activity/Trophy.png deleted file mode 100644 index 07b80582..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Trophy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Activity/Video Game.png b/shine-UI/assets/emoji/telegram-previews/Activity/Video Game.png deleted file mode 100644 index 608fcdb3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Activity/Video Game.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ant.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ant.png deleted file mode 100644 index c72cf2f6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ant.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Baby Chick.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Baby Chick.png deleted file mode 100644 index e5a805e3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Baby Chick.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bat.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bat.png deleted file mode 100644 index ba7dc58c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bear.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bear.png deleted file mode 100644 index 2398256c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bear.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Beetle.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Beetle.png deleted file mode 100644 index 3606585f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Beetle.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bird.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bird.png deleted file mode 100644 index a715dcf7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bird.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bison.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bison.png deleted file mode 100644 index a30600ed..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bison.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Black Cat.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Black Cat.png deleted file mode 100644 index 560290b7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Black Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Blossom.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Blossom.png deleted file mode 100644 index 16edbef1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Blossom.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bouquet.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bouquet.png deleted file mode 100644 index ccceacfd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bouquet.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bug.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bug.png deleted file mode 100644 index c82a8834..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Bug.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Butterfly.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Butterfly.png deleted file mode 100644 index 51d433a5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Butterfly.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cactus.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cactus.png deleted file mode 100644 index 0a10a8ce..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cactus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat Face.png deleted file mode 100644 index 4e51f8d7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat.png deleted file mode 100644 index d75137d8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cherry Blossom.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cherry Blossom.png deleted file mode 100644 index fdebd4bc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cherry Blossom.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Chicken.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Chicken.png deleted file mode 100644 index 62c97a57..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Chicken.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning And Rain.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning And Rain.png deleted file mode 100644 index efc8a197..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning And Rain.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning.png deleted file mode 100644 index 2d4b195e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Lightning.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Rain.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Rain.png deleted file mode 100644 index 1b9ffc12..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Rain.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Snow.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Snow.png deleted file mode 100644 index af01fa31..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud With Snow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud.png deleted file mode 100644 index e14c6a76..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cloud.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cockroach.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cockroach.png deleted file mode 100644 index da1e98f9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cockroach.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cow.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cow.png deleted file mode 100644 index b2b4e61f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cricket.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cricket.png deleted file mode 100644 index 1907dd52..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Cricket.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deciduous Tree.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deciduous Tree.png deleted file mode 100644 index 888fbf99..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deciduous Tree.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deer.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deer.png deleted file mode 100644 index 1d6d7ede..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Deer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dog Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dog Face.png deleted file mode 100644 index 18752f09..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dog Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dove.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dove.png deleted file mode 100644 index c137c3a8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Dove.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Duck.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Duck.png deleted file mode 100644 index df1f7af5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Duck.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Evergreen Tree.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Evergreen Tree.png deleted file mode 100644 index 3d4ae5cd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Evergreen Tree.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fire.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fire.png deleted file mode 100644 index a6f06a4f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon Face.png deleted file mode 100644 index 6928e535..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon.png deleted file mode 100644 index 12cf5354..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/First Quarter Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fish.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fish.png deleted file mode 100644 index 7f232dcc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fish.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Flamingo.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Flamingo.png deleted file mode 100644 index 0791fc7e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Flamingo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Four Leaf Clover.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Four Leaf Clover.png deleted file mode 100644 index 24d32e9a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Four Leaf Clover.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fox.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fox.png deleted file mode 100644 index accfc6e0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Fox.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Frog.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Frog.png deleted file mode 100644 index af2b5f5c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Frog.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Front Facing Baby Chick.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Front Facing Baby Chick.png deleted file mode 100644 index 378707da..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Front Facing Baby Chick.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon Face.png deleted file mode 100644 index a3341b14..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon.png deleted file mode 100644 index f9eecedb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Full Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Giraffe.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Giraffe.png deleted file mode 100644 index 681d1950..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Giraffe.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Glowing Star.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Glowing Star.png deleted file mode 100644 index 8d0d3d7f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Glowing Star.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Gorilla.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Gorilla.png deleted file mode 100644 index 73a01cfb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Gorilla.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hamster.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hamster.png deleted file mode 100644 index 9db3dec5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hamster.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hatching Chick.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hatching Chick.png deleted file mode 100644 index 776f935f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hatching Chick.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hedgehog.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hedgehog.png deleted file mode 100644 index fdf2a809..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hedgehog.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Herb.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Herb.png deleted file mode 100644 index d9ea96ca..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Herb.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hibiscus.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hibiscus.png deleted file mode 100644 index 180f7c0b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Hibiscus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/High Voltage.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/High Voltage.png deleted file mode 100644 index 2943595b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/High Voltage.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Honeybee.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Honeybee.png deleted file mode 100644 index cbf034b2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Honeybee.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse Face.png deleted file mode 100644 index 85692526..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse.png deleted file mode 100644 index 662f4553..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Horse.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Kangaroo.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Kangaroo.png deleted file mode 100644 index 27dc5ff8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Kangaroo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Koala.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Koala.png deleted file mode 100644 index 1ff39ec9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Koala.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lady Beetle.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lady Beetle.png deleted file mode 100644 index c0f521b7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lady Beetle.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon Face.png deleted file mode 100644 index 75b7cf27..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon.png deleted file mode 100644 index 8c17892c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Last Quarter Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Leopard.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Leopard.png deleted file mode 100644 index 4efbdb8f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Leopard.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lion.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lion.png deleted file mode 100644 index 0968328b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lion.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Llama.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Llama.png deleted file mode 100644 index 0087e7be..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Llama.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lobster.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lobster.png deleted file mode 100644 index 8cf94f95..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Lobster.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Microbe.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Microbe.png deleted file mode 100644 index bfdd80c1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Microbe.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Monkey Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Monkey Face.png deleted file mode 100644 index a273ff55..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Monkey Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mosquito.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mosquito.png deleted file mode 100644 index d581ff63..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mosquito.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mouse Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mouse Face.png deleted file mode 100644 index c0baf69f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Mouse Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon Face.png deleted file mode 100644 index 897d32dd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon.png deleted file mode 100644 index b8eb14f4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/New Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Octopus.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Octopus.png deleted file mode 100644 index 90509ef9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Octopus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Owl.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Owl.png deleted file mode 100644 index 1dea5488..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Owl.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ox.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ox.png deleted file mode 100644 index 023a1f31..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Ox.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Palm Tree.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Palm Tree.png deleted file mode 100644 index eb111d33..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Palm Tree.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Panda.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Panda.png deleted file mode 100644 index f6ef1cf7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Panda.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Parrot.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Parrot.png deleted file mode 100644 index c8a41555..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Parrot.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Paw Prints.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Paw Prints.png deleted file mode 100644 index c7bc9732..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Paw Prints.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Penguin.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Penguin.png deleted file mode 100644 index 2b128b0f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Penguin.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Face.png deleted file mode 100644 index ab8b5f14..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Nose.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Nose.png deleted file mode 100644 index 964e00da..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Pig Nose.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Polar Bear.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Polar Bear.png deleted file mode 100644 index 2c695791..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Polar Bear.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit Face.png deleted file mode 100644 index 9182a481..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit.png deleted file mode 100644 index 5f1ce924..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rabbit.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Raccoon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Raccoon.png deleted file mode 100644 index 5d858f2a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Raccoon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rainbow.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rainbow.png deleted file mode 100644 index 4f8b044f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rainbow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rose.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rose.png deleted file mode 100644 index c23e6ac1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rose.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rosette.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rosette.png deleted file mode 100644 index a22a5df3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Rosette.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sauropod.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sauropod.png deleted file mode 100644 index acac0406..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sauropod.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seal.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seal.png deleted file mode 100644 index ffbb452c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seedling.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seedling.png deleted file mode 100644 index 1b308613..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Seedling.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shamrock.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shamrock.png deleted file mode 100644 index 0b90a3aa..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shamrock.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shrimp.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shrimp.png deleted file mode 100644 index 28d7f70b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Shrimp.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snail.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snail.png deleted file mode 100644 index b942bec7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snail.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snake.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snake.png deleted file mode 100644 index 81d0ee9a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowflake.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowflake.png deleted file mode 100644 index 94bccf8e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowflake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman Without Snow.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman Without Snow.png deleted file mode 100644 index 33af2535..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman Without Snow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman.png deleted file mode 100644 index 265be577..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Snowman.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider Web.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider Web.png deleted file mode 100644 index 68c313e2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider Web.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider.png deleted file mode 100644 index 23273c17..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spider.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spouting Whale.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spouting Whale.png deleted file mode 100644 index 1d620545..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Spouting Whale.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Squid.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Squid.png deleted file mode 100644 index a9ec801e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Squid.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Star.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Star.png deleted file mode 100644 index f1d5e6a3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Star.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Cloud.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Cloud.png deleted file mode 100644 index 00d4ff20..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Cloud.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Large Cloud.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Large Cloud.png deleted file mode 100644 index 97d2d715..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Large Cloud.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Rain Cloud.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Rain Cloud.png deleted file mode 100644 index b356c033..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Rain Cloud.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Small Cloud.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Small Cloud.png deleted file mode 100644 index d55e61d6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun Behind Small Cloud.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun With Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun With Face.png deleted file mode 100644 index 3d114813..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun With Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun.png deleted file mode 100644 index 3ddb0964..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Sun.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Swan.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Swan.png deleted file mode 100644 index 40c5e3ec..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Swan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/T Rex.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/T Rex.png deleted file mode 100644 index e41e5ae2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/T Rex.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger Face.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger Face.png deleted file mode 100644 index 9cd13a4c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger.png deleted file mode 100644 index 7e72e287..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tiger.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tropical Fish.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tropical Fish.png deleted file mode 100644 index 0d143b32..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tropical Fish.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tulip.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tulip.png deleted file mode 100644 index ad2b611d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Tulip.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turkey.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turkey.png deleted file mode 100644 index 7ece3a6f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turkey.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turtle.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turtle.png deleted file mode 100644 index 4e9f010a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Turtle.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Unicorn.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Unicorn.png deleted file mode 100644 index 8a9efedf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Unicorn.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waning Crescent Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waning Crescent Moon.png deleted file mode 100644 index 991da818..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waning Crescent Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Crescent Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Crescent Moon.png deleted file mode 100644 index 7c075ed8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Crescent Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Gibbous Moon.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Gibbous Moon.png deleted file mode 100644 index 109f5827..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Waxing Gibbous Moon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Zebra.png b/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Zebra.png deleted file mode 100644 index cada8758..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Animals and Nature/Zebra.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Black Flag.png b/shine-UI/assets/emoji/telegram-previews/Flags/Black Flag.png deleted file mode 100644 index 1377c59d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Black Flag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Chequered Flag.png b/shine-UI/assets/emoji/telegram-previews/Flags/Chequered Flag.png deleted file mode 100644 index 909e3114..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Chequered Flag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Argentina.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Argentina.png deleted file mode 100644 index 37d71e46..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Argentina.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Armenia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Armenia.png deleted file mode 100644 index 626b541c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Armenia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Austria.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Austria.png deleted file mode 100644 index 9728457e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Austria.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bahamas.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bahamas.png deleted file mode 100644 index f55a3424..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bahamas.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belarus.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belarus.png deleted file mode 100644 index 5bdab0de..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belarus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belgium.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belgium.png deleted file mode 100644 index 4619ce15..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Belgium.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Benin.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Benin.png deleted file mode 100644 index e226f45c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Benin.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Brazil.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Brazil.png deleted file mode 100644 index 89d5d570..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Brazil.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bulgaria.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bulgaria.png deleted file mode 100644 index 9d64e383..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Bulgaria.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Burkina Faso.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Burkina Faso.png deleted file mode 100644 index 83aa7a53..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Burkina Faso.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cameroon.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cameroon.png deleted file mode 100644 index bbd1a3a8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cameroon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Chad.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Chad.png deleted file mode 100644 index 0f91311f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Chad.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag China.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag China.png deleted file mode 100644 index c1666f1b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag China.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cote Divoire.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cote Divoire.png deleted file mode 100644 index 635e0d40..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Cote Divoire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Croatia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Croatia.png deleted file mode 100644 index 5795b262..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Croatia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Czechia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Czechia.png deleted file mode 100644 index 55d740cf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Czechia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Djibouti.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Djibouti.png deleted file mode 100644 index 089b4871..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Djibouti.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Equatorial Guinea.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Equatorial Guinea.png deleted file mode 100644 index 1ae24a35..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Equatorial Guinea.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Estonia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Estonia.png deleted file mode 100644 index 5acfa55b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Estonia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag France.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag France.png deleted file mode 100644 index 0a3d9dfc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag France.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Gabon.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Gabon.png deleted file mode 100644 index e47fdaab..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Gabon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Germany.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Germany.png deleted file mode 100644 index 49aba557..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Germany.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ghana.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ghana.png deleted file mode 100644 index 803356ba..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ghana.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea Bissau.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea Bissau.png deleted file mode 100644 index f31c3eac..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea Bissau.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea.png deleted file mode 100644 index 72b2e5b6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Guinea.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Hungary.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Hungary.png deleted file mode 100644 index 1c704740..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Hungary.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag India.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag India.png deleted file mode 100644 index 66e3f85f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag India.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Indonesia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Indonesia.png deleted file mode 100644 index 85c25ad6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Indonesia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ireland.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ireland.png deleted file mode 100644 index 8097030b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ireland.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Italy.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Italy.png deleted file mode 100644 index 39fdd265..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Italy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Japan.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Japan.png deleted file mode 100644 index 310d8b1a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Japan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Jordan.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Jordan.png deleted file mode 100644 index d527c40e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Jordan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Lithuania.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Lithuania.png deleted file mode 100644 index d1e38b8b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Lithuania.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Luxembourg.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Luxembourg.png deleted file mode 100644 index eea26757..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Luxembourg.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Madagascar.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Madagascar.png deleted file mode 100644 index 997f3e0c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Madagascar.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mali.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mali.png deleted file mode 100644 index 613fd0ab..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mali.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Monaco.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Monaco.png deleted file mode 100644 index 6142e1b0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Monaco.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Morocco.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Morocco.png deleted file mode 100644 index 596ea7d1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Morocco.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mozambique.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mozambique.png deleted file mode 100644 index 23786d8a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Mozambique.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Myanmar Burma.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Myanmar Burma.png deleted file mode 100644 index 50dc7bea..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Myanmar Burma.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Netherlands.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Netherlands.png deleted file mode 100644 index 340fc9f9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Netherlands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Nigeria.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Nigeria.png deleted file mode 100644 index 716ef079..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Nigeria.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Palestinian Territories.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Palestinian Territories.png deleted file mode 100644 index f0183d5a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Palestinian Territories.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Peru.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Peru.png deleted file mode 100644 index b1a423a9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Peru.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Philippines.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Philippines.png deleted file mode 100644 index 25233cb9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Philippines.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Poland.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Poland.png deleted file mode 100644 index b7e2dab9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Poland.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Romania.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Romania.png deleted file mode 100644 index d5718a13..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Romania.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Russia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Russia.png deleted file mode 100644 index 104bae40..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Russia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sao Tome Principe.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sao Tome Principe.png deleted file mode 100644 index e126d41c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sao Tome Principe.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Saudi Arabia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Saudi Arabia.png deleted file mode 100644 index 16185928..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Saudi Arabia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Senegal.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Senegal.png deleted file mode 100644 index 79210b17..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Senegal.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sierra Leone.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sierra Leone.png deleted file mode 100644 index a9c88013..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sierra Leone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Singapore.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Singapore.png deleted file mode 100644 index 88cebff9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Singapore.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sint Maarten.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sint Maarten.png deleted file mode 100644 index 68220640..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sint Maarten.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Somalia.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Somalia.png deleted file mode 100644 index 1e90347e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Somalia.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Korea.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Korea.png deleted file mode 100644 index c4fd1808..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Korea.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Sudan.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Sudan.png deleted file mode 100644 index a4e5eb09..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag South Sudan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Spain.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Spain.png deleted file mode 100644 index 0e4f7b53..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Spain.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sudan.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sudan.png deleted file mode 100644 index 2df2c77d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Sudan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ukraine.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ukraine.png deleted file mode 100644 index 3f40bf18..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Ukraine.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Arab Emirates.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Arab Emirates.png deleted file mode 100644 index 34e7bbdf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Arab Emirates.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Kingdom.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Kingdom.png deleted file mode 100644 index a873c05f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United Kingdom.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United States.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag United States.png deleted file mode 100644 index 17728e32..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag United States.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Uzbekistan.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Uzbekistan.png deleted file mode 100644 index 6a7561c6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Uzbekistan.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Vietnam.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Vietnam.png deleted file mode 100644 index c561aba8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Vietnam.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Western Sahara.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Western Sahara.png deleted file mode 100644 index ec707bd4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Western Sahara.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Yemen.png b/shine-UI/assets/emoji/telegram-previews/Flags/Flag Yemen.png deleted file mode 100644 index df3b4eed..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Flag Yemen.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Pirate Flag.png b/shine-UI/assets/emoji/telegram-previews/Flags/Pirate Flag.png deleted file mode 100644 index 80de0f52..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Pirate Flag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/Triangular Flag.png b/shine-UI/assets/emoji/telegram-previews/Flags/Triangular Flag.png deleted file mode 100644 index d5f5adb5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/Triangular Flag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Flags/White Flag.png b/shine-UI/assets/emoji/telegram-previews/Flags/White Flag.png deleted file mode 100644 index bd5c8bc1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Flags/White Flag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Baby Bottle.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Baby Bottle.png deleted file mode 100644 index ac337cd1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Baby Bottle.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Banana.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Banana.png deleted file mode 100644 index a64467d7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Banana.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bento Box.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bento Box.png deleted file mode 100644 index bb560453..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bento Box.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Beverage Box.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Beverage Box.png deleted file mode 100644 index 87ded83e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Beverage Box.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Birthday Cake.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Birthday Cake.png deleted file mode 100644 index 22cc70fe..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Birthday Cake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bottle With Popping Cork.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bottle With Popping Cork.png deleted file mode 100644 index 0dafc007..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bottle With Popping Cork.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bubble Tea.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bubble Tea.png deleted file mode 100644 index 7de1087b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Bubble Tea.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Canned Food.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Canned Food.png deleted file mode 100644 index 4140cf58..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Canned Food.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Chocolate Bar.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Chocolate Bar.png deleted file mode 100644 index 8d2a14a6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Chocolate Bar.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Clinking Glasses.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Clinking Glasses.png deleted file mode 100644 index fcfaf6a8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Clinking Glasses.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cocktail Glass.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cocktail Glass.png deleted file mode 100644 index e7b5b3d2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cocktail Glass.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cookie.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cookie.png deleted file mode 100644 index 559a5ce9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cookie.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cooking.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cooking.png deleted file mode 100644 index e0657c91..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cooking.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cup With Straw.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cup With Straw.png deleted file mode 100644 index 4c51f5a2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cup With Straw.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cupcake.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cupcake.png deleted file mode 100644 index dc064149..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Cupcake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Custard.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Custard.png deleted file mode 100644 index 28e5652d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Custard.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Dango.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Dango.png deleted file mode 100644 index aa646cf0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Dango.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Doughnut.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Doughnut.png deleted file mode 100644 index 5603c5d2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Doughnut.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fish Cake With Swirl.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fish Cake With Swirl.png deleted file mode 100644 index 55a6e338..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fish Cake With Swirl.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fork And Knife With Plate.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fork And Knife With Plate.png deleted file mode 100644 index 88e7553d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Fork And Knife With Plate.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/French Fries.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/French Fries.png deleted file mode 100644 index 499dd2b9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/French Fries.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Glass Of Milk.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Glass Of Milk.png deleted file mode 100644 index 4078941f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Glass Of Milk.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hamburger.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hamburger.png deleted file mode 100644 index 5ee68431..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hamburger.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Beverage.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Beverage.png deleted file mode 100644 index 9ddc53ba..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Beverage.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Dog.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Dog.png deleted file mode 100644 index 4c3cacbd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Hot Dog.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Lollipop.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Lollipop.png deleted file mode 100644 index 7e5fba1e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Lollipop.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Mate.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Mate.png deleted file mode 100644 index fdfa73d7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Mate.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Meat On Bone.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Meat On Bone.png deleted file mode 100644 index d2c81d3c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Meat On Bone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Oden.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Oden.png deleted file mode 100644 index d88cbcf8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Oden.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pancakes.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pancakes.png deleted file mode 100644 index fe172cd1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pancakes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pie.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pie.png deleted file mode 100644 index 49d9c5a9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pie.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pizza.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pizza.png deleted file mode 100644 index ab23e6d0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pizza.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Popcorn.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Popcorn.png deleted file mode 100644 index bbb1fcb2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Popcorn.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Poultry Leg.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Poultry Leg.png deleted file mode 100644 index a663d03c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Poultry Leg.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pouring Liquid.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pouring Liquid.png deleted file mode 100644 index 55fa7263..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pouring Liquid.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pretzel.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pretzel.png deleted file mode 100644 index 3e5afcc6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Pretzel.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Ball.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Ball.png deleted file mode 100644 index 72770482..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Ball.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Cracker.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Cracker.png deleted file mode 100644 index f8f0f626..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Rice Cracker.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sandwich.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sandwich.png deleted file mode 100644 index 371fc34e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sandwich.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Shortcake.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Shortcake.png deleted file mode 100644 index 97396be6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Shortcake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Soft Ice Cream.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Soft Ice Cream.png deleted file mode 100644 index 47e89508..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Soft Ice Cream.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Strawberry.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Strawberry.png deleted file mode 100644 index 9d174e7a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Strawberry.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Stuffed Flatbread.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Stuffed Flatbread.png deleted file mode 100644 index f562dc2c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Stuffed Flatbread.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sushi.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sushi.png deleted file mode 100644 index 6516c11c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Sushi.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Taco.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Taco.png deleted file mode 100644 index f7632d75..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Taco.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tropical Drink.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tropical Drink.png deleted file mode 100644 index aaf8802e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tropical Drink.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tumbler Glass.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tumbler Glass.png deleted file mode 100644 index ecf7ac43..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Tumbler Glass.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Wine Glass.png b/shine-UI/assets/emoji/telegram-previews/Food and Drink/Wine Glass.png deleted file mode 100644 index ea9285e3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Food and Drink/Wine Glass.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Abacus.png b/shine-UI/assets/emoji/telegram-previews/Objects/Abacus.png deleted file mode 100644 index 73f5739f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Abacus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Ballot Box With Ballot.png b/shine-UI/assets/emoji/telegram-previews/Objects/Ballot Box With Ballot.png deleted file mode 100644 index 111fa20e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Ballot Box With Ballot.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Bar Chart.png b/shine-UI/assets/emoji/telegram-previews/Objects/Bar Chart.png deleted file mode 100644 index 76c50d9c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Bar Chart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Bomb.png b/shine-UI/assets/emoji/telegram-previews/Objects/Bomb.png deleted file mode 100644 index eb5abc99..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Bomb.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Books.png b/shine-UI/assets/emoji/telegram-previews/Objects/Books.png deleted file mode 100644 index 0456dbfa..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Books.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Briefcase.png b/shine-UI/assets/emoji/telegram-previews/Objects/Briefcase.png deleted file mode 100644 index 6f72fef1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Briefcase.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Card Index Dividers.png b/shine-UI/assets/emoji/telegram-previews/Objects/Card Index Dividers.png deleted file mode 100644 index 47f6806c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Card Index Dividers.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Chart Decreasing.png b/shine-UI/assets/emoji/telegram-previews/Objects/Chart Decreasing.png deleted file mode 100644 index 004ea2fb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Chart Decreasing.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Chart Increasing.png b/shine-UI/assets/emoji/telegram-previews/Objects/Chart Increasing.png deleted file mode 100644 index 1e4de1b2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Chart Increasing.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Clapper Board.png b/shine-UI/assets/emoji/telegram-previews/Objects/Clapper Board.png deleted file mode 100644 index 153d5daa..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Clapper Board.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Coffin.png b/shine-UI/assets/emoji/telegram-previews/Objects/Coffin.png deleted file mode 100644 index 961dcc3d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Coffin.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Coin.png b/shine-UI/assets/emoji/telegram-previews/Objects/Coin.png deleted file mode 100644 index 2c58b42e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Coin.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Crown.png b/shine-UI/assets/emoji/telegram-previews/Objects/Crown.png deleted file mode 100644 index 052cd24d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Crown.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/File Folder.png b/shine-UI/assets/emoji/telegram-previews/Objects/File Folder.png deleted file mode 100644 index 9c572ac8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/File Folder.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Gem Stone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Gem Stone.png deleted file mode 100644 index f720f046..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Gem Stone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Graduation Cap.png b/shine-UI/assets/emoji/telegram-previews/Objects/Graduation Cap.png deleted file mode 100644 index 2ab8ff8d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Graduation Cap.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Handbag.png b/shine-UI/assets/emoji/telegram-previews/Objects/Handbag.png deleted file mode 100644 index 8d29dcc7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Handbag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/High Heeled Shoe.png b/shine-UI/assets/emoji/telegram-previews/Objects/High Heeled Shoe.png deleted file mode 100644 index 09cc7750..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/High Heeled Shoe.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Done.png b/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Done.png deleted file mode 100644 index 90f69d1d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Done.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Not Done.png b/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Not Done.png deleted file mode 100644 index 3bec7708..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Hourglass Not Done.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Identification Card.png b/shine-UI/assets/emoji/telegram-previews/Objects/Identification Card.png deleted file mode 100644 index 776dabe4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Identification Card.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Inbox Tray.png b/shine-UI/assets/emoji/telegram-previews/Objects/Inbox Tray.png deleted file mode 100644 index f424ae9c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Inbox Tray.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Incoming Envelope.png b/shine-UI/assets/emoji/telegram-previews/Objects/Incoming Envelope.png deleted file mode 100644 index b3b1689d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Incoming Envelope.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Key.png b/shine-UI/assets/emoji/telegram-previews/Objects/Key.png deleted file mode 100644 index 3ff2bd1a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Key.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Keyboard.png b/shine-UI/assets/emoji/telegram-previews/Objects/Keyboard.png deleted file mode 100644 index 7509939f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Keyboard.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Laptop.png b/shine-UI/assets/emoji/telegram-previews/Objects/Laptop.png deleted file mode 100644 index eaa5c011..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Laptop.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Light Bulb.png b/shine-UI/assets/emoji/telegram-previews/Objects/Light Bulb.png deleted file mode 100644 index e29d8426..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Light Bulb.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Locked With Key.png b/shine-UI/assets/emoji/telegram-previews/Objects/Locked With Key.png deleted file mode 100644 index b11e283a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Locked With Key.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Luggage.png b/shine-UI/assets/emoji/telegram-previews/Objects/Luggage.png deleted file mode 100644 index d5380d52..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Luggage.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Left.png b/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Left.png deleted file mode 100644 index 7dbd1ef3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Left.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Right.png b/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Right.png deleted file mode 100644 index 7635fd18..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Magnifying Glass Tilted Right.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Megaphone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Megaphone.png deleted file mode 100644 index f93a854c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Megaphone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Memo.png b/shine-UI/assets/emoji/telegram-previews/Objects/Memo.png deleted file mode 100644 index b03f3926..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Memo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Microphone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Microphone.png deleted file mode 100644 index 0237dc6f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Microphone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Microscope.png b/shine-UI/assets/emoji/telegram-previews/Objects/Microscope.png deleted file mode 100644 index 015150c5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Microscope.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Military Helmet.png b/shine-UI/assets/emoji/telegram-previews/Objects/Military Helmet.png deleted file mode 100644 index 6e578f5c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Military Helmet.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone With Arrow.png b/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone With Arrow.png deleted file mode 100644 index 3694b2f7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone With Arrow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone.png deleted file mode 100644 index 0ebd8510..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Mobile Phone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Money Bag.png b/shine-UI/assets/emoji/telegram-previews/Objects/Money Bag.png deleted file mode 100644 index 7b7aba89..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Money Bag.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Money With Wings.png b/shine-UI/assets/emoji/telegram-previews/Objects/Money With Wings.png deleted file mode 100644 index 7a62df08..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Money With Wings.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Musical Note.png b/shine-UI/assets/emoji/telegram-previews/Objects/Musical Note.png deleted file mode 100644 index be4b74d8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Musical Note.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Musical Notes.png b/shine-UI/assets/emoji/telegram-previews/Objects/Musical Notes.png deleted file mode 100644 index 470f894a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Musical Notes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Newspaper.png b/shine-UI/assets/emoji/telegram-previews/Objects/Newspaper.png deleted file mode 100644 index 3a9e8914..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Newspaper.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Old Key.png b/shine-UI/assets/emoji/telegram-previews/Objects/Old Key.png deleted file mode 100644 index 18dfee4b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Old Key.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Open Book.png b/shine-UI/assets/emoji/telegram-previews/Objects/Open Book.png deleted file mode 100644 index 3d0cfe70..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Open Book.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Outbox Tray.png b/shine-UI/assets/emoji/telegram-previews/Objects/Outbox Tray.png deleted file mode 100644 index e192ae11..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Outbox Tray.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Pencil.png b/shine-UI/assets/emoji/telegram-previews/Objects/Pencil.png deleted file mode 100644 index 15e88e49..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Pencil.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Pill.png b/shine-UI/assets/emoji/telegram-previews/Objects/Pill.png deleted file mode 100644 index a74e4a82..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Pill.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Printer.png b/shine-UI/assets/emoji/telegram-previews/Objects/Printer.png deleted file mode 100644 index 5902698d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Printer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Purse.png b/shine-UI/assets/emoji/telegram-previews/Objects/Purse.png deleted file mode 100644 index c535a6b2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Purse.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Reminder Ribbon.png b/shine-UI/assets/emoji/telegram-previews/Objects/Reminder Ribbon.png deleted file mode 100644 index 7f4781cc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Reminder Ribbon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Roll Of Paper.png b/shine-UI/assets/emoji/telegram-previews/Objects/Roll Of Paper.png deleted file mode 100644 index 22fe1034..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Roll Of Paper.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Bags.png b/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Bags.png deleted file mode 100644 index a3789de4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Bags.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Cart.png b/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Cart.png deleted file mode 100644 index 1efdaeb8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Shopping Cart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Soap.png b/shine-UI/assets/emoji/telegram-previews/Objects/Soap.png deleted file mode 100644 index 4c1b41d4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Soap.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Sponge.png b/shine-UI/assets/emoji/telegram-previews/Objects/Sponge.png deleted file mode 100644 index 3f70eef7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Sponge.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Stethoscope.png b/shine-UI/assets/emoji/telegram-previews/Objects/Stethoscope.png deleted file mode 100644 index 5a239748..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Stethoscope.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Studio Microphone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Studio Microphone.png deleted file mode 100644 index c5b0e361..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Studio Microphone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Syringe.png b/shine-UI/assets/emoji/telegram-previews/Objects/Syringe.png deleted file mode 100644 index 98083b8e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Syringe.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Tear Off Calendar.png b/shine-UI/assets/emoji/telegram-previews/Objects/Tear Off Calendar.png deleted file mode 100644 index 9d449348..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Tear Off Calendar.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Telephone Receiver.png b/shine-UI/assets/emoji/telegram-previews/Objects/Telephone Receiver.png deleted file mode 100644 index 15103585..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Telephone Receiver.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Telephone.png b/shine-UI/assets/emoji/telegram-previews/Objects/Telephone.png deleted file mode 100644 index 34b3ca48..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Telephone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Telescope.png b/shine-UI/assets/emoji/telegram-previews/Objects/Telescope.png deleted file mode 100644 index 56718911..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Telescope.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Television.png b/shine-UI/assets/emoji/telegram-previews/Objects/Television.png deleted file mode 100644 index 27a37554..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Television.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Test Tube.png b/shine-UI/assets/emoji/telegram-previews/Objects/Test Tube.png deleted file mode 100644 index c234ff5a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Test Tube.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Thermometer.png b/shine-UI/assets/emoji/telegram-previews/Objects/Thermometer.png deleted file mode 100644 index 94b0f169..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Thermometer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Toolbox.png b/shine-UI/assets/emoji/telegram-previews/Objects/Toolbox.png deleted file mode 100644 index c96ef76b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Toolbox.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Objects/Top Hat.png b/shine-UI/assets/emoji/telegram-previews/Objects/Top Hat.png deleted file mode 100644 index 93d3a799..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Objects/Top Hat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Baby.png b/shine-UI/assets/emoji/telegram-previews/People/Baby.png deleted file mode 100644 index 176d1b26..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Baby.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Down.png b/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Down.png deleted file mode 100644 index 1374307d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Down.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Left.png b/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Left.png deleted file mode 100644 index 2b8af415..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Left.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Right.png b/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Right.png deleted file mode 100644 index b418deca..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Right.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Up.png b/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Up.png deleted file mode 100644 index 95ba5863..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Backhand Index Pointing Up.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Biting Lip.png b/shine-UI/assets/emoji/telegram-previews/People/Biting Lip.png deleted file mode 100644 index 161d2f45..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Biting Lip.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Bone.png b/shine-UI/assets/emoji/telegram-previews/People/Bone.png deleted file mode 100644 index 7080a61e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Bone.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Bust In Silhouette.png b/shine-UI/assets/emoji/telegram-previews/People/Bust In Silhouette.png deleted file mode 100644 index 6f6a541e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Bust In Silhouette.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Busts In Silhouette.png b/shine-UI/assets/emoji/telegram-previews/People/Busts In Silhouette.png deleted file mode 100644 index 328d3139..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Busts In Silhouette.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Call Me Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Call Me Hand.png deleted file mode 100644 index 38a5078b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Call Me Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Clapping Hands.png b/shine-UI/assets/emoji/telegram-previews/People/Clapping Hands.png deleted file mode 100644 index 564bbd0d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Clapping Hands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Crossed Fingers.png b/shine-UI/assets/emoji/telegram-previews/People/Crossed Fingers.png deleted file mode 100644 index 61b5d2a5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Crossed Fingers.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Ear With Hearing Aid.png b/shine-UI/assets/emoji/telegram-previews/People/Ear With Hearing Aid.png deleted file mode 100644 index 45894edc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Ear With Hearing Aid.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Ear.png b/shine-UI/assets/emoji/telegram-previews/People/Ear.png deleted file mode 100644 index f397e62a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Ear.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Eyes.png b/shine-UI/assets/emoji/telegram-previews/People/Eyes.png deleted file mode 100644 index 663e3d7f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Family Man Woman Girl Boy.png b/shine-UI/assets/emoji/telegram-previews/People/Family Man Woman Girl Boy.png deleted file mode 100644 index 428d574a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Family Man Woman Girl Boy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Flexed Biceps.png b/shine-UI/assets/emoji/telegram-previews/People/Flexed Biceps.png deleted file mode 100644 index 853f166c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Flexed Biceps.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Folded Hands.png b/shine-UI/assets/emoji/telegram-previews/People/Folded Hands.png deleted file mode 100644 index 2824094c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Folded Hands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Foot.png b/shine-UI/assets/emoji/telegram-previews/People/Foot.png deleted file mode 100644 index c33a8f84..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Foot.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Footprints.png b/shine-UI/assets/emoji/telegram-previews/People/Footprints.png deleted file mode 100644 index 8a1f5db4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Footprints.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Hand With Fingers Splayed.png b/shine-UI/assets/emoji/telegram-previews/People/Hand With Fingers Splayed.png deleted file mode 100644 index d82c981f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Hand With Fingers Splayed.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Hand With Index Finger And Thumb Crossed.png b/shine-UI/assets/emoji/telegram-previews/People/Hand With Index Finger And Thumb Crossed.png deleted file mode 100644 index 3c5f7ac2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Hand With Index Finger And Thumb Crossed.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Handshake.png b/shine-UI/assets/emoji/telegram-previews/People/Handshake.png deleted file mode 100644 index 718eb39b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Handshake.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Heart Hands.png b/shine-UI/assets/emoji/telegram-previews/People/Heart Hands.png deleted file mode 100644 index 29007bc2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Heart Hands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Index Pointing At The Viewer.png b/shine-UI/assets/emoji/telegram-previews/People/Index Pointing At The Viewer.png deleted file mode 100644 index 0734f31c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Index Pointing At The Viewer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Index Pointing Up.png b/shine-UI/assets/emoji/telegram-previews/People/Index Pointing Up.png deleted file mode 100644 index f7678a96..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Index Pointing Up.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Left Facing Fist.png b/shine-UI/assets/emoji/telegram-previews/People/Left Facing Fist.png deleted file mode 100644 index f7f229c7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Left Facing Fist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Leftwards Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Leftwards Hand.png deleted file mode 100644 index e542e8b1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Leftwards Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Leg.png b/shine-UI/assets/emoji/telegram-previews/People/Leg.png deleted file mode 100644 index 0b855554..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Leg.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Love You Gesture.png b/shine-UI/assets/emoji/telegram-previews/People/Love You Gesture.png deleted file mode 100644 index 13003621..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Love You Gesture.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Dancing.png b/shine-UI/assets/emoji/telegram-previews/People/Man Dancing.png deleted file mode 100644 index ca7621ea..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Dancing.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Facepalming.png b/shine-UI/assets/emoji/telegram-previews/People/Man Facepalming.png deleted file mode 100644 index 31518a77..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Facepalming.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Health Worker.png b/shine-UI/assets/emoji/telegram-previews/People/Man Health Worker.png deleted file mode 100644 index 4e029165..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Health Worker.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Police Officer.png b/shine-UI/assets/emoji/telegram-previews/People/Man Police Officer.png deleted file mode 100644 index 90349b97..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Police Officer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Shrugging.png b/shine-UI/assets/emoji/telegram-previews/People/Man Shrugging.png deleted file mode 100644 index b8790596..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Shrugging.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Teacher.png b/shine-UI/assets/emoji/telegram-previews/People/Man Teacher.png deleted file mode 100644 index b352128f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Teacher.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Technologist.png b/shine-UI/assets/emoji/telegram-previews/People/Man Technologist.png deleted file mode 100644 index 3a15d8d7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Technologist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Vampire.png b/shine-UI/assets/emoji/telegram-previews/People/Man Vampire.png deleted file mode 100644 index bbbdd2d5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Vampire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Man Zombie.png b/shine-UI/assets/emoji/telegram-previews/People/Man Zombie.png deleted file mode 100644 index 641aff49..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Man Zombie.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Mechanical Arm.png b/shine-UI/assets/emoji/telegram-previews/People/Mechanical Arm.png deleted file mode 100644 index f982177a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Mechanical Arm.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Mechanical Leg.png b/shine-UI/assets/emoji/telegram-previews/People/Mechanical Leg.png deleted file mode 100644 index 09e1e581..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Mechanical Leg.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Middle Finger.png b/shine-UI/assets/emoji/telegram-previews/People/Middle Finger.png deleted file mode 100644 index f982b404..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Middle Finger.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Mouth.png b/shine-UI/assets/emoji/telegram-previews/People/Mouth.png deleted file mode 100644 index da2bfad8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Mrs Claus.png b/shine-UI/assets/emoji/telegram-previews/People/Mrs Claus.png deleted file mode 100644 index 2cb23ebb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Mrs Claus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Mx Claus.png b/shine-UI/assets/emoji/telegram-previews/People/Mx Claus.png deleted file mode 100644 index 5985ecb0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Mx Claus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Nail Polish.png b/shine-UI/assets/emoji/telegram-previews/People/Nail Polish.png deleted file mode 100644 index bcda9a73..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Nail Polish.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Nose.png b/shine-UI/assets/emoji/telegram-previews/People/Nose.png deleted file mode 100644 index bd54f910..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Nose.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Ok Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Ok Hand.png deleted file mode 100644 index 7829c2b4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Ok Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Old Woman.png b/shine-UI/assets/emoji/telegram-previews/People/Old Woman.png deleted file mode 100644 index 3337c76b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Old Woman.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Oncoming Fist.png b/shine-UI/assets/emoji/telegram-previews/People/Oncoming Fist.png deleted file mode 100644 index e9bda5a3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Oncoming Fist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Open Hands.png b/shine-UI/assets/emoji/telegram-previews/People/Open Hands.png deleted file mode 100644 index dabec852..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Open Hands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Palm Down Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Palm Down Hand.png deleted file mode 100644 index 07c4962f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Palm Down Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Palm Up Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Palm Up Hand.png deleted file mode 100644 index 423e3726..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Palm Up Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Palms Up Together.png b/shine-UI/assets/emoji/telegram-previews/People/Palms Up Together.png deleted file mode 100644 index 82a73738..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Palms Up Together.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/People Hugging.png b/shine-UI/assets/emoji/telegram-previews/People/People Hugging.png deleted file mode 100644 index 2c0f70f4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/People Hugging.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Person Facepalming.png b/shine-UI/assets/emoji/telegram-previews/People/Person Facepalming.png deleted file mode 100644 index 26930642..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Person Facepalming.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Person Shrugging.png b/shine-UI/assets/emoji/telegram-previews/People/Person Shrugging.png deleted file mode 100644 index 2f1511d3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Person Shrugging.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Pinched Fingers.png b/shine-UI/assets/emoji/telegram-previews/People/Pinched Fingers.png deleted file mode 100644 index 82551fb1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Pinched Fingers.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Pinching Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Pinching Hand.png deleted file mode 100644 index 9fe1c50b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Pinching Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Pregnant Woman.png b/shine-UI/assets/emoji/telegram-previews/People/Pregnant Woman.png deleted file mode 100644 index 594088c5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Pregnant Woman.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Raised Back Of Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Raised Back Of Hand.png deleted file mode 100644 index 4d7cc24b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Raised Back Of Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Raised Fist.png b/shine-UI/assets/emoji/telegram-previews/People/Raised Fist.png deleted file mode 100644 index 12c2baed..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Raised Fist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Raised Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Raised Hand.png deleted file mode 100644 index e4515647..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Raised Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Raising Hands.png b/shine-UI/assets/emoji/telegram-previews/People/Raising Hands.png deleted file mode 100644 index c9e433b4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Raising Hands.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Right Facing Fist.png b/shine-UI/assets/emoji/telegram-previews/People/Right Facing Fist.png deleted file mode 100644 index 5766aad7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Right Facing Fist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Rightwards Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Rightwards Hand.png deleted file mode 100644 index 4b176c45..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Rightwards Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Santa Claus.png b/shine-UI/assets/emoji/telegram-previews/People/Santa Claus.png deleted file mode 100644 index 335d32b0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Santa Claus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Sign Of The Horns.png b/shine-UI/assets/emoji/telegram-previews/People/Sign Of The Horns.png deleted file mode 100644 index 07ebdb4a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Sign Of The Horns.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Speaking Head.png b/shine-UI/assets/emoji/telegram-previews/People/Speaking Head.png deleted file mode 100644 index 9d5f1fc2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Speaking Head.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Technologist.png b/shine-UI/assets/emoji/telegram-previews/People/Technologist.png deleted file mode 100644 index 18421386..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Technologist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Thumbs Down.png b/shine-UI/assets/emoji/telegram-previews/People/Thumbs Down.png deleted file mode 100644 index edeeffad..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Thumbs Down.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Thumbs Up.png b/shine-UI/assets/emoji/telegram-previews/People/Thumbs Up.png deleted file mode 100644 index 348d9764..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Thumbs Up.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Tongue.png b/shine-UI/assets/emoji/telegram-previews/People/Tongue.png deleted file mode 100644 index 2a4d8351..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Tongue.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Tooth.png b/shine-UI/assets/emoji/telegram-previews/People/Tooth.png deleted file mode 100644 index 9a027985..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Tooth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Vampire.png b/shine-UI/assets/emoji/telegram-previews/People/Vampire.png deleted file mode 100644 index 8f6eb511..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Vampire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Victory Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Victory Hand.png deleted file mode 100644 index dc251269..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Victory Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Vulcan Salute.png b/shine-UI/assets/emoji/telegram-previews/People/Vulcan Salute.png deleted file mode 100644 index dac0671f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Vulcan Salute.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Waving Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Waving Hand.png deleted file mode 100644 index 62708287..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Waving Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Dancing.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Dancing.png deleted file mode 100644 index 6466bc8b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Dancing.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Facepalming.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Facepalming.png deleted file mode 100644 index f014bd65..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Facepalming.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Health Worker.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Health Worker.png deleted file mode 100644 index 45375305..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Health Worker.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Police Officer.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Police Officer.png deleted file mode 100644 index 65f80d6a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Police Officer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Shrugging.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Shrugging.png deleted file mode 100644 index 35748872..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Shrugging.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Technologist.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Technologist.png deleted file mode 100644 index 677634ce..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Technologist.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Vampire.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Vampire.png deleted file mode 100644 index 680eaeda..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Vampire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Woman Zombie.png b/shine-UI/assets/emoji/telegram-previews/People/Woman Zombie.png deleted file mode 100644 index 09efd766..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Woman Zombie.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Writing Hand.png b/shine-UI/assets/emoji/telegram-previews/People/Writing Hand.png deleted file mode 100644 index 018fe892..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Writing Hand.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/People/Zombie.png b/shine-UI/assets/emoji/telegram-previews/People/Zombie.png deleted file mode 100644 index 8fd2a915..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/People/Zombie.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Alien Monster.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Alien Monster.png deleted file mode 100644 index 40b566b9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Alien Monster.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Alien.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Alien.png deleted file mode 100644 index 8339a767..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Alien.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face With Horns.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face With Horns.png deleted file mode 100644 index 4ec0ee74..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face With Horns.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face.png deleted file mode 100644 index c516f411..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Angry Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Anguished Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Anguished Face.png deleted file mode 100644 index b93bf146..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Anguished Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Anxious Face With Sweat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Anxious Face With Sweat.png deleted file mode 100644 index 98815379..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Anxious Face With Sweat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Astonished Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Astonished Face.png deleted file mode 100644 index 5103c0b1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Astonished Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Beaming Face With Smiling Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Beaming Face With Smiling Eyes.png deleted file mode 100644 index 949380dc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Beaming Face With Smiling Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Tears Of Joy.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Tears Of Joy.png deleted file mode 100644 index 55461090..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Tears Of Joy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Wry Smile.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Wry Smile.png deleted file mode 100644 index f63b9bab..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Cat With Wry Smile.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Clown Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Clown Face.png deleted file mode 100644 index 52cdb33d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Clown Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Cold Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Cold Face.png deleted file mode 100644 index e51aba06..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Cold Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Confounded Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Confounded Face.png deleted file mode 100644 index 1625fcb6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Confounded Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Confused Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Confused Face.png deleted file mode 100644 index 8865df1e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Confused Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Cowboy Hat Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Cowboy Hat Face.png deleted file mode 100644 index 53191993..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Cowboy Hat Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Cat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Cat.png deleted file mode 100644 index 5876bf6b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Face.png deleted file mode 100644 index 8d7313d5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Crying Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Disappointed Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Disappointed Face.png deleted file mode 100644 index f7e5384d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Disappointed Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Disguised Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Disguised Face.png deleted file mode 100644 index 5600fbad..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Disguised Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Dizzy Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Dizzy Face.png deleted file mode 100644 index 082f2b9e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Dizzy Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Dotted Line Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Dotted Line Face.png deleted file mode 100644 index 4517cd09..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Dotted Line Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Downcast Face With Sweat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Downcast Face With Sweat.png deleted file mode 100644 index 9726dee9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Downcast Face With Sweat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Drooling Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Drooling Face.png deleted file mode 100644 index 1c6490cf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Drooling Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Exploding Head.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Exploding Head.png deleted file mode 100644 index b87d29d7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Exploding Head.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Expressionless Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Expressionless Face.png deleted file mode 100644 index f6a2c545..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Expressionless Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Blowing A Kiss.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Blowing A Kiss.png deleted file mode 100644 index a121d244..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Blowing A Kiss.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Exhaling.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Exhaling.png deleted file mode 100644 index e13f1181..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Exhaling.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Holding Back Tears.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Holding Back Tears.png deleted file mode 100644 index 2ddd7c41..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Holding Back Tears.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face In Clouds.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face In Clouds.png deleted file mode 100644 index 54870c00..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face In Clouds.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Savoring Food.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Savoring Food.png deleted file mode 100644 index acaec446..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Savoring Food.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Screaming In Fear.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Screaming In Fear.png deleted file mode 100644 index 0ba7cc7b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Screaming In Fear.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Vomiting.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Vomiting.png deleted file mode 100644 index 2a18d511..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Vomiting.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Diagonal Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Diagonal Mouth.png deleted file mode 100644 index 0a459856..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Diagonal Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Hand Over Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Hand Over Mouth.png deleted file mode 100644 index f83bde01..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Hand Over Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Head Bandage.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Head Bandage.png deleted file mode 100644 index 600be031..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Head Bandage.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Medical Mask.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Medical Mask.png deleted file mode 100644 index a8aec7ca..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Medical Mask.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Monocle.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Monocle.png deleted file mode 100644 index a81afaba..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Monocle.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Eyes And Hand Over Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Eyes And Hand Over Mouth.png deleted file mode 100644 index 85c6ec90..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Eyes And Hand Over Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Mouth.png deleted file mode 100644 index 8b9aa31b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Open Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Peeking Eye.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Peeking Eye.png deleted file mode 100644 index 96db9c4d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Peeking Eye.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Raised Eyebrow.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Raised Eyebrow.png deleted file mode 100644 index 03b81bee..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Raised Eyebrow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Rolling Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Rolling Eyes.png deleted file mode 100644 index 72e11ce2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Rolling Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Spiral Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Spiral Eyes.png deleted file mode 100644 index cb1e1913..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Spiral Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Steam From Nose.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Steam From Nose.png deleted file mode 100644 index 125c8ffb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Steam From Nose.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Symbols On Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Symbols On Mouth.png deleted file mode 100644 index 3cdd58dd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Symbols On Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tears Of Joy.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tears Of Joy.png deleted file mode 100644 index 0ee4146b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tears Of Joy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Thermometer.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Thermometer.png deleted file mode 100644 index 60db76ce..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Thermometer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tongue.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tongue.png deleted file mode 100644 index 6a961a1d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face With Tongue.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Without Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Face Without Mouth.png deleted file mode 100644 index 12ae0e99..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Face Without Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Fearful Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Fearful Face.png deleted file mode 100644 index 0aca3dc2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Fearful Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Flushed Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Flushed Face.png deleted file mode 100644 index 19874986..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Flushed Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face With Open Mouth.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face With Open Mouth.png deleted file mode 100644 index 0bd3bd60..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face With Open Mouth.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face.png deleted file mode 100644 index da69d0af..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Frowning Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Ghost.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Ghost.png deleted file mode 100644 index 58d048f9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Ghost.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Goblin.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Goblin.png deleted file mode 100644 index 0f2f1587..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Goblin.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grimacing Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grimacing Face.png deleted file mode 100644 index dff29701..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grimacing Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat With Smiling Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat With Smiling Eyes.png deleted file mode 100644 index 9bbdf33b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat With Smiling Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat.png deleted file mode 100644 index 79763d04..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Big Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Big Eyes.png deleted file mode 100644 index 90a5d78f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Big Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Smiling Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Smiling Eyes.png deleted file mode 100644 index f39deba1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Smiling Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Sweat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Sweat.png deleted file mode 100644 index 5b718662..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face With Sweat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face.png deleted file mode 100644 index 95a519fb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Squinting Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Squinting Face.png deleted file mode 100644 index 6c04884c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Grinning Squinting Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Hear No Evil Monkey.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Hear No Evil Monkey.png deleted file mode 100644 index 28dbf466..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Hear No Evil Monkey.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Hot Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Hot Face.png deleted file mode 100644 index 3f54dbe0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Hot Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Hugging Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Hugging Face.png deleted file mode 100644 index f302cd95..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Hugging Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Hushed Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Hushed Face.png deleted file mode 100644 index b100c805..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Hushed Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Cat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Cat.png deleted file mode 100644 index ace551cf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face With Closed Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face With Closed Eyes.png deleted file mode 100644 index a24a6dcf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face With Closed Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face.png deleted file mode 100644 index fd478a96..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Kissing Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Loudly Crying Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Loudly Crying Face.png deleted file mode 100644 index 9bf924f7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Loudly Crying Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Lying Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Lying Face.png deleted file mode 100644 index 25772f73..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Lying Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Melting Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Melting Face.png deleted file mode 100644 index 059886ca..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Melting Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Money Mouth Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Money Mouth Face.png deleted file mode 100644 index d55cbda0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Money Mouth Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Nauseated Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Nauseated Face.png deleted file mode 100644 index 7c221b84..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Nauseated Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Nerd Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Nerd Face.png deleted file mode 100644 index fe6871a4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Nerd Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Neutral Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Neutral Face.png deleted file mode 100644 index c709b1e2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Neutral Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Ogre.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Ogre.png deleted file mode 100644 index 607c9d1c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Ogre.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Partying Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Partying Face.png deleted file mode 100644 index b33d25ff..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Partying Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Pensive Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Pensive Face.png deleted file mode 100644 index 7eea6a3c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Pensive Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Persevering Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Persevering Face.png deleted file mode 100644 index 428a96be..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Persevering Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Pile Of Poo.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Pile Of Poo.png deleted file mode 100644 index 9166a4dd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Pile Of Poo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Pleading Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Pleading Face.png deleted file mode 100644 index 8634fb10..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Pleading Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Cat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Cat.png deleted file mode 100644 index e02366a5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Face.png deleted file mode 100644 index 9a13648f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Pouting Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Relieved Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Relieved Face.png deleted file mode 100644 index ddcdf5dc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Relieved Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Robot.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Robot.png deleted file mode 100644 index 425a16bb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Robot.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Rolling On The Floor Laughing.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Rolling On The Floor Laughing.png deleted file mode 100644 index 2a02edc7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Rolling On The Floor Laughing.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Sad But Relieved Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Sad But Relieved Face.png deleted file mode 100644 index 9a4befae..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Sad But Relieved Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Saluting Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Saluting Face.png deleted file mode 100644 index e6311c2e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Saluting Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/See No Evil Monkey.png b/shine-UI/assets/emoji/telegram-previews/Smileys/See No Evil Monkey.png deleted file mode 100644 index 5d5aa030..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/See No Evil Monkey.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Shushing Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Shushing Face.png deleted file mode 100644 index 104060b0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Shushing Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Skull And Crossbones.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Skull And Crossbones.png deleted file mode 100644 index 66004abd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Skull And Crossbones.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Skull.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Skull.png deleted file mode 100644 index 6ae501e6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Skull.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Sleeping Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Sleeping Face.png deleted file mode 100644 index 1844c6b1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Sleeping Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Sleepy Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Sleepy Face.png deleted file mode 100644 index 16a47c1f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Sleepy Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Frowning Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Frowning Face.png deleted file mode 100644 index 6a0f1e25..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Frowning Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Smiling Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Smiling Face.png deleted file mode 100644 index 51b052ed..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Slightly Smiling Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Cat With Heart Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Cat With Heart Eyes.png deleted file mode 100644 index 135495ea..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Cat With Heart Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Halo.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Halo.png deleted file mode 100644 index 5c9ad722..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Halo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Hearts.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Hearts.png deleted file mode 100644 index e2d278e7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Hearts.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Horns.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Horns.png deleted file mode 100644 index 5e7fe232..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Horns.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Smiling Eyes.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Smiling Eyes.png deleted file mode 100644 index 907841b8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Smiling Eyes.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Sunglasses.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Sunglasses.png deleted file mode 100644 index 1def0174..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Sunglasses.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Tear.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Tear.png deleted file mode 100644 index ff5db4df..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face With Tear.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face.png deleted file mode 100644 index 6ae5abdc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smiling Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Smirking Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Smirking Face.png deleted file mode 100644 index a69d966a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Smirking Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Sneezing Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Sneezing Face.png deleted file mode 100644 index c80a288c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Sneezing Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Speak No Evil Monkey.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Speak No Evil Monkey.png deleted file mode 100644 index 3c7ef643..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Speak No Evil Monkey.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Squinting Face With Tongue.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Squinting Face With Tongue.png deleted file mode 100644 index 50e2a8f1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Squinting Face With Tongue.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Star Struck.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Star Struck.png deleted file mode 100644 index 2b50b401..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Star Struck.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Thinking Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Thinking Face.png deleted file mode 100644 index 93f65839..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Thinking Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Tired Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Tired Face.png deleted file mode 100644 index 9031b6d1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Tired Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Unamused Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Unamused Face.png deleted file mode 100644 index da6eb865..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Unamused Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Upside Down Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Upside Down Face.png deleted file mode 100644 index cf36dd45..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Upside Down Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Cat.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Cat.png deleted file mode 100644 index 195f14e8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Cat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Face.png deleted file mode 100644 index df6a427e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Weary Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face With Tongue.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face With Tongue.png deleted file mode 100644 index 8f6e80e4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face With Tongue.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face.png deleted file mode 100644 index f38b2bd8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Winking Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Woozy Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Woozy Face.png deleted file mode 100644 index b2de97c2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Woozy Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Worried Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Worried Face.png deleted file mode 100644 index d3ff72e8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Worried Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Yawning Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Yawning Face.png deleted file mode 100644 index b5db467c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Yawning Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Zany Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Zany Face.png deleted file mode 100644 index 299d96e8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Zany Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Smileys/Zipper Mouth Face.png b/shine-UI/assets/emoji/telegram-previews/Smileys/Zipper Mouth Face.png deleted file mode 100644 index dca5acc7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Smileys/Zipper Mouth Face.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Anger Symbol.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Anger Symbol.png deleted file mode 100644 index f9e5610e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Anger Symbol.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Aquarius.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Aquarius.png deleted file mode 100644 index 70886863..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Aquarius.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Aries.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Aries.png deleted file mode 100644 index e3caa635..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Aries.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Baby Symbol.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Baby Symbol.png deleted file mode 100644 index d0aa68b2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Baby Symbol.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Baggage Claim.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Baggage Claim.png deleted file mode 100644 index 278a4d4b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Baggage Claim.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Beating Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Beating Heart.png deleted file mode 100644 index bf7f88d0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Beating Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Black Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Black Heart.png deleted file mode 100644 index 4c132a04..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Black Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Blue Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Blue Heart.png deleted file mode 100644 index 7203e169..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Blue Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Broken Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Broken Heart.png deleted file mode 100644 index 5080d314..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Broken Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Brown Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Brown Heart.png deleted file mode 100644 index 57825fd8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Brown Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Cancer.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Cancer.png deleted file mode 100644 index 3340e9aa..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Cancer.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Capricorn.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Capricorn.png deleted file mode 100644 index 608efdaf..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Capricorn.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Box With Check.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Check Box With Check.png deleted file mode 100644 index 0a2b5d71..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Box With Check.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark Button.png deleted file mode 100644 index f8ea5845..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark.png deleted file mode 100644 index c56e4605..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Check Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Collision.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Collision.png deleted file mode 100644 index 1ce45e09..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Collision.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Cool Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Cool Button.png deleted file mode 100644 index c84a49db..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Cool Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Cross Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Cross Mark.png deleted file mode 100644 index e08881cb..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Cross Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Currency Exchange.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Currency Exchange.png deleted file mode 100644 index 5ace4aa9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Currency Exchange.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Customs.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Customs.png deleted file mode 100644 index 051baa52..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Customs.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Dizzy.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Dizzy.png deleted file mode 100644 index 287ead97..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Dizzy.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Double Exclamation Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Double Exclamation Mark.png deleted file mode 100644 index 2e01f520..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Double Exclamation Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Mark.png deleted file mode 100644 index fe33dbc7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Question Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Question Mark.png deleted file mode 100644 index 33f807bd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Exclamation Question Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Free Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Free Button.png deleted file mode 100644 index b25f7cd8..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Free Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Gemini.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Gemini.png deleted file mode 100644 index 395dbede..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Gemini.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Green Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Green Heart.png deleted file mode 100644 index 3f54d02a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Green Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Growing Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Growing Heart.png deleted file mode 100644 index 6f0b1b6d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Growing Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Decoration.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Decoration.png deleted file mode 100644 index a7d3d5b9..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Decoration.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Exclamation.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Exclamation.png deleted file mode 100644 index cfeba0f4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart Exclamation.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart On Fire.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Heart On Fire.png deleted file mode 100644 index 29f10121..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart On Fire.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Arrow.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Arrow.png deleted file mode 100644 index 3a3893c5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Arrow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Ribbon.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Ribbon.png deleted file mode 100644 index 8057844b..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Heart With Ribbon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Hundred Points.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Hundred Points.png deleted file mode 100644 index f438d343..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Hundred Points.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Kiss Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Kiss Mark.png deleted file mode 100644 index a39998b5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Kiss Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Left Luggage.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Left Luggage.png deleted file mode 100644 index ea9ac7fa..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Left Luggage.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Leo.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Leo.png deleted file mode 100644 index 10f0e411..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Leo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Libra.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Libra.png deleted file mode 100644 index 8e3dbc0c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Libra.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Love Letter.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Love Letter.png deleted file mode 100644 index 4614293d..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Love Letter.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Mending Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Mending Heart.png deleted file mode 100644 index 8ba66be1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Mending Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Mens Room.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Mens Room.png deleted file mode 100644 index 7c11cb31..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Mens Room.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/New Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/New Button.png deleted file mode 100644 index 2398932a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/New Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/No One Under Eighteen.png b/shine-UI/assets/emoji/telegram-previews/Symbols/No One Under Eighteen.png deleted file mode 100644 index 0c0e04e6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/No One Under Eighteen.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Ok Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Ok Button.png deleted file mode 100644 index 2a602afd..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Ok Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Ophiuchus.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Ophiuchus.png deleted file mode 100644 index 15a72260..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Ophiuchus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Orange Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Orange Heart.png deleted file mode 100644 index 3bad33ad..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Orange Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Passport Control.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Passport Control.png deleted file mode 100644 index 294b3646..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Passport Control.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Pisces.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Pisces.png deleted file mode 100644 index 934fc2c6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Pisces.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Purple Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Purple Heart.png deleted file mode 100644 index ec8bd68f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Purple Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Question Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Question Mark.png deleted file mode 100644 index ca42443c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Question Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Red Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Red Heart.png deleted file mode 100644 index 77dff455..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Red Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Revolving Hearts.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Revolving Hearts.png deleted file mode 100644 index c1313cd6..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Revolving Hearts.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Right Anger Bubble.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Right Anger Bubble.png deleted file mode 100644 index 4f754c92..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Right Anger Bubble.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Sagittarius.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Sagittarius.png deleted file mode 100644 index 5916e53f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Sagittarius.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Scorpio.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Scorpio.png deleted file mode 100644 index c944cec1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Scorpio.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Sparkling Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Sparkling Heart.png deleted file mode 100644 index 0aa2d707..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Sparkling Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Speech Balloon.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Speech Balloon.png deleted file mode 100644 index 328605fc..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Speech Balloon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Taurus.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Taurus.png deleted file mode 100644 index 0d2f9a50..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Taurus.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Thought Balloon.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Thought Balloon.png deleted file mode 100644 index 0a909cd2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Thought Balloon.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Top Arrow.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Top Arrow.png deleted file mode 100644 index eb04aaa1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Top Arrow.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Two Hearts.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Two Hearts.png deleted file mode 100644 index 8d22e1ee..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Two Hearts.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Up Button.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Up Button.png deleted file mode 100644 index 54c39d0a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Up Button.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Virgo.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Virgo.png deleted file mode 100644 index bc2c252e..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Virgo.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/White Exclamation Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/White Exclamation Mark.png deleted file mode 100644 index ac2e7e5a..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/White Exclamation Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/White Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/White Heart.png deleted file mode 100644 index d216f7ad..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/White Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/White Question Mark.png b/shine-UI/assets/emoji/telegram-previews/Symbols/White Question Mark.png deleted file mode 100644 index 924c8205..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/White Question Mark.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Womens Room.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Womens Room.png deleted file mode 100644 index ce99c097..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Womens Room.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Yellow Heart.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Yellow Heart.png deleted file mode 100644 index d8d1c572..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Yellow Heart.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Symbols/Zzz.png b/shine-UI/assets/emoji/telegram-previews/Symbols/Zzz.png deleted file mode 100644 index ae9e05d5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Symbols/Zzz.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Airplane.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Airplane.png deleted file mode 100644 index bc95329f..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Airplane.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Ambulance.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Ambulance.png deleted file mode 100644 index 4c421aa4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Ambulance.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Automobile.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Automobile.png deleted file mode 100644 index 940b922c..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Automobile.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Beach With Umbrella.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Beach With Umbrella.png deleted file mode 100644 index 32a75d03..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Beach With Umbrella.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Camping.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Camping.png deleted file mode 100644 index 3e9ac2f2..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Camping.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Classical Building.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Classical Building.png deleted file mode 100644 index 9b5766f7..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Classical Building.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Compass.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Compass.png deleted file mode 100644 index 79ad6ee5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Compass.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Desert Island.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Desert Island.png deleted file mode 100644 index f1b921e1..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Desert Island.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Hot Springs.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Hot Springs.png deleted file mode 100644 index c8bace15..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Hot Springs.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/House.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/House.png deleted file mode 100644 index 2493f5b0..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/House.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Locomotive.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Locomotive.png deleted file mode 100644 index 730fc6b5..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Locomotive.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Moai.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Moai.png deleted file mode 100644 index 3a87df89..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Moai.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Motor Boat.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Motor Boat.png deleted file mode 100644 index 51af89c3..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Motor Boat.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Police Car.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Police Car.png deleted file mode 100644 index 76cb88c4..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Police Car.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Rocket.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Rocket.png deleted file mode 100644 index 05dd8032..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Rocket.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Roller Coaster.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Roller Coaster.png deleted file mode 100644 index 8ce02568..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Roller Coaster.png and /dev/null differ diff --git a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Taxi.png b/shine-UI/assets/emoji/telegram-previews/Travel and Places/Taxi.png deleted file mode 100644 index 7e063824..00000000 Binary files a/shine-UI/assets/emoji/telegram-previews/Travel and Places/Taxi.png and /dev/null differ diff --git a/shine-UI/assets/profile-icon-profile.svg b/shine-UI/assets/profile-icon-profile.svg new file mode 100644 index 00000000..5d7244e4 --- /dev/null +++ b/shine-UI/assets/profile-icon-profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shine-UI/assets/profile-icon-settings.svg b/shine-UI/assets/profile-icon-settings.svg new file mode 100644 index 00000000..c3bd6471 --- /dev/null +++ b/shine-UI/assets/profile-icon-settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shine-UI/assets/profile-icon-wallet.svg b/shine-UI/assets/profile-icon-wallet.svg new file mode 100644 index 00000000..03cccb21 --- /dev/null +++ b/shine-UI/assets/profile-icon-wallet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shine-UI/emoji-catalog-diagnostic.mjs b/shine-UI/emoji-catalog-diagnostic.mjs index 475c73d9..7746b3ee 100644 --- a/shine-UI/emoji-catalog-diagnostic.mjs +++ b/shine-UI/emoji-catalog-diagnostic.mjs @@ -1,6 +1,6 @@ -import { TELEGRAM_EMOJI_GROUPS } from './js/components/telegram-emoji-catalog.js?diagnostic=202607151545'; +import { TWEMOJI_GROUPS } from './js/components/twemoji-catalog.js?diagnostic=202607151545'; -const items = TELEGRAM_EMOJI_GROUPS.flatMap((group) => +const items = TWEMOJI_GROUPS.flatMap((group) => group.items.map((item) => ({ ...item, category: group.id })), ); @@ -21,7 +21,7 @@ const missing = items .map((item) => ({ file: item.file, missing: ['emoji', 'file', 'name', 'category', 'previewUrl', 'animatedUrl'].filter((field) => !item[field]) })); console.log(JSON.stringify({ - categories: TELEGRAM_EMOJI_GROUPS.map((group) => ({ id: group.id, label: group.label, count: group.items.length })), + categories: TWEMOJI_GROUPS.map((group) => ({ id: group.id, label: group.label, count: group.items.length })), total: items.length, uniqueUnicode: new Set(items.map((item) => item.emoji)).size, uniqueUrl: new Set(items.map((item) => item.file)).size, diff --git a/shine-UI/generate_telegram_emoji_previews.py b/shine-UI/generate_telegram_emoji_previews.py deleted file mode 100644 index ccec0655..00000000 --- a/shine-UI/generate_telegram_emoji_previews.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Generate local first-frame PNG previews for the Telegram emoji catalog.""" - -from __future__ import annotations - -import argparse -import re -import sys -from concurrent.futures import ThreadPoolExecutor, as_completed -from io import BytesIO -from pathlib import Path -from urllib.parse import quote -from urllib.request import Request, urlopen - -from PIL import Image - - -ROOT = Path(__file__).resolve().parent -CATALOG_DIR = ROOT / "js" / "components" -OUTPUT_DIR = ROOT / "assets" / "emoji" / "telegram-previews" -ASSET_BASE_URL = "https://raw.githubusercontent.com/Tarikul-Islam-Anik/Telegram-Animated-Emojis/main/" -FILE_PATTERN = re.compile(r'"file"\s*:\s*"([^"]+\.webp)"', re.IGNORECASE) - - -def catalog_files() -> list[str]: - files: set[str] = set() - for path in sorted(CATALOG_DIR.glob("telegram-emoji-*.js")): - files.update(FILE_PATTERN.findall(path.read_text(encoding="utf-8"))) - return sorted(files) - - -def preview_path(source_file: str) -> Path: - return OUTPUT_DIR / Path(source_file).with_suffix(".png") - - -def generate_one(source_file: str, force: bool) -> tuple[str, str]: - output = preview_path(source_file) - if output.exists() and not force: - return source_file, "cached" - - url = ASSET_BASE_URL + quote(source_file, safe="/") - request = Request(url, headers={"User-Agent": "SHiNE emoji preview generator"}) - with urlopen(request, timeout=30) as response: - payload = response.read() - - with Image.open(BytesIO(payload)) as source: - source.seek(0) - frame = source.convert("RGBA") - frame.thumbnail((96, 96), Image.Resampling.LANCZOS) - canvas = Image.new("RGBA", (96, 96), (0, 0, 0, 0)) - offset = ((96 - frame.width) // 2, (96 - frame.height) // 2) - canvas.alpha_composite(frame, offset) - output.parent.mkdir(parents=True, exist_ok=True) - canvas.save(output, format="PNG", optimize=True) - return source_file, "generated" - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--force", action="store_true", help="Regenerate existing previews") - parser.add_argument("--workers", type=int, default=8) - args = parser.parse_args() - - files = catalog_files() - if not files: - print("No catalog files found", file=sys.stderr) - return 1 - - print(f"Generating {len(files)} previews into {OUTPUT_DIR}") - failures: list[tuple[str, str]] = [] - generated = cached = 0 - with ThreadPoolExecutor(max_workers=max(1, args.workers)) as executor: - futures = {executor.submit(generate_one, source_file, args.force): source_file for source_file in files} - for future in as_completed(futures): - source_file = futures[future] - try: - _, status = future.result() - except Exception as error: # noqa: BLE001 - report all asset failures together - failures.append((source_file, str(error))) - continue - generated += status == "generated" - cached += status == "cached" - - print(f"Generated: {generated}; cached: {cached}; failed: {len(failures)}") - for source_file, error in failures: - print(f"FAILED {source_file}: {error}", file=sys.stderr) - return 1 if failures else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 27f95d22..d8034140 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -56,9 +56,10 @@ import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileEditView from './pages/profile-edit-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; +import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; -import * as toolsSettingsView from './pages/tools-settings-view.js'; +import * as arweaveUploadsView from './pages/arweave-uploads-view.js'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; @@ -117,9 +118,10 @@ const routes = { 'profile-edit-view': profileEditView, 'wallet-view': walletView, 'settings-view': settingsView, + 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, - 'tools-settings-view': toolsSettingsView, + 'arweave-uploads-view': arweaveUploadsView, 'remote-addblock-session-view': remoteAddBlockSessionView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, @@ -1203,7 +1205,7 @@ async function init() { } if (refBaseKey) { if (messageType === 3) { - markOutgoingReadByBaseKey(refBaseKey); + markOutgoingReadByBaseKey(refBaseKey, Number(parsed.timeMs || 0)); } else { if (markIncomingReadByBaseKey(refBaseKey)) { shouldRefreshToolbarUnread = true; diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js new file mode 100644 index 00000000..244601c2 --- /dev/null +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -0,0 +1,1075 @@ +import { + buildArweaveDataUrl, + getArweaveUploadPrice, + loadArweaveFileMetadata, + sha256HexFromArrayBuffer, + uploadArweaveFile, + validateArweaveTxId, +} from '../services/arweave-file-service.js'; +import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js'; +import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js'; +import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js'; +import { + estimateTurboUploadPrice, + formatTurboCredits, + getFreeTurboUploadBytesLimit, + getTurboBalanceForStoredSolanaKey, + topUpTurboWithSolana, + uploadFileWithTurbo, +} from '../services/turbo-upload-service.js'; + +const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1'; +const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:'; +const LAST_TURBO_KEY_SOURCE_PREFIX = 'shine-ui-turbo-last-key-source-v1:'; +const MAX_HISTORY_ITEMS = 100; +const RECENT_UPLOAD_MS = 20 * 60 * 1000; + +function normalizeHistoryItem(input = {}) { + const item = normalizeAttachment(input); + const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0; + const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment'; + return { + ...item, + purpose, + pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs, + placedInShineAtMs, + }; +} + +function hasAttachmentPreview(item) { + return !!String(item?.preview?.ar || '').trim(); +} + +function isVideoAttachmentName(name) { + return getAttachmentKind({ name }) === 'video'; +} + +function buildHistoryPreviewMarkup(item, gateway) { + if (!hasAttachmentPreview(item)) return ''; + const previewTxId = String(item.preview.ar || '').trim(); + const previewUrl = buildArweaveDataUrl({ gateway, txId: previewTxId }); + return ` +
+ Превью ${escapeHtml(item.name)} +
Есть превью
+
+ `; +} + +export function readArweaveAttachmentHistory(login) { + try { + const raw = sessionStorage.getItem(HISTORY_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const key = String(login || '').trim().toLowerCase(); + const rows = Array.isArray(parsed?.[key]) ? parsed[key] : []; + return rows.map((item) => normalizeHistoryItem(item)).filter(Boolean); + } catch { + return []; + } +} + +export function saveArweaveAttachmentHistory(login, rows) { + try { + const key = String(login || '').trim().toLowerCase(); + const raw = sessionStorage.getItem(HISTORY_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + parsed[key] = (Array.isArray(rows) ? rows : []) + .map((item) => normalizeHistoryItem(item)) + .slice(0, MAX_HISTORY_ITEMS); + sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed)); + } catch { + // ignore sessionStorage errors + } +} + +export function clearArweaveAttachmentHistory(login) { + saveArweaveAttachmentHistory(login, []); +} + +export function addArweaveAttachmentToHistory(login, attachment, { pendingPlacement = undefined, markPlaced = false } = {}) { + const item = normalizeHistoryItem(attachment); + const rows = readArweaveAttachmentHistory(login); + const existing = rows.find((row) => row.ar === item.ar) || {}; + const placedInShineAtMs = markPlaced + ? Date.now() + : Number(existing.placedInShineAtMs || item.placedInShineAtMs || 0) || 0; + const nextPendingPlacement = pendingPlacement === undefined + ? (existing.pendingPlacement === true && !placedInShineAtMs) + : (!!pendingPlacement && !placedInShineAtMs); + const nextItem = { + ...item, + pendingPlacement: nextPendingPlacement, + placedInShineAtMs, + }; + const nextRows = rows.filter((row) => row.ar !== item.ar); + nextRows.unshift(nextItem); + saveArweaveAttachmentHistory(login, nextRows); + return nextItem; +} + +export function markArweaveAttachmentPlaced(login, attachment) { + const item = normalizeHistoryItem(attachment); + const rows = readArweaveAttachmentHistory(login); + const existing = rows.find((row) => row.ar === item.ar) || {}; + const nextItem = { + ...item, + uploadedAtMs: item.uploadedAtMs || existing.uploadedAtMs || Date.now(), + pendingPlacement: false, + placedInShineAtMs: Number(existing.placedInShineAtMs || 0) || Date.now(), + }; + const nextRows = rows.filter((row) => row.ar !== item.ar); + nextRows.unshift(nextItem); + saveArweaveAttachmentHistory(login, nextRows); + return nextItem; +} + +export function formatArweaveHistoryTime(ms) { + const value = Number(ms || 0); + if (!Number.isFinite(value) || value <= 0) return '—'; + const diffMs = Date.now() - value; + if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) { + const minutes = Math.max(1, Math.floor(diffMs / 60000)); + return `${minutes} мин назад`; + } + return new Date(value).toLocaleString('ru-RU'); +} + +export function formatArweaveHistoryDateTime(ms) { + const value = Number(ms || 0); + if (!Number.isFinite(value) || value <= 0) return '—'; + return new Date(value).toLocaleString('ru-RU'); +} + +export async function getArweaveAttachmentAvailability({ gateway, attachment } = {}) { + const item = normalizeAttachment(attachment); + const url = buildArweaveDataUrl({ gateway, txId: item.ar }); + try { + const response = await fetch(url, { method: 'HEAD', cache: 'no-store' }); + if (response.ok) return { status: 'available', label: '✓ Доступен', url }; + } catch { + // gateway может временно не отвечать или блокировать HEAD; для UI это всё равно недоступно. + } + const ageMs = Date.now() - Number(item.uploadedAtMs || 0); + if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < RECENT_UPLOAD_MS) { + return { status: 'pending', label: '… Ещё обновляется', url }; + } + return { status: 'unavailable', label: '✕ Недоступен', url }; +} + +function setText(node, text) { + if (node) node.textContent = String(text || ''); +} + +function shortAddress(value) { + const raw = String(value || '').trim(); + if (raw.length <= 16) return raw; + return `${raw.slice(0, 8)}...${raw.slice(-6)}`; +} + +function lastWalletStorageKey(login) { + return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`; +} + +function lastTurboKeySourceStorageKey(login) { + return `${LAST_TURBO_KEY_SOURCE_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`; +} + +function readLastWalletId(login) { + try { + return sessionStorage.getItem(lastWalletStorageKey(login)) || ''; + } catch { + return ''; + } +} + +function writeLastWalletId(login, walletId) { + try { + const clean = String(walletId || '').trim(); + if (clean) sessionStorage.setItem(lastWalletStorageKey(login), clean); + } catch { + // Если sessionStorage недоступен, выбор сохраняется хотя бы в текущем окне менеджера. + } +} + +function readLastTurboKeySource(login, fallback = 'client') { + try { + return sessionStorage.getItem(lastTurboKeySourceStorageKey(login)) || String(fallback || 'client'); + } catch { + return String(fallback || 'client'); + } +} + +function writeLastTurboKeySource(login, keySource) { + try { + const clean = String(keySource || '').trim() || 'client'; + sessionStorage.setItem(lastTurboKeySourceStorageKey(login), clean); + } catch { + // ignore sessionStorage errors + } +} + +async function resizeImageFileToAvatar(file) { + const source = file instanceof File ? file : null; + if (!source) throw new Error('Выберите изображение.'); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Не удалось прочитать изображение.')); + reader.readAsDataURL(source); + }); + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Не удалось открыть изображение.')); + img.src = dataUrl; + }); + const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height); + if (!side || side <= 0) throw new Error('Некорректный размер изображения.'); + const canvas = document.createElement('canvas'); + canvas.width = 512; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas недоступен.'); + const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2); + const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2); + ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)); + if (!blob) throw new Error('Не удалось сжать аватар.'); + return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() }); +} + +async function createVideoPreviewFile(file) { + const source = file instanceof File ? file : null; + if (!source) throw new Error('Выберите видеофайл.'); + const blobUrl = URL.createObjectURL(source); + try { + const video = await new Promise((resolve, reject) => { + const node = document.createElement('video'); + node.preload = 'metadata'; + node.muted = true; + node.playsInline = true; + node.onloadeddata = () => resolve(node); + node.onerror = () => reject(new Error('Не удалось открыть видео для создания превью.')); + node.src = blobUrl; + }); + const width = Number(video.videoWidth || 0); + const height = Number(video.videoHeight || 0); + if (!width || !height) { + throw new Error('Не удалось определить размер видео для превью.'); + } + const maxSide = 640; + const scale = Math.min(1, maxSide / Math.max(width, height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width * scale)); + canvas.height = Math.max(1, Math.round(height * scale)); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas недоступен для создания превью.'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.86)); + if (!blob) throw new Error('Не удалось собрать preview image.'); + const baseName = String(source.name || 'video').replace(/\.[^.]+$/u, '') || 'video'; + return new File([blob], `${baseName}-preview.jpg`, { + type: 'image/jpeg', + lastModified: Date.now(), + }); + } finally { + URL.revokeObjectURL(blobUrl); + } +} + +export function openArweaveAttachmentManager({ + login, + storagePwd, + gateway, + onSelect, + selectedTxIds = [], + historyOnly = false, + mode = 'attachment', + historyPurpose = '', + uploadTransport = 'arweave', + turboKeySource = 'client', +} = {}) { + const cleanLogin = String(login || '').trim(); + const cleanStoragePwd = String(storagePwd || '').trim(); + const cleanGateway = String(gateway || 'https://arweave.net').trim(); + if (!cleanLogin || !cleanStoragePwd) return Promise.reject(new Error('Нет активной сессии.')); + + const root = document.createElement('div'); + root.className = 'ar-attachment-manager-root'; + document.body.append(root); + + let closed = false; + let wallets = []; + let turboKeyChoices = []; + let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key'; + let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource); + let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave'; + let selectedFile = null; + let selectedSha256 = ''; + let selectedPreviewEnabled = false; + let selectedPreviewFile = null; + let selectedPreviewSha256 = ''; + let selectedPreviewPriceInfo = null; + let priceInfo = null; + let balanceInfo = null; + let autoOpenedFileDialog = false; + const isAvatarMode = String(mode || '') === 'avatar'; + const historyPurposeMode = String(historyPurpose || '').trim(); + const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment'; + const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean)); + + function isTurboUpload() { + return selectedUploadTransport === 'turbo'; + } + + function selectedWallet() { + return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null; + } + + function close(resolve, result = null) { + if (closed) return; + closed = true; + root.remove(); + resolve(result); + } + + function finish(resolve, attachment, { pendingPlacement = undefined } = {}) { + const item = addArweaveAttachmentToHistory(cleanLogin, attachment, { + pendingPlacement, + markPlaced: false, + }); + if (!pendingPlacement && typeof onSelect === 'function') onSelect(item); + close(resolve, item); + } + + return new Promise((resolve) => { + const bindBackdrop = () => { + const modal = root.querySelector('[data-ar-attach-modal="true"]'); + modal?.addEventListener('click', (event) => { + if (event.target === modal) close(resolve, null); + }); + }; + + const renderWalletOptions = () => wallets.map((wallet) => ( + `` + )).join(''); + + const loadWallets = async (errorEl = null) => { + try { + wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd }); + if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) { + selectedWalletId = String(wallets[0]?.id || 'derived-client-key'); + } + writeLastWalletId(cleanLogin, selectedWalletId); + } catch (error) { + setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.'); + } + }; + + const loadTurboKeyChoices = async (errorEl = null) => { + try { + turboKeyChoices = await getStoredSolanaWalletChoices({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + includeRoot: true, + }); + turboKeyChoices.sort((a, b) => { + if (a?.keySource === b?.keySource) return 0; + if (a?.keySource === 'client') return -1; + if (b?.keySource === 'client') return 1; + return 0; + }); + if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) { + selectedTurboKeySource = String( + turboKeyChoices.find((item) => item.keySource === 'client')?.keySource + || turboKeyChoices[0]?.keySource + || 'client' + ); + } + writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); + } catch (error) { + setText(errorEl, error?.message || 'Не удалось загрузить Solana-ключи для Turbo.'); + } + }; + + const renderTurboMeta = (mainMetaEl, previewMetaEl) => { + if (!mainMetaEl || !previewMetaEl) return; + const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim(); + const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—'; + const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—'; + const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—'; + const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—'; + const freeHint = selectedFile + ? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance') + : `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`; + const freeBadge = priceInfo?.isFree + ? ' (пока файлы до 100 KB бесплатно)' + : ''; + mainMetaEl.innerHTML = ` +
Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}
+
Turbo balance: ${escapeHtml(turboCredits)}
+
SOL на этом ключе: ${solBalance}
+
Размер файла: ${escapeHtml(fileSize)}
+
SHA-256: ${escapeHtml(selectedSha256 || '—')}
+
Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}
+
До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}
+ `; + previewMetaEl.innerHTML = ''; + if (selectedPreviewEnabled && selectedPreviewFile) { + previewMetaEl.innerHTML = ` +
Файл превью
+
Имя: ${escapeHtml(selectedPreviewFile.name)}
+
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
+
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
+
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}
+ `; + } + }; + + const refreshTurboBalance = async (mainMetaEl, previewMetaEl, errorEl) => { + try { + balanceInfo = await getTurboBalanceForStoredSolanaKey({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + }); + renderTurboMeta(mainMetaEl, previewMetaEl); + if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) { + setText(errorEl, ''); + } + } catch (error) { + setText(errorEl, error?.message || 'Не удалось получить Turbo balance.'); + } + }; + + const promptTurboTopUp = async (mainMetaEl, previewMetaEl, errorEl) => { + const value = window.prompt('Сколько SOL перевести в Turbo?', '0.01'); + if (value == null) return; + setText(errorEl, 'Пополняем Turbo...'); + try { + const result = await topUpTurboWithSolana({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + amountSol: value, + }); + await refreshTurboBalance(mainMetaEl, previewMetaEl, errorEl); + setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`); + } catch (error) { + setText(errorEl, error?.message || 'Не удалось пополнить Turbo.'); + } + }; + + const resetPreviewState = () => { + selectedPreviewEnabled = false; + selectedPreviewFile = null; + selectedPreviewSha256 = ''; + selectedPreviewPriceInfo = null; + }; + + const isPreviewEligibleForCurrentFile = () => ( + !isAvatarMode + && selectedFile instanceof File + && isVideoAttachmentName(selectedFile.name) + ); + + const combinedUploadSize = () => { + return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0); + }; + + const renderArweaveMeta = (mainMetaEl, previewMetaEl) => { + if (!mainMetaEl || !previewMetaEl) return; + mainMetaEl.innerHTML = ` + ${isAvatarMode ? '' : `
Имя: ${escapeHtml(selectedFile?.name || 'file')}
`} +
Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}
+
SHA-256: ${escapeHtml(selectedSha256 || '—')}
+
Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+
Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+ `; + previewMetaEl.innerHTML = ''; + if (selectedPreviewEnabled && selectedPreviewFile) { + previewMetaEl.innerHTML = ` +
Файл превью
+
Имя: ${escapeHtml(selectedPreviewFile.name)}
+
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
+
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
+
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}
+ `; + } + }; + + const recalculateArweaveState = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { + uploadBtn.disabled = true; + priceInfo = null; + balanceInfo = null; + selectedPreviewPriceInfo = null; + setText(errorEl, ''); + if (!selectedFile || !selectedSha256) { + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); + return; + } + const wallet = selectedWallet(); + if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.'); + const mainPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size }); + let totalWinston = BigInt(mainPrice.winston); + if (selectedPreviewEnabled && selectedPreviewFile) { + const previewPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedPreviewFile.size }); + selectedPreviewPriceInfo = previewPrice; + totalWinston += BigInt(previewPrice.winston); + } + balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address }); + priceInfo = { + ar: Number(totalWinston) / 1e12, + winston: totalWinston.toString(), + }; + renderArweaveMeta(mainMetaEl, previewMetaEl); + const hasFunds = BigInt(balanceInfo.winston) >= totalWinston; + if (!hasFunds) { + setText(errorEl, 'Недостаточно AR на выбранном кошельке.'); + return; + } + uploadBtn.disabled = false; + }; + + const refreshTurboStateForCurrentFile = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { + uploadBtn.disabled = true; + priceInfo = null; + balanceInfo = null; + selectedPreviewPriceInfo = null; + setText(errorEl, ''); + if (!selectedFile || !selectedSha256) { + renderTurboMeta(mainMetaEl, previewMetaEl); + return; + } + try { + const mainPrice = await estimateTurboUploadPrice({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + byteLength: selectedFile.size, + }); + let totalWinc = BigInt(mainPrice.winc); + if (selectedPreviewEnabled && selectedPreviewFile) { + const previewPrice = await estimateTurboUploadPrice({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + byteLength: selectedPreviewFile.size, + }); + selectedPreviewPriceInfo = previewPrice; + totalWinc += BigInt(previewPrice.winc); + } + priceInfo = { + ...mainPrice, + byteLength: combinedUploadSize(), + isFree: combinedUploadSize() <= getFreeTurboUploadBytesLimit(), + winc: totalWinc.toString(), + credits: Number(totalWinc) / 1_000_000_000_000, + }; + balanceInfo = await getTurboBalanceForStoredSolanaKey({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + }); + renderTurboMeta(mainMetaEl, previewMetaEl); + if (!priceInfo.isFree && BigInt(balanceInfo.effectiveWinc) < BigInt(priceInfo.winc)) { + setText(errorEl, 'Turbo balance недостаточен. Нажмите «Пополнить Turbo».'); + return; + } + uploadBtn.disabled = false; + } catch (error) { + setText(errorEl, error?.message || 'Не удалось обновить цену Turbo.'); + } + }; + + const showUpload = async () => { + const turboMode = isTurboUpload(); + root.innerHTML = ` + + `; + bindBackdrop(); + const walletEl = root.querySelector('#ar-attach-wallet'); + const turboKeyEl = root.querySelector('#turbo-key-source'); + const fileEl = root.querySelector('#ar-attach-file'); + const mainMetaEl = root.querySelector('[data-meta-main="true"]'); + const previewMetaEl = root.querySelector('[data-meta-preview="true"]'); + const errorEl = root.querySelector('[data-error="true"]'); + const uploadBtn = root.querySelector('[data-action="upload"]'); + const previewOptionEl = root.querySelector('[data-preview-option="true"]'); + const previewToggleEl = root.querySelector('[data-preview-toggle="true"]'); + + root.querySelector('[data-action="switch-arweave"]')?.addEventListener('click', async () => { + if (selectedUploadTransport === 'arweave') return; + selectedUploadTransport = 'arweave'; + selectedFile = null; + selectedSha256 = ''; + resetPreviewState(); + priceInfo = null; + balanceInfo = null; + await showUpload(); + }); + root.querySelector('[data-action="switch-turbo"]')?.addEventListener('click', async () => { + if (selectedUploadTransport === 'turbo') return; + selectedUploadTransport = 'turbo'; + selectedFile = null; + selectedSha256 = ''; + resetPreviewState(); + priceInfo = null; + balanceInfo = null; + await showUpload(); + }); + root.querySelector('[data-action="history-top"]')?.addEventListener('click', showHistory); + root.querySelector('[data-action="existing-top"]')?.addEventListener('click', showExisting); + + if (turboMode) { + await loadTurboKeyChoices(errorEl); + if (turboKeyEl) { + turboKeyEl.innerHTML = turboKeyChoices.map((item) => ( + `` + )).join(''); + turboKeyEl.value = selectedTurboKeySource; + turboKeyEl.addEventListener('change', async () => { + selectedTurboKeySource = String(turboKeyEl.value || 'client'); + writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + }); + } + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } else { + await loadWallets(errorEl); + if (walletEl) { + walletEl.innerHTML = renderWalletOptions(); + walletEl.value = selectedWalletId; + walletEl.addEventListener('change', () => { + selectedWalletId = String(walletEl.value || ''); + writeLastWalletId(cleanLogin, selectedWalletId); + uploadBtn.disabled = true; + selectedFile = null; + selectedSha256 = ''; + resetPreviewState(); + priceInfo = null; + balanceInfo = null; + if (fileEl) fileEl.value = ''; + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); + }); + } + } + + root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); + root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => { + await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl); + if (turboMode && selectedFile && selectedSha256) { + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } + }); + if (!turboMode) { + root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet); + } + + fileEl?.addEventListener('change', async () => { + selectedFile = fileEl.files?.[0] || null; + selectedSha256 = ''; + resetPreviewState(); + priceInfo = null; + balanceInfo = null; + uploadBtn.disabled = true; + setText(errorEl, ''); + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); + if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false; + if (previewOptionEl) previewOptionEl.hidden = true; + if (!selectedFile) return; + + try { + if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) { + throw new Error('Для аватара можно выбрать только изображение.'); + } + if (isAvatarMode) { + selectedFile = await resizeImageFileToAvatar(selectedFile); + } + const buffer = await selectedFile.arrayBuffer(); + selectedSha256 = await sha256HexFromArrayBuffer(buffer); + if (previewOptionEl && isPreviewEligibleForCurrentFile()) { + previewOptionEl.hidden = false; + } + if (turboMode) { + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } else { + await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } + } catch (error) { + setText(errorEl, error?.message || 'Не удалось подготовить файл.'); + } + }); + + previewToggleEl?.addEventListener('change', async () => { + if (!(previewToggleEl instanceof HTMLInputElement)) return; + if (!selectedFile || !isPreviewEligibleForCurrentFile()) { + previewToggleEl.checked = false; + return; + } + uploadBtn.disabled = true; + selectedPreviewEnabled = previewToggleEl.checked; + selectedPreviewFile = null; + selectedPreviewSha256 = ''; + setText(errorEl, ''); + try { + if (selectedPreviewEnabled) { + selectedPreviewFile = await createVideoPreviewFile(selectedFile); + selectedPreviewSha256 = await sha256HexFromArrayBuffer(await selectedPreviewFile.arrayBuffer()); + } + if (turboMode) { + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } else { + await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); + } + } catch (error) { + resetPreviewState(); + previewToggleEl.checked = false; + setText(errorEl, error?.message || 'Не удалось подготовить превью.'); + } + }); + + uploadBtn?.addEventListener('click', async () => { + if (!selectedFile || !selectedSha256) { + setText(errorEl, 'Выберите файл.'); + return; + } + if (!turboMode) { + const wallet = selectedWallet(); + if (!wallet?.jwk) { + setText(errorEl, 'Выберите Arweave-кошелёк.'); + return; + } + } + uploadBtn.disabled = true; + setText(errorEl, turboMode ? 'Загружаем файл через Turbo...' : 'Загружаем файл в Arweave...'); + try { + let previewUpload = null; + if (selectedPreviewEnabled && selectedPreviewFile && selectedPreviewSha256) { + previewUpload = turboMode + ? await uploadFileWithTurbo({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + file: selectedPreviewFile, + shineType: 'attachment-preview', + tags: [ + { name: 'SHiNE-Login', value: cleanLogin }, + { name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' }, + ], + }) + : await uploadArweaveFile({ + gateway: cleanGateway, + jwk: selectedWallet()?.jwk, + file: selectedPreviewFile, + shineType: 'attachment-preview', + tags: [ + { name: 'SHiNE-Login', value: cleanLogin }, + { name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' }, + ], + }); + } + const uploaded = turboMode + ? await uploadFileWithTurbo({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + keySource: selectedTurboKeySource, + file: selectedFile, + shineType: isAvatarMode ? 'avatar' : 'attachment', + tags: [ + { name: 'SHiNE-Login', value: cleanLogin }, + { name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') }, + ], + }) + : await uploadArweaveFile({ + gateway: cleanGateway, + jwk: selectedWallet()?.jwk, + file: selectedFile, + shineType: isAvatarMode ? 'avatar' : 'attachment', + tags: [ + { name: 'SHiNE-Login', value: cleanLogin }, + { name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') }, + ], + }); + finish(resolve, { + name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'), + size: selectedFile.size, + sha256: selectedSha256, + ar: uploaded.id, + preview: previewUpload?.id && selectedPreviewSha256 + ? { + ar: previewUpload.id, + sha256: selectedPreviewSha256, + } + : undefined, + uploadedAtMs: Date.now(), + purpose: isAvatarMode ? 'avatar' : 'attachment', + }, { pendingPlacement: historyOnly && !isAvatarMode }); + } catch (error) { + uploadBtn.disabled = false; + setText(errorEl, error?.message || (turboMode ? 'Не удалось загрузить файл через Turbo.' : 'Не удалось загрузить файл в Arweave.')); + } + }); + + if (!autoOpenedFileDialog) { + autoOpenedFileDialog = true; + window.setTimeout(() => fileEl?.click(), 0); + } + }; + + const showExisting = () => { + root.innerHTML = ` + + `; + bindBackdrop(); + const txEl = root.querySelector('#ar-existing-txid'); + const nameEl = root.querySelector('#ar-existing-name'); + const previewWrapEl = root.querySelector('[data-existing-preview-wrap="true"]'); + const previewToggleEl = root.querySelector('[data-existing-preview-toggle="true"]'); + const previewFieldsEl = root.querySelector('[data-existing-preview-fields="true"]'); + const previewTxEl = root.querySelector('#ar-existing-preview-txid'); + const metaEl = root.querySelector('[data-meta="true"]'); + const errorEl = root.querySelector('[data-error="true"]'); + const checkBtn = root.querySelector('[data-action="check"]'); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + nameEl?.addEventListener('input', () => { + const isVideo = isVideoAttachmentName(String(nameEl.value || '').trim()); + if (previewWrapEl) previewWrapEl.hidden = !isVideo; + if (!isVideo) { + if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false; + if (previewFieldsEl) previewFieldsEl.hidden = true; + if (previewTxEl) previewTxEl.value = ''; + } + }); + previewToggleEl?.addEventListener('change', () => { + if (previewFieldsEl && previewToggleEl instanceof HTMLInputElement) { + previewFieldsEl.hidden = !previewToggleEl.checked; + } + }); + checkBtn?.addEventListener('click', async () => { + const txId = String(txEl?.value || '').trim(); + const name = String(nameEl?.value || '').trim() || 'arweave-file'; + const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked; + const previewTxId = String(previewTxEl?.value || '').trim(); + if (!validateArweaveTxId(txId)) { + setText(errorEl, 'Некорректный Transaction ID Arweave.'); + return; + } + if (wantsPreview && !validateArweaveTxId(previewTxId)) { + setText(errorEl, 'Некорректный Transaction ID превью.'); + return; + } + checkBtn.disabled = true; + setText(errorEl, 'Скачиваем файл и считаем SHA-256...'); + setText(metaEl, ''); + try { + const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId }); + let preview = null; + if (wantsPreview) { + const previewMeta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId: previewTxId }); + preview = { + ar: previewTxId, + sha256: previewMeta.sha256Hex, + }; + } + const item = normalizeAttachment({ + name, + size: meta.sizeBytes, + sha256: meta.sha256Hex, + ar: txId, + preview, + uploadedAtMs: Date.now(), + }); + metaEl.innerHTML = ` +
Размер: ${escapeHtml(formatBytes(item.size))}
+
SHA-256: ${escapeHtml(item.sha256)}
+ ${item.preview ? '
Превью будет добавлено вместе с файлом.
' : ''} + `; + setText(errorEl, ''); + const addBtn = document.createElement('button'); + addBtn.className = 'primary-btn'; + addBtn.type = 'button'; + addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение'; + addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode })); + metaEl.append(addBtn); + } catch (error) { + setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.'); + checkBtn.disabled = false; + } + }); + window.setTimeout(() => txEl?.focus(), 0); + }; + + const showHistory = () => { + const rows = readArweaveAttachmentHistory(cleanLogin) + .filter((item) => purposeFilter === 'avatar' ? item.purpose === 'avatar' : item.purpose !== 'avatar') + .slice() + .reverse(); + root.innerHTML = ` + + `; + bindBackdrop(); + const scrollEl = root.querySelector('[data-history-scroll="true"]'); + window.setTimeout(() => { + if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight; + }, 0); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + root.querySelectorAll('[data-pick]').forEach((button) => { + button.addEventListener('click', () => { + const index = Number(button.getAttribute('data-pick')); + const item = rows[index]; + if (item) finish(resolve, item); + }); + }); + rows.forEach((item, index) => { + const statusEl = root.querySelector(`[data-status="${index}"]`); + void getArweaveAttachmentAvailability({ gateway: cleanGateway, attachment: item }) + .then((result) => { + if (!statusEl) return; + statusEl.className = `ar-attachment-status ar-attachment-status--${result.status}`; + statusEl.textContent = result.label; + }) + .catch(() => { + if (!statusEl) return; + statusEl.className = 'ar-attachment-status ar-attachment-status--unavailable'; + statusEl.textContent = 'Недоступен'; + }); + }); + }; + + const showAddWallet = () => { + root.innerHTML = ` + + `; + bindBackdrop(); + const labelEl = root.querySelector('#ar-wallet-label'); + const secretEl = root.querySelector('#ar-wallet-secret'); + const errorEl = root.querySelector('[data-error="true"]'); + const saveBtn = root.querySelector('[data-action="save"]'); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + saveBtn?.addEventListener('click', async () => { + saveBtn.disabled = true; + setText(errorEl, ''); + try { + const wallet = await addArweaveWalletSecret({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + label: String(labelEl?.value || '').trim(), + secret: String(secretEl?.value || '').trim(), + }); + selectedWalletId = wallet.id; + writeLastWalletId(cleanLogin, selectedWalletId); + await showUpload(); + } catch (error) { + saveBtn.disabled = false; + setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.'); + } + }); + }; + + void showUpload(); + }); +} diff --git a/shine-UI/js/components/avatar-wizard.js b/shine-UI/js/components/avatar-wizard.js index ae2d38a9..335afd86 100644 --- a/shine-UI/js/components/avatar-wizard.js +++ b/shine-UI/js/components/avatar-wizard.js @@ -1,56 +1,7 @@ -import { authService } from '../state.js'; -import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js'; -import { - buildArweaveDataUrl, - getArweaveUploadPrice, - prepareAvatarImageFile, - sha256HexFromArrayBuffer, - uploadArweaveFile, - validateArweaveTxId, - validateSha256Hex, - validateAvatarSourceFile, -} from '../services/arweave-file-service.js'; -import { bytesToBase64 } from '../services/crypto-utils.js'; +import { openArweaveAttachmentManager } from './arweave-attachment-manager.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js'; -const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024; - -function escapeHtml(text) { - return String(text || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function formatBytes(bytes) { - const value = Number(bytes || 0); - if (!Number.isFinite(value) || value <= 0) return '0 B'; - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${(value / (1024 * 1024)).toFixed(2)} MB`; -} - -function formatAr(ar) { - const n = Number(ar); - if (!Number.isFinite(n)) return '0'; - return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 }); -} - -function clearJwk(ctx) { - if (!ctx?.jwk || typeof ctx.jwk !== 'object') return; - Object.keys(ctx.jwk).forEach((key) => { - ctx.jwk[key] = ''; - }); - ctx.jwk = null; -} - -function setNodeText(node, text) { - if (node) node.textContent = String(text || ''); -} - -export function openAvatarWizard({ +export async function openAvatarWizard({ login, storagePwd, gateway, @@ -58,568 +9,37 @@ export function openAvatarWizard({ onAvatarSaved, onStatus, } = {}) { - const root = document.getElementById('modal-root'); - if (!root) return Promise.resolve(false); - const cleanLogin = String(login || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim(); const cleanGateway = String(gateway || '').trim(); if (!cleanLogin || !cleanStoragePwd) { - return Promise.reject(new Error('Нет активной сессии.')); + throw new Error('Нет активной сессии.'); } - let closed = false; - let lastPreviewUrl = ''; - let walletCtx = null; - let balanceInfo = null; - let optimized = null; - let priceInfo = null; - let uploadedTxId = ''; - let uploadedSha256Hex = ''; - let uploadedInfoText = ''; - let freeQuotaInfo = null; - - function revokePreviewUrl() { - if (!lastPreviewUrl) return; - URL.revokeObjectURL(lastPreviewUrl); - lastPreviewUrl = ''; - } - - function close(result = false, resolve) { - if (closed) return; - closed = true; - revokePreviewUrl(); - clearJwk(walletCtx); - walletCtx = null; - root.innerHTML = ''; - resolve(result); - } - - async function ensurePreviewImage(url, imageEl) { - return new Promise((resolve, reject) => { - const img = imageEl; - img.onload = () => resolve(true); - img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID')); - img.src = url; - }); - } - - return new Promise((resolve) => { - const showStepChoice = () => { - if (closed) return; - root.innerHTML = ` - - `; - const modal = root.querySelector('[data-avatar-wizard-modal="true"]'); - modal?.addEventListener('click', (event) => { - if (event.target === modal) close(false, resolve); - }); - root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve)); - root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput); - root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); }); - root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); }); - }; - - const showStepExistingInput = () => { - if (closed) return; - root.innerHTML = ` - - `; - const modal = root.querySelector('[data-avatar-wizard-modal="true"]'); - const inputEl = root.querySelector('#avatar-existing-txid'); - const errorEl = root.querySelector('[data-error="true"]'); - modal?.addEventListener('click', (event) => { - if (event.target === modal) close(false, resolve); - }); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - root.querySelector('[data-action="next"]')?.addEventListener('click', () => { - const txId = String(inputEl?.value || '').trim(); - if (!validateArweaveTxId(txId)) { - setNodeText(errorEl, 'Некорректный Transaction ID Arweave.'); - return; - } - setNodeText(errorEl, ''); - void showStepExistingPreview(txId); - }); - window.setTimeout(() => inputEl?.focus(), 0); - }; - - const showStepExistingPreview = async (txId) => { - if (closed) return; - const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId }); - let existingSha256Hex = ''; - root.innerHTML = ` - - `; - const modal = root.querySelector('[data-avatar-wizard-modal="true"]'); - const imageEl = root.querySelector('[data-preview-image="true"]'); - const errorEl = root.querySelector('[data-error="true"]'); - const saveBtn = root.querySelector('[data-action="save"]'); - modal?.addEventListener('click', (event) => { - if (event.target === modal) close(false, resolve); - }); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput); - if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true; - - try { - await ensurePreviewImage(previewUrl, imageEl); - const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' }); - if (!response.ok) throw new Error('BAD_AVATAR_FETCH'); - existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer()); - if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH'); - if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false; - } catch (error) { - setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.'); - if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true; - } - - saveBtn?.addEventListener('click', async () => { - try { - await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex); - if (typeof onAvatarSaved === 'function') await onAvatarSaved(); - close(true, resolve); - } catch { - setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.'); - } - }); - }; - - const showStepZeroBalance = () => { - if (closed) return; - root.innerHTML = ` - - `; - root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => { - close(false, resolve); - if (typeof navigate === 'function') navigate('wallet-view'); - }); - root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve)); - }; - - const showStepUploadForm = () => { - if (closed) return; - root.innerHTML = ` - - `; - - const modal = root.querySelector('[data-avatar-wizard-modal="true"]'); - const fileInput = root.querySelector('#avatar-file-input'); - const errorEl = root.querySelector('[data-error="true"]'); - const metaEl = root.querySelector('[data-meta="true"]'); - const previewWrap = root.querySelector('[data-preview-wrap="true"]'); - const previewImage = root.querySelector('[data-preview-image="true"]'); - const uploadBtn = root.querySelector('[data-action="upload"]'); - - let selectedFile = null; - optimized = null; - priceInfo = null; - uploadedSha256Hex = ''; - - modal?.addEventListener('click', (event) => { - if (event.target === modal) close(false, resolve); - }); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - - fileInput?.addEventListener('change', async () => { - setNodeText(errorEl, ''); - setNodeText(metaEl, ''); - uploadBtn.disabled = true; - revokePreviewUrl(); - - selectedFile = fileInput.files?.[0] || null; - if (!selectedFile) { - setNodeText(errorEl, 'Выберите файл изображения.'); - return; - } - - try { - validateAvatarSourceFile(selectedFile); - optimized = await prepareAvatarImageFile(selectedFile); - priceInfo = await getArweaveUploadPrice({ - gateway: cleanGateway, - byteLength: optimized.file.size, - }); - - lastPreviewUrl = URL.createObjectURL(optimized.file); - previewImage.src = lastPreviewUrl; - previewWrap.hidden = false; - - const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston); - metaEl.innerHTML = ` -
Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}
-
Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}
-
Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}
-
Тип файла: ${escapeHtml(optimized.contentType)}
-
SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}
-
Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR
- `; - - if (!hasFunds) { - setNodeText(errorEl, 'Недостаточно средств для загрузки.'); - uploadBtn.disabled = true; - return; - } - uploadBtn.disabled = false; - } catch (error) { - const message = String(error?.message || ''); - if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') { - setNodeText(errorEl, message); - } else if (message.includes('цену загрузки')) { - setNodeText(errorEl, 'Не удалось получить баланс Arweave.'); - } else { - setNodeText(errorEl, 'Не удалось подготовить изображение.'); - } - } - }); - - uploadBtn?.addEventListener('click', async () => { - setNodeText(errorEl, ''); - if (!optimized?.file) { - setNodeText(errorEl, 'Выберите файл изображения.'); - return; - } - if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) { - setNodeText(errorEl, 'Недостаточно средств для загрузки.'); - return; - } - - uploadBtn.disabled = true; - try { - uploadedInfoText = ''; - const uploaded = await uploadArweaveFile({ - gateway: cleanGateway, - jwk: walletCtx?.jwk, - file: optimized.file, - tags: [ - { name: 'SHiNE-Profile-Login', value: cleanLogin }, - ], - }); - uploadedTxId = String(uploaded.id || '').trim(); - uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase(); - if (!uploadedTxId) { - throw new Error('Пустой Transaction ID'); - } - if (!validateSha256Hex(uploadedSha256Hex)) { - throw new Error('Некорректный SHA256'); - } - showStepUploaded(); - } catch { - setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.'); - uploadBtn.disabled = false; - } - }); - }; - - const showStepFreeLimitExhausted = () => { - if (closed) return; - root.innerHTML = ` - - `; - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve)); - }; - - const showStepFreeUploadForm = () => { - if (closed) return; - const remaining = Number(freeQuotaInfo?.remainingCount || 0); - const limit = Number(freeQuotaInfo?.limit || 3); - const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES); - root.innerHTML = ` - - `; - - const modal = root.querySelector('[data-avatar-wizard-modal="true"]'); - const fileInput = root.querySelector('#avatar-free-file-input'); - const errorEl = root.querySelector('[data-error="true"]'); - const metaEl = root.querySelector('[data-meta="true"]'); - const previewWrap = root.querySelector('[data-preview-wrap="true"]'); - const previewImage = root.querySelector('[data-preview-image="true"]'); - const uploadBtn = root.querySelector('[data-action="upload"]'); - - optimized = null; - uploadedSha256Hex = ''; - - modal?.addEventListener('click', (event) => { - if (event.target === modal) close(false, resolve); - }); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - - fileInput?.addEventListener('change', async () => { - setNodeText(errorEl, ''); - setNodeText(metaEl, ''); - uploadBtn.disabled = true; - revokePreviewUrl(); - - const selectedFile = fileInput.files?.[0] || null; - if (!selectedFile) { - setNodeText(errorEl, 'Выберите файл изображения.'); - return; - } - - try { - validateAvatarSourceFile(selectedFile); - optimized = await prepareAvatarImageFile(selectedFile); - if (Number(optimized?.file?.size || 0) > maxBytes) { - throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`); - } - - lastPreviewUrl = URL.createObjectURL(optimized.file); - previewImage.src = lastPreviewUrl; - previewWrap.hidden = false; - metaEl.innerHTML = ` -
Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}
-
Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}
-
Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}
-
Тип файла: ${escapeHtml(optimized.contentType)}
-
SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}
- `; - uploadBtn.disabled = false; - } catch (error) { - setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.')); - } - }); - - uploadBtn?.addEventListener('click', async () => { - setNodeText(errorEl, ''); - if (!optimized?.file) { - setNodeText(errorEl, 'Выберите файл изображения.'); - return; - } - - uploadBtn.disabled = true; - try { - const fileBytes = new Uint8Array(await optimized.file.arrayBuffer()); - const uploaded = await authService.uploadTestFreeAvatar({ - contentType: optimized.contentType, - fileBytesBase64: bytesToBase64(fileBytes), - sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(), - }); - uploadedTxId = String(uploaded.txId || '').trim(); - uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase(); - uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`; - if (!uploadedTxId) { - throw new Error('Сервер не вернул Transaction ID.'); - } - showStepUploaded(); - } catch (error) { - setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.')); - uploadBtn.disabled = false; - } - }); - }; - - const showStepFreeUpload = async () => { - if (closed) return; - root.innerHTML = ` - - `; - - const loadingEl = root.querySelector('[data-loading="true"]'); - const errorEl = root.querySelector('[data-error="true"]'); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - - try { - freeQuotaInfo = await authService.getTestFreeAvatarQuota(); - } catch (error) { - setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.'); - return; - } - - if (!freeQuotaInfo?.enabled) { - setNodeText(loadingEl, ''); - setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.'); - return; - } - if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) { - showStepFreeLimitExhausted(); - return; - } - showStepFreeUploadForm(); - }; - - const showStepUploaded = () => { - if (closed) return; - root.innerHTML = ` - - `; - - const errorEl = root.querySelector('[data-error="true"]'); - root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(uploadedTxId); - setNodeText(errorEl, ''); - } catch { - setNodeText(errorEl, 'Не удалось скопировать Transaction ID.'); - } - }); - root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => { - try { - await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex); - if (typeof onAvatarSaved === 'function') await onAvatarSaved(); - close(true, resolve); - } catch { - setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.'); - } - }); - root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve)); - }; - - const showStepUpload = async () => { - if (closed) return; - root.innerHTML = ` - - `; - - const loadingEl = root.querySelector('[data-loading="true"]'); - const errorEl = root.querySelector('[data-error="true"]'); - root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); - try { - walletCtx = await getArweaveWalletFromStoredClientKey({ - login: cleanLogin, - storagePwd: cleanStoragePwd, - onStatus: (message) => { - setNodeText(loadingEl, message); - if (typeof onStatus === 'function') onStatus(message); - }, - }); - balanceInfo = await getArweaveBalance({ - gateway: cleanGateway, - address: walletCtx.address, - }); - } catch (error) { - setNodeText(errorEl, 'Не удалось получить баланс Arweave.'); - return; - } - - if (BigInt(balanceInfo.winston) <= 0n) { - showStepZeroBalance(); - return; - } - showStepUploadForm(); - }; - - showStepChoice(); + const item = await openArweaveAttachmentManager({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + gateway: cleanGateway, + navigate, + mode: 'avatar', + historyPurpose: 'avatar', }); + + if (!item?.ar || !item?.sha256) return false; + + if (typeof onStatus === 'function') { + onStatus('Сохраняем аватар в профиль...'); + } + + await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256); + + if (typeof onAvatarSaved === 'function') { + await onAvatarSaved(item); + } + + if (typeof onStatus === 'function') { + onStatus(''); + } + + return true; } diff --git a/shine-UI/js/components/emoji-picker.js b/shine-UI/js/components/emoji-picker.js index f91f1c2c..4119ea53 100644 --- a/shine-UI/js/components/emoji-picker.js +++ b/shine-UI/js/components/emoji-picker.js @@ -1,14 +1,14 @@ import { - TELEGRAM_EMOJI_GROUPS, - getTelegramEmojiAssetUrl, - getTelegramEmojiPreviewUrl, -} from './telegram-emoji-catalog.js?v=202607151545'; + TWEMOJI_GROUPS, + getTwemojiAssetUrl, + getTwemojiPreviewUrl, +} from './twemoji-catalog.js?v=202607151545'; const RECENT_STORAGE_KEY = 'shine-ui-recent-emojis-v1'; const MAX_RECENT = 28; -const ALL_EMOJI_ITEMS = TELEGRAM_EMOJI_GROUPS.flatMap((group) => group.items); +const ALL_EMOJI_ITEMS = TWEMOJI_GROUPS.flatMap((group) => group.items); const EMOJI_ASSET_BY_CHAR = new Map(ALL_EMOJI_ITEMS.map((item) => [item.emoji, item])); -const TELEGRAM_EMOJI_MATCHER = new RegExp( +const TWEMOJI_MATCHER = new RegExp( [...EMOJI_ASSET_BY_CHAR.keys()] .sort((left, right) => right.length - left.length) .map((emoji) => emoji.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')) @@ -38,11 +38,11 @@ function saveRecent(items) { } function appendAnimatedAsset(root, item) { - const previewUrl = item.previewUrl || getTelegramEmojiPreviewUrl(item.file); - const animatedUrl = item.animatedUrl || getTelegramEmojiAssetUrl(item.file); + const previewUrl = item.previewUrl || getTwemojiPreviewUrl(item.emoji); + const animatedUrl = item.animatedUrl || getTwemojiAssetUrl(item.emoji); const hasAnimation = animatedUrl && animatedUrl !== previewUrl; const image = document.createElement('img'); - image.className = 'telegram-emoji-image'; + image.className = 'twemoji-image'; image.alt = ''; image.decoding = 'async'; image.setAttribute('aria-hidden', 'true'); @@ -134,7 +134,7 @@ function appendAnimatedAsset(root, item) { root.append(image); } -export function stopAllTelegramEmojiAnimations() { +export function stopAllTwemojiAnimations() { activeAnimation?.deactivate(); activeAnimation = null; } @@ -155,14 +155,14 @@ function createEmojiButton(item, onSelect) { return button; } -export function isTelegramAnimatedEmoji(emoji) { +export function hasTwemojiAsset(emoji) { return EMOJI_ASSET_BY_CHAR.has(emoji); } -export function createTelegramAnimatedEmoji(emoji, { className = '' } = {}) { +export function createTwemojiAsset(emoji, { className = '' } = {}) { const item = EMOJI_ASSET_BY_CHAR.get(emoji); const root = document.createElement('span'); - root.className = `telegram-emoji ${className}`.trim(); + root.className = `twemoji ${className}`.trim(); root.setAttribute('aria-label', emoji); root.setAttribute('role', 'img'); @@ -175,16 +175,16 @@ export function createTelegramAnimatedEmoji(emoji, { className = '' } = {}) { return root; } -export function appendTelegramAnimatedText(root, text, { className = '' } = {}) { +export function appendTwemojiText(root, text, { className = '' } = {}) { const source = String(text || ''); let cursor = 0; let found = false; - TELEGRAM_EMOJI_MATCHER.lastIndex = 0; - for (const match of source.matchAll(TELEGRAM_EMOJI_MATCHER)) { + TWEMOJI_MATCHER.lastIndex = 0; + for (const match of source.matchAll(TWEMOJI_MATCHER)) { const index = Number(match.index || 0); if (index > cursor) root.append(document.createTextNode(source.slice(cursor, index))); - root.append(createTelegramAnimatedEmoji(match[0], { className })); + root.append(createTwemojiAsset(match[0], { className })); cursor = index + match[0].length; found = true; } @@ -224,7 +224,7 @@ export function createEmojiPicker({ onSelect }) { categoryGrid.className = 'emoji-picker-grid'; categorySection.append(categoryTitle, categoryGrid); - let activeGroup = TELEGRAM_EMOJI_GROUPS[0]; + let activeGroup = TWEMOJI_GROUPS[0]; const tabs = new Map(); const rememberAndSelect = (emoji) => { @@ -238,13 +238,13 @@ export function createEmojiPicker({ onSelect }) { const recentItems = (loadRecent().length ? loadRecent() : DEFAULT_RECENT) .map((emoji) => EMOJI_ASSET_BY_CHAR.get(emoji)) .filter(Boolean); - stopAllTelegramEmojiAnimations(); + stopAllTwemojiAnimations(); recentGrid.replaceChildren(...recentItems.map((item) => createEmojiButton(item, rememberAndSelect))); }; const renderGroup = () => { categoryTitle.textContent = activeGroup.label; - stopAllTelegramEmojiAnimations(); + stopAllTwemojiAnimations(); categoryGrid.replaceChildren(...activeGroup.items.map((item) => createEmojiButton(item, rememberAndSelect))); tabs.forEach((tab, groupId) => { const active = groupId === activeGroup.id; @@ -253,7 +253,7 @@ export function createEmojiPicker({ onSelect }) { }); }; - TELEGRAM_EMOJI_GROUPS.forEach((group) => { + TWEMOJI_GROUPS.forEach((group) => { const tab = document.createElement('button'); tab.type = 'button'; tab.className = 'emoji-picker-tab'; diff --git a/shine-UI/js/components/header.js b/shine-UI/js/components/header.js index ead1a756..a3337646 100644 --- a/shine-UI/js/components/header.js +++ b/shine-UI/js/components/header.js @@ -6,6 +6,7 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions = left.className = 'header-left'; if (leftAction) { const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'icon-btn'; btn.textContent = leftAction.label; btn.addEventListener('click', leftAction.onClick); @@ -26,10 +27,15 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions = right.className = 'header-actions'; rightActions.forEach((action) => { const btn = document.createElement('button'); + btn.type = 'button'; btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`; - btn.textContent = action.label; if (action.title) btn.title = action.title; if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel); + if (action.iconNode instanceof Node) { + btn.append(action.iconNode); + } else { + btn.textContent = action.label; + } btn.addEventListener('click', action.onClick); right.append(btn); }); diff --git a/shine-UI/js/components/speech-input-modal.js b/shine-UI/js/components/speech-input-modal.js deleted file mode 100644 index 57075fef..00000000 --- a/shine-UI/js/components/speech-input-modal.js +++ /dev/null @@ -1,132 +0,0 @@ -import { - createMicrophoneRecorder, - isSpeechToTextConfigured, - transcribeAudioBySettings, -} from '../services/speech-tools-service.js'; -import { state } from '../state.js'; - -function formatDuration(ms) { - const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000)); - const mm = String(Math.floor(totalSec / 60)).padStart(2, '0'); - const ss = String(totalSec % 60).padStart(2, '0'); - return `${mm}:${ss}`; -} - -function showSttMissingConfigDialog(navigate) { - const goSettings = window.confirm( - 'Распознавание речи не настроено. Перейти в настройки инструментов?' - ); - if (goSettings) navigate('tools-settings-view'); -} - -export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) { - if (!isSpeechToTextConfigured(state.entrySettings)) { - showSttMissingConfigDialog(navigate); - return; - } - - const root = document.getElementById('modal-root'); - const host = document.createElement('div'); - host.innerHTML = ` - - `; - root.append(host); - - const statusEl = host.querySelector('#speech-input-status'); - const timeEl = host.querySelector('#speech-input-time'); - const levelEl = host.querySelector('#speech-level-fill'); - const errorEl = host.querySelector('#speech-input-error'); - const cancelBtn = host.querySelector('#speech-cancel'); - const sendNowBtn = host.querySelector('#speech-send-now'); - const okBtn = host.querySelector('#speech-ok'); - const recorder = createMicrophoneRecorder(); - let closed = false; - let busy = false; - - const close = () => { - if (closed) return; - closed = true; - host.remove(); - }; - - const setBusy = (flag) => { - busy = !!flag; - cancelBtn.disabled = busy; - sendNowBtn.disabled = busy; - okBtn.disabled = busy; - okBtn.textContent = busy ? 'Распознаю...' : 'OK'; - sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение'; - }; - - try { - await recorder.start(({ elapsedMs, level }) => { - if (timeEl) timeEl.textContent = formatDuration(elapsedMs); - if (levelEl) levelEl.style.width = `${Math.max(2, Math.round((Number(level) || 0) * 100))}%`; - }); - } catch (error) { - close(); - window.alert(`Не удалось получить доступ к микрофону: ${error?.message || 'unknown'}`); - return; - } - - cancelBtn.addEventListener('click', () => { - recorder.cancel(); - close(); - }); - - okBtn.addEventListener('click', async () => { - if (busy) return; - setBusy(true); - try { - const audioBlob = await recorder.stop(); - host.innerHTML = ` - - `; - const text = await transcribeAudioBySettings(audioBlob, state.entrySettings); - if (typeof onTextReady === 'function') onTextReady(text); - close(); - } catch (error) { - setBusy(false); - statusEl.textContent = 'Идёт запись...'; - errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`; - } - }); - - sendNowBtn.addEventListener('click', async () => { - if (busy) return; - setBusy(true); - try { - const audioBlob = await recorder.stop(); - close(); - if (typeof onSendQueued === 'function') onSendQueued(); - const text = await transcribeAudioBySettings(audioBlob, state.entrySettings); - if (typeof onSendText === 'function') { - await onSendText(text); - } else if (typeof onTextReady === 'function') { - onTextReady(text); - } - } catch (error) { - setBusy(false); - close(); - window.alert(`Ошибка распознавания: ${error?.message || 'unknown'}`); - } - }); -} diff --git a/shine-UI/js/components/telegram-emoji-catalog.js b/shine-UI/js/components/telegram-emoji-catalog.js deleted file mode 100644 index 69e6bc64..00000000 --- a/shine-UI/js/components/telegram-emoji-catalog.js +++ /dev/null @@ -1,51 +0,0 @@ -import { TELEGRAM_EMOJI_GROUP_ACTIVITY } from './telegram-emoji-activity.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_ANIMALS_NATURE } from './telegram-emoji-animals_nature.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_FLAGS } from './telegram-emoji-flags.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_FOOD_DRINK } from './telegram-emoji-food_drink.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_OBJECTS } from './telegram-emoji-objects.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_PEOPLE } from './telegram-emoji-people.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_SMILEYS } from './telegram-emoji-smileys.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_SYMBOLS } from './telegram-emoji-symbols.js?v=202607151545'; -import { TELEGRAM_EMOJI_GROUP_TRAVEL_PLACES } from './telegram-emoji-travel_places.js?v=202607151545'; - -const TELEGRAM_EMOJI_PREVIEW_BASE_URL = '/assets/emoji/telegram-previews/'; - -function getTelegramEmojiName(file) { - return String(file || '').split('/').pop()?.replace(/\.webp$/i, '') || ''; -} - -function withPreviewUrls(group) { - return { - ...group, - items: group.items.map((item) => ({ - ...item, - name: item.name || getTelegramEmojiName(item.file), - previewUrl: getTelegramEmojiPreviewUrl(item.file), - animatedUrl: getTelegramEmojiAssetUrl(item.file), - })), - }; -} - -export const TELEGRAM_EMOJI_GROUPS = [ - TELEGRAM_EMOJI_GROUP_SMILEYS, - TELEGRAM_EMOJI_GROUP_PEOPLE, - TELEGRAM_EMOJI_GROUP_ANIMALS_NATURE, - TELEGRAM_EMOJI_GROUP_FOOD_DRINK, - TELEGRAM_EMOJI_GROUP_TRAVEL_PLACES, - TELEGRAM_EMOJI_GROUP_ACTIVITY, - TELEGRAM_EMOJI_GROUP_OBJECTS, - TELEGRAM_EMOJI_GROUP_SYMBOLS, - TELEGRAM_EMOJI_GROUP_FLAGS, -].map(withPreviewUrls); - -export function getTelegramEmojiAssetUrl(file) { - // Анимированные webp с raw.githubusercontent.com отключены: внешний хост - // блокируется у части пользователей (VPN/операторы) — тап по эмодзи вызывал - // сетевую ошибку и «моргание» картинки. Пока отдаём локальное статичное превью; - // возврат анимаций — только локальным паком в assets. - return getTelegramEmojiPreviewUrl(file); -} - -export function getTelegramEmojiPreviewUrl(file) { - return `${TELEGRAM_EMOJI_PREVIEW_BASE_URL}${String(file || '').replace(/\.webp$/i, '.png').split('/').map((part) => encodeURIComponent(part)).join('/')}`; -} diff --git a/shine-UI/js/components/telegram-emoji-activity.js b/shine-UI/js/components/twemoji-activity.js similarity index 97% rename from shine-UI/js/components/telegram-emoji-activity.js rename to shine-UI/js/components/twemoji-activity.js index 6462bd22..f51d8eea 100644 --- a/shine-UI/js/components/telegram-emoji-activity.js +++ b/shine-UI/js/components/twemoji-activity.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_ACTIVITY = { +export const TWEMOJI_GROUP_ACTIVITY = { "id": "activity", "label": "Активности", "icon": "🎉", diff --git a/shine-UI/js/components/telegram-emoji-animals_nature.js b/shine-UI/js/components/twemoji-animals_nature.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-animals_nature.js rename to shine-UI/js/components/twemoji-animals_nature.js index 8d3bdf66..4d962eec 100644 --- a/shine-UI/js/components/telegram-emoji-animals_nature.js +++ b/shine-UI/js/components/twemoji-animals_nature.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_ANIMALS_NATURE = { +export const TWEMOJI_GROUP_ANIMALS_NATURE = { "id": "animals-and-nature", "label": "Природа и животные", "icon": "🌿", diff --git a/shine-UI/js/components/twemoji-catalog.js b/shine-UI/js/components/twemoji-catalog.js new file mode 100644 index 00000000..6a516650 --- /dev/null +++ b/shine-UI/js/components/twemoji-catalog.js @@ -0,0 +1,52 @@ +import { TWEMOJI_GROUP_ACTIVITY } from './twemoji-activity.js?v=202607151545'; +import { TWEMOJI_GROUP_ANIMALS_NATURE } from './twemoji-animals_nature.js?v=202607151545'; +import { TWEMOJI_GROUP_FLAGS } from './twemoji-flags.js?v=202607151545'; +import { TWEMOJI_GROUP_FOOD_DRINK } from './twemoji-food_drink.js?v=202607151545'; +import { TWEMOJI_GROUP_OBJECTS } from './twemoji-objects.js?v=202607151545'; +import { TWEMOJI_GROUP_PEOPLE } from './twemoji-people.js?v=202607151545'; +import { TWEMOJI_GROUP_SMILEYS } from './twemoji-smileys.js?v=202607151545'; +import { TWEMOJI_GROUP_SYMBOLS } from './twemoji-symbols.js?v=202607151545'; +import { TWEMOJI_GROUP_TRAVEL_PLACES } from './twemoji-travel_places.js?v=202607151545'; + +const TWEMOJI_SVG_BASE_URL = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/'; + +function getTwemojiCodepoint(emoji) { + return Array.from(String(emoji || '')) + .map((char) => char.codePointAt(0)) + .filter((codepoint) => codepoint && codepoint !== 0xfe0f) + .map((codepoint) => codepoint.toString(16)) + .join('-'); +} + +function withPreviewUrls(group) { + return { + ...group, + items: group.items.map((item) => ({ + ...item, + name: item.name || String(item.emoji || ''), + previewUrl: getTwemojiPreviewUrl(item.emoji), + animatedUrl: getTwemojiAssetUrl(item.emoji), + })), + }; +} + +export const TWEMOJI_GROUPS = [ + TWEMOJI_GROUP_SMILEYS, + TWEMOJI_GROUP_PEOPLE, + TWEMOJI_GROUP_ANIMALS_NATURE, + TWEMOJI_GROUP_FOOD_DRINK, + TWEMOJI_GROUP_TRAVEL_PLACES, + TWEMOJI_GROUP_ACTIVITY, + TWEMOJI_GROUP_OBJECTS, + TWEMOJI_GROUP_SYMBOLS, + TWEMOJI_GROUP_FLAGS, +].map(withPreviewUrls); + +export function getTwemojiAssetUrl(emoji) { + return getTwemojiPreviewUrl(emoji); +} + +export function getTwemojiPreviewUrl(emoji) { + const codepoint = getTwemojiCodepoint(emoji); + return codepoint ? `${TWEMOJI_SVG_BASE_URL}${codepoint}.svg` : ''; +} diff --git a/shine-UI/js/components/telegram-emoji-flags.js b/shine-UI/js/components/twemoji-flags.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-flags.js rename to shine-UI/js/components/twemoji-flags.js index a8bcda4e..c57cae24 100644 --- a/shine-UI/js/components/telegram-emoji-flags.js +++ b/shine-UI/js/components/twemoji-flags.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_FLAGS = { +export const TWEMOJI_GROUP_FLAGS = { "id": "flags", "label": "Флаги", "icon": "🏳️", diff --git a/shine-UI/js/components/telegram-emoji-food_drink.js b/shine-UI/js/components/twemoji-food_drink.js similarity index 98% rename from shine-UI/js/components/telegram-emoji-food_drink.js rename to shine-UI/js/components/twemoji-food_drink.js index 1c9b0505..729108f3 100644 --- a/shine-UI/js/components/telegram-emoji-food_drink.js +++ b/shine-UI/js/components/twemoji-food_drink.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_FOOD_DRINK = { +export const TWEMOJI_GROUP_FOOD_DRINK = { "id": "food-and-drink", "label": "Еда и напитки", "icon": "🍓", diff --git a/shine-UI/js/components/telegram-emoji-objects.js b/shine-UI/js/components/twemoji-objects.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-objects.js rename to shine-UI/js/components/twemoji-objects.js index 1eb8c73f..812a5f09 100644 --- a/shine-UI/js/components/telegram-emoji-objects.js +++ b/shine-UI/js/components/twemoji-objects.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_OBJECTS = { +export const TWEMOJI_GROUP_OBJECTS = { "id": "objects", "label": "Предметы", "icon": "💡", diff --git a/shine-UI/js/components/telegram-emoji-people.js b/shine-UI/js/components/twemoji-people.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-people.js rename to shine-UI/js/components/twemoji-people.js index e7e72be8..060b1ff5 100644 --- a/shine-UI/js/components/telegram-emoji-people.js +++ b/shine-UI/js/components/twemoji-people.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_PEOPLE = { +export const TWEMOJI_GROUP_PEOPLE = { "id": "people", "label": "Люди", "icon": "🧑", diff --git a/shine-UI/js/components/telegram-emoji-smileys.js b/shine-UI/js/components/twemoji-smileys.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-smileys.js rename to shine-UI/js/components/twemoji-smileys.js index 807c5eaa..92a32dc5 100644 --- a/shine-UI/js/components/telegram-emoji-smileys.js +++ b/shine-UI/js/components/twemoji-smileys.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_SMILEYS = { +export const TWEMOJI_GROUP_SMILEYS = { "id": "smileys", "label": "Смайлы", "icon": "🙂", diff --git a/shine-UI/js/components/telegram-emoji-symbols.js b/shine-UI/js/components/twemoji-symbols.js similarity index 99% rename from shine-UI/js/components/telegram-emoji-symbols.js rename to shine-UI/js/components/twemoji-symbols.js index c089ab6d..0dc20e57 100644 --- a/shine-UI/js/components/telegram-emoji-symbols.js +++ b/shine-UI/js/components/twemoji-symbols.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_SYMBOLS = { +export const TWEMOJI_GROUP_SYMBOLS = { "id": "symbols", "label": "Символы", "icon": "❤️", diff --git a/shine-UI/js/components/telegram-emoji-travel_places.js b/shine-UI/js/components/twemoji-travel_places.js similarity index 96% rename from shine-UI/js/components/telegram-emoji-travel_places.js rename to shine-UI/js/components/twemoji-travel_places.js index 7e7cb125..cbd63032 100644 --- a/shine-UI/js/components/telegram-emoji-travel_places.js +++ b/shine-UI/js/components/twemoji-travel_places.js @@ -1,4 +1,4 @@ -export const TELEGRAM_EMOJI_GROUP_TRAVEL_PLACES = { +export const TWEMOJI_GROUP_TRAVEL_PLACES = { "id": "travel-and-places", "label": "Путешествия", "icon": "✈️", diff --git a/shine-UI/js/deploy-config.js b/shine-UI/js/deploy-config.js index cb6a5c30..af2db939 100644 --- a/shine-UI/js/deploy-config.js +++ b/shine-UI/js/deploy-config.js @@ -6,4 +6,4 @@ export const defaultServerAddress = 'shineup.me'; export const defaultServerHttp = `https://${defaultServerAddress}`; export const defaultServerWs = `wss://${defaultServerAddress}/ws`; export const defaultSolanaCluster = 'mainnet-beta'; -export const defaultSolanaEndpoint = 'https://solana-rpc.publicnode.com'; +export const defaultSolanaEndpoint = 'https://public.rpc.solanavibestation.com/'; diff --git a/shine-UI/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js new file mode 100644 index 00000000..ce7d7277 --- /dev/null +++ b/shine-UI/js/pages/access-servers-view.js @@ -0,0 +1,703 @@ +import { renderHeader } from '../components/header.js'; +import { authService, state } from '../state.js'; +import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js'; +import { loadEncryptedUserSecrets } from '../services/key-vault.js'; +import { resolveShineServerByServerLogin } from '../services/shine-server-resolver.js'; +import { readShineUserPda, updateShineUserPdaOnSolana } from '../services/shine-user-pda-service.js'; +import { getTopupSiteUrl } from '../services/solana-wallet-service.js'; + +export const pageMeta = { id: 'access-servers-view', title: 'Серверы доступа' }; + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normalizeLogin(value) { + return String(value || '').trim().toLowerCase(); +} + +function equalBytes(a, b) { + const left = a instanceof Uint8Array ? a : new Uint8Array(a || []); + const right = b instanceof Uint8Array ? b : new Uint8Array(b || []); + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + +function uniqueLogins(values) { + const seen = new Set(); + const result = []; + (Array.isArray(values) ? values : []).forEach((value) => { + const login = normalizeLogin(value); + if (!login || seen.has(login)) return; + seen.add(login); + result.push(login); + }); + return result; +} + +function shortenSignature(value) { + const text = String(value || '').trim(); + if (text.length <= 16) return text; + return `${text.slice(0, 8)}...${text.slice(-8)}`; +} + +function isInsufficientFundsForRentError(error) { + const text = [ + error?.message, + error?.transactionMessage, + Array.isArray(error?.logs) ? error.logs.join('\n') : '', + Array.isArray(error?.transactionLogs) ? error.transactionLogs.join('\n') : '', + Array.isArray(error?.simulationLogs) ? error.simulationLogs.join('\n') : '', + ].filter(Boolean).join('\n').toLowerCase(); + return text.includes('insufficient funds') && text.includes('rent'); +} + +function clientAddressFromPublicB64(publicKeyB64) { + const bytes = base64ToBytes(publicKeyB64); + if (bytes.length !== 32) throw new Error('client public key должен быть 32 байта'); + return bytesToBase58(bytes); +} + +async function clientAddressFromPrivatePkcs8(privatePkcs8B64) { + return clientAddressFromPublicB64(await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64)); +} + +function createConfirmModal() { + const root = document.getElementById('modal-root'); + if (!(root instanceof HTMLElement)) return null; + + let onConfirm = null; + let onCancel = null; + + const close = () => { + root.innerHTML = ''; + onConfirm = null; + onCancel = null; + }; + + return { + open({ + title, + text, + note = '', + confirmLabel = 'Да', + cancelLabel = 'Нет', + onConfirm: confirmHandler, + onCancel: cancelHandler, + }) { + root.innerHTML = ` + + `; + const modal = root.querySelector('#access-servers-confirm-modal'); + const titleEl = root.querySelector('#access-servers-dialog-title'); + const textEl = root.querySelector('#access-servers-dialog-text'); + const noteEl = root.querySelector('#access-servers-dialog-note'); + const cancelBtn = root.querySelector('#access-servers-dialog-cancel'); + const confirmBtn = root.querySelector('#access-servers-dialog-confirm'); + if (!(modal instanceof HTMLElement) + || !(titleEl instanceof HTMLElement) + || !(textEl instanceof HTMLElement) + || !(noteEl instanceof HTMLElement) + || !(cancelBtn instanceof HTMLButtonElement) + || !(confirmBtn instanceof HTMLButtonElement)) { + close(); + return; + } + + titleEl.textContent = String(title || 'Подтверждение'); + textEl.textContent = String(text || ''); + if (note) { + noteEl.hidden = false; + noteEl.textContent = String(note); + } else { + noteEl.hidden = true; + noteEl.textContent = ''; + } + cancelBtn.textContent = String(cancelLabel || 'Нет'); + confirmBtn.textContent = String(confirmLabel || 'Да'); + onConfirm = confirmHandler || null; + onCancel = cancelHandler || null; + + modal.addEventListener('click', (event) => { + if (event.target === modal) close(); + }); + cancelBtn.addEventListener('click', async () => { + const handler = onCancel; + close(); + if (typeof handler === 'function') await handler(); + }); + confirmBtn.addEventListener('click', async () => { + const handler = onConfirm; + close(); + if (typeof handler === 'function') await handler(); + }); + }, + destroy() { + close(); + }, + }; +} + +function createPasswordModal() { + const root = document.getElementById('modal-root'); + if (!(root instanceof HTMLElement)) return null; + + return { + open({ title, text, note = '' }) { + root.innerHTML = ` + + `; + + return new Promise((resolve) => { + const modal = root.querySelector('#access-servers-password-modal'); + const titleEl = root.querySelector('#access-servers-password-title'); + const textEl = root.querySelector('#access-servers-password-text'); + const noteEl = root.querySelector('#access-servers-password-note'); + const inputEl = root.querySelector('#access-servers-password-input'); + const cancelBtn = root.querySelector('#access-servers-password-cancel'); + const confirmBtn = root.querySelector('#access-servers-password-confirm'); + if (!(modal instanceof HTMLElement) + || !(titleEl instanceof HTMLElement) + || !(textEl instanceof HTMLElement) + || !(noteEl instanceof HTMLElement) + || !(inputEl instanceof HTMLInputElement) + || !(cancelBtn instanceof HTMLButtonElement) + || !(confirmBtn instanceof HTMLButtonElement)) { + root.innerHTML = ''; + resolve(null); + return; + } + + titleEl.textContent = String(title || 'Введите пароль'); + textEl.textContent = String(text || ''); + if (note) { + noteEl.hidden = false; + noteEl.textContent = String(note); + } else { + noteEl.hidden = true; + noteEl.textContent = ''; + } + + const close = (result = null) => { + root.innerHTML = ''; + resolve(result); + }; + + modal.addEventListener('click', (event) => { + if (event.target === modal) close(null); + }); + cancelBtn.addEventListener('click', () => close(null)); + inputEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + confirmBtn.click(); + } + }); + confirmBtn.addEventListener('click', () => { + const password = String(inputEl.value || ''); + if (!password.trim()) { + inputEl.focus(); + return; + } + const mode = root.querySelector('input[name="access-servers-key-mode"]:checked'); + close({ + password, + saveRoot: mode instanceof HTMLInputElement && mode.value === 'save', + }); + }); + window.setTimeout(() => inputEl.focus(), 0); + }); + }, + destroy() { + root.innerHTML = ''; + }, + }; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + const sessionLogin = normalizeLogin(state.session.login); + const solanaEndpoint = String(state.entrySettings.solanaServer || '').trim(); + const storagePwd = String(state.session.storagePwdInMemory || '').trim(); + + let disposed = false; + let operationBusy = false; + let currentAccessServers = []; + let selectedCandidate = null; + let suggestionsLoading = false; + + const confirmModal = createConfirmModal(); + const passwordModal = createPasswordModal(); + + const introCard = document.createElement('div'); + introCard.className = 'card stack'; + introCard.innerHTML = ` +

Где хранятся личные данные

+

+ Серверы доступа хранят зашифрованную личную переписку пользователя и участвуют в звонках. + Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния, + а доступ пользователя и приватная переписка хранятся только на этих серверах. +

+ `; + + const listCard = document.createElement('div'); + listCard.className = 'card stack'; + const listTitle = document.createElement('p'); + listTitle.className = 'field-label'; + listTitle.textContent = 'Текущий список серверов доступа'; + const listHint = document.createElement('p'); + listHint.className = 'meta-muted'; + listHint.textContent = sessionLogin + ? `Пользователь: @${sessionLogin}` + : 'В текущей сессии не найден логин пользователя.'; + const listBody = document.createElement('div'); + listBody.className = 'stack'; + const listStatus = document.createElement('p'); + listStatus.className = 'meta-muted'; + listStatus.textContent = 'Загрузка данных из PDA...'; + listCard.append(listTitle, listHint, listBody, listStatus); + + const addCard = document.createElement('div'); + addCard.className = 'card stack'; + const addTitle = document.createElement('p'); + addTitle.className = 'field-label'; + addTitle.textContent = 'Добавить сервер доступа'; + const addHint = document.createElement('p'); + addHint.className = 'meta-muted'; + addHint.textContent = 'Введите логин сервера или несколько первых букв, затем выберите сервер из подсказок.'; + const addInput = document.createElement('input'); + addInput.className = 'input'; + addInput.type = 'text'; + addInput.autocomplete = 'off'; + addInput.placeholder = 'Например: shineup'; + const suggestEl = document.createElement('div'); + suggestEl.className = 'profile-relative-search-suggest'; + suggestEl.hidden = true; + const addStatus = document.createElement('p'); + addStatus.className = 'meta-muted'; + addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; + const addButton = document.createElement('button'); + addButton.className = 'primary-btn'; + addButton.type = 'button'; + addButton.textContent = 'Добавить сервер'; + addButton.disabled = true; + addCard.append(addTitle, addHint, addInput, suggestEl, addStatus, addButton); + + const refreshAddButton = () => { + addButton.disabled = operationBusy || (!selectedCandidate && !normalizeLogin(addInput.value)); + }; + + const setOperationBusy = (busy) => { + operationBusy = busy; + addInput.disabled = busy; + refreshAddButton(); + listBody.querySelectorAll('button').forEach((button) => { + button.disabled = busy; + }); + }; + + const setSelectedCandidate = (candidate) => { + selectedCandidate = candidate; + refreshAddButton(); + if (candidate) { + addInput.value = candidate.login; + addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`; + } else { + addStatus.textContent = 'Для изменения списка понадобится подпись root key.'; + } + }; + + const renderServerList = () => { + listBody.innerHTML = ''; + if (!currentAccessServers.length) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Список серверов доступа пока пуст.'; + listBody.append(empty); + return; + } + + currentAccessServers.forEach((server, index) => { + const button = document.createElement('button'); + button.className = 'text-btn'; + button.type = 'button'; + button.disabled = operationBusy; + button.innerHTML = ` + + @${escapeHtml(server.login)} + ${escapeHtml(server.url || 'URL не указан')} + + `; + button.addEventListener('click', () => { + if (operationBusy) return; + const isLast = currentAccessServers.length <= 1; + const performDisable = async () => { + const nextList = currentAccessServers + .map((item) => item.login) + .filter((login) => login !== server.login); + try { + await updateAccessServers(nextList, { + statusTarget: listStatus, + successText: `Сервер доступа @${server.login} отключён.`, + inFlightText: `Обновляем PDA и отключаем сервер @${server.login}...`, + }); + } catch { + // Сообщение уже показано в статусе. + } + }; + + const askFinal = (noteText = '') => { + confirmModal?.open({ + title: isLast ? 'Последний сервер доступа' : 'Отключить сервер доступа?', + text: isLast + ? 'Это последний сервер доступа пользователя. Если его отключить, личная переписка пользователя на серверах доступа будет удалена.' + : `Хотите изменить запись в блокчейне Solana и отключить сервер доступа @${server.login}?`, + note: noteText, + confirmLabel: isLast ? 'Понимаю' : 'Да', + cancelLabel: isLast ? 'Отмена' : 'Нет', + onConfirm: performDisable, + }); + }; + + if (isLast) { + confirmModal?.open({ + title: 'Отключить последний сервер?', + text: `У пользователя остался только один сервер доступа: @${server.login}.`, + note: 'После отключения последнего сервера доступа личная переписка пользователя на серверах доступа будет удалена.', + confirmLabel: 'Продолжить', + cancelLabel: 'Отмена', + onConfirm: () => askFinal('Это повторное предупреждение перед записью нового списка серверов в Solana PDA.'), + }); + return; + } + + askFinal(); + }); + listBody.append(button); + if (index < currentAccessServers.length - 1) { + const divider = document.createElement('div'); + divider.style.height = '1px'; + divider.style.background = 'rgba(255,255,255,0.08)'; + listBody.append(divider); + } + }); + }; + + const loadCurrentServers = async () => { + if (!sessionLogin) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = 'Нет активной пользовательской сессии.'; + return; + } + if (!solanaEndpoint) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = 'Не задан Solana RPC endpoint.'; + return; + } + + listStatus.textContent = 'Читаем серверы доступа из Solana PDA...'; + try { + const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); + const logins = uniqueLogins(parsed?.accessServers); + const rows = []; + for (const login of logins) { + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + rows.push({ login: resolved.serverLogin, url: resolved.httpBase }); + } catch { + rows.push({ login, url: '' }); + } + } + currentAccessServers = rows; + renderServerList(); + listStatus.textContent = rows.length + ? `Найдено серверов доступа: ${rows.length}` + : 'В PDA пользователя пока нет серверов доступа.'; + } catch (error) { + currentAccessServers = []; + renderServerList(); + listStatus.textContent = error?.message || 'Не удалось прочитать список серверов доступа.'; + } + }; + + const renderSuggestions = (items) => { + suggestEl.innerHTML = ''; + if (!items.length) { + suggestEl.hidden = true; + return; + } + suggestEl.hidden = false; + suggestEl.innerHTML = items.map((item) => ( + `` + )).join(''); + }; + + const showTopupRequiredStatus = (target, clientAddress) => { + const address = String(clientAddress || '').trim(); + const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup'; + target.innerHTML = ` + + Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA. + + ${address ? `Кошелёк: ${escapeHtml(address)}` : ''} + Пополнить DEVNET кошелёк + `; + }; + + const loadSuggestions = async () => { + const prefix = normalizeLogin(addInput.value); + if (suggestionsLoading || prefix.length < 2 || operationBusy) { + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + return; + } + suggestionsLoading = true; + try { + const logins = await authService.searchUsers(prefix, { isServer: true }); + const items = []; + for (const login of logins.slice(0, 6)) { + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + items.push({ login: resolved.serverLogin, url: resolved.httpBase }); + } catch { + // Если server PDA повреждена, не показываем подсказку. + } + } + if (!disposed) renderSuggestions(items); + } catch (error) { + if (!disposed) { + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + addStatus.textContent = error?.message || 'Не удалось получить список серверов.'; + } + } finally { + suggestionsLoading = false; + } + }; + + const resolveAccessServerSigningMaterial = async (currentPda) => { + if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); + if (!storagePwd) throw new Error('В памяти сессии нет storagePwd. Выполните вход заново.'); + + let saved = null; + try { + saved = await loadEncryptedUserSecrets(sessionLogin, storagePwd); + } catch { + saved = null; + } + + const savedRoot = String(saved?.rootKey || '').trim(); + const savedClient = String(saved?.clientKey || '').trim(); + if (savedRoot && savedClient) { + return { + rootPrivatePkcs8B64: savedRoot, + clientPrivatePkcs8B64: savedClient, + clientAddress: await clientAddressFromPrivatePkcs8(savedClient), + }; + } + + const passwordResult = await passwordModal?.open({ + title: 'Нужен пароль для обновления серверов доступа', + text: 'Чтобы изменить список серверов доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.', + note: savedClient + ? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.' + : 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.', + }); + if (!passwordResult) { + throw new Error('Операция отменена пользователем.'); + } + + const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password); + const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64); + if (!equalBytes(derivedRootPublic, currentPda.rootKey)) { + throw new Error('Пароль не подходит: root key не совпал с user PDA.'); + } + if (!equalBytes(derivedClientPublic, currentPda.clientKey)) { + throw new Error('Пароль не подходит: client key не совпал с user PDA.'); + } + + if (passwordResult.saveRoot) { + await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, { + saveRoot: true, + saveBlockchain: false, + }); + } + + return { + rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, + clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, + clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64), + }; + }; + + const updateAccessServers = async (nextLogins, { + statusTarget, + successText, + inFlightText, + } = {}) => { + const target = statusTarget instanceof HTMLElement ? statusTarget : listStatus; + if (!sessionLogin) throw new Error('Нет активной пользовательской сессии.'); + if (!solanaEndpoint) throw new Error('Не задан Solana RPC endpoint.'); + if (operationBusy) return; + + const normalizedList = uniqueLogins(nextLogins); + setOperationBusy(true); + target.textContent = String(inFlightText || 'Обновляем список серверов доступа...'); + let signingMaterial = null; + try { + const currentPda = await readShineUserPda({ login: sessionLogin, solanaEndpoint }); + signingMaterial = await resolveAccessServerSigningMaterial(currentPda); + const tx = await updateShineUserPdaOnSolana({ + login: sessionLogin, + solanaEndpoint, + rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64, + clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64, + accessServers: normalizedList, + }); + await loadCurrentServers(); + target.textContent = `${String(successText || 'Список серверов доступа обновлён.')} Tx: ${shortenSignature(tx?.signature)}`; + setSelectedCandidate(null); + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + refreshAddButton(); + } catch (error) { + if (isInsufficientFundsForRentError(error)) { + showTopupRequiredStatus(target, signingMaterial?.clientAddress); + } else { + target.textContent = error?.message || 'Не удалось обновить список серверов доступа.'; + } + throw error; + } finally { + setOperationBusy(false); + } + }; + + addInput.addEventListener('input', () => { + setSelectedCandidate(null); + refreshAddButton(); + void loadSuggestions(); + }); + addInput.addEventListener('focus', () => { + void loadSuggestions(); + }); + suggestEl.addEventListener('click', (event) => { + const target = event.target instanceof HTMLElement ? event.target.closest('[data-login]') : null; + if (!(target instanceof HTMLElement)) return; + setSelectedCandidate({ + login: String(target.dataset.login || ''), + url: String(target.dataset.url || ''), + }); + suggestEl.hidden = true; + suggestEl.innerHTML = ''; + }); + + addButton.addEventListener('click', async () => { + if (operationBusy) return; + const login = normalizeLogin(selectedCandidate?.login || addInput.value); + if (!login) { + setSelectedCandidate(null); + addStatus.textContent = 'Сначала укажите логин сервера доступа.'; + return; + } + if (currentAccessServers.some((item) => item.login === login)) { + addStatus.textContent = `Сервер @${login} уже есть в списке.`; + return; + } + + try { + const resolved = await resolveShineServerByServerLogin({ serverLogin: login, solanaEndpoint }); + confirmModal?.open({ + title: 'Добавить сервер доступа?', + text: `Вы хотите добавить сервер доступа @${resolved.serverLogin}?`, + note: resolved.httpBase + ? `Адрес сервера: ${resolved.httpBase}\nИзменение будет записано в Solana user PDA.` + : 'Изменение будет записано в Solana user PDA.', + onConfirm: async () => { + const nextList = uniqueLogins([...currentAccessServers.map((item) => item.login), resolved.serverLogin]); + try { + await updateAccessServers(nextList, { + statusTarget: addStatus, + successText: `Сервер доступа @${resolved.serverLogin} добавлен.`, + inFlightText: `Обновляем PDA и добавляем сервер @${resolved.serverLogin}...`, + }); + } catch { + // Сообщение уже показано в статусе. + } + }, + }); + } catch (error) { + addStatus.textContent = error?.message || 'Не удалось проверить выбранный сервер.'; + } + }); + + screen.append( + renderHeader({ + title: 'Серверы доступа', + leftAction: { label: '←', onClick: () => navigate('settings-view') }, + }), + introCard, + listCard, + addCard, + ); + + void loadCurrentServers(); + + screen.cleanup = () => { + disposed = true; + confirmModal?.destroy(); + passwordModal?.destroy(); + }; + + return screen; +} diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index d4b3c70b..b6f3b4d7 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -3,10 +3,11 @@ import { authService, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { channelNameErrorText, - normalizeChannelDescription, normalizeChannelDisplayName, validateChannelDisplayName, } from '../services/channel-name-rules.js'; +import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js'; +import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' }; @@ -21,13 +22,30 @@ function persistCreateSuccessFlash(message) { } } -function validateDescription(value) { - const normalized = normalizeChannelDescription(value); - const bytes = new TextEncoder().encode(normalized).length; - if (bytes > 200) { - return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' }; +function normalizeMetaText(value, max, label) { + const normalized = String(value || '').trim(); + const length = Array.from(normalized).length; + if (length > max) { + return { ok: false, normalized, length, error: `${label}: максимум ${max} символов.` }; } - return { ok: true, normalized, bytes, error: '' }; + return { ok: true, normalized, length, error: '' }; +} + +function renderAvatarPreview(slot, avatar, title) { + if (!slot) return; + slot.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'channel-profile-avatar'; + wrap.style.setProperty('--channel-avatar-size', '104px'); + if (avatar?.ar) { + const img = document.createElement('img'); + img.alt = 'Аватар канала'; + img.src = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: avatar.ar }); + wrap.append(img); + } else { + wrap.textContent = String(title || 'К').trim().slice(0, 1).toUpperCase() || 'К'; + } + slot.append(wrap); } export function render({ navigate }) { @@ -45,17 +63,26 @@ export function render({ navigate }) { form.className = 'card stack'; form.innerHTML = ` Создание канала -

Можно использовать только латиницу, цифры, _ и -.

-

Длина названия: от 1 до 32 символов.

+

Разрешены только латинские буквы, цифры, _ и -.

+

Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.

+

Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.

Тип канала фиксирован: публичный (1).
- - + +
+ + +
0 / 50 символов
+
+ +
+ + - -
0 / 200 байт
+ +
0 / 250 символов
@@ -66,8 +93,13 @@ export function render({ navigate }) { `; const nameEl = form.querySelector('#channel-name'); + const titleEl = form.querySelector('#channel-title'); const descriptionEl = form.querySelector('#channel-description'); + const avatarPreviewEl = form.querySelector('#channel-avatar-preview'); + const avatarBtn = form.querySelector('#channel-avatar-btn'); const nameErrorEl = form.querySelector('#channel-name-error'); + const titleErrorEl = form.querySelector('#channel-title-error'); + const titleCounterEl = form.querySelector('#channel-title-counter'); const descriptionErrorEl = form.querySelector('#channel-description-error'); const descriptionCounterEl = form.querySelector('#channel-description-counter'); const errorEl = form.querySelector('#channel-create-error'); @@ -75,38 +107,60 @@ export function render({ navigate }) { const cancelEl = form.querySelector('#cancel-create-channel'); let submitInFlight = false; + let selectedAvatar = null; const setBusy = (busy) => { submitInFlight = !!busy; submitEl.disabled = submitInFlight; cancelEl.disabled = submitInFlight; nameEl.disabled = submitInFlight; + titleEl.disabled = submitInFlight; descriptionEl.disabled = submitInFlight; + avatarBtn.disabled = submitInFlight; submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать'; }; const updateValidation = () => { const nameCheck = validateChannelDisplayName(nameEl.value); - const descriptionCheck = validateDescription(descriptionEl.value); + const titleCheck = normalizeMetaText(titleEl.value, 50, 'Название'); + const descriptionCheck = normalizeMetaText(descriptionEl.value, 250, 'Описание'); nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code); + titleErrorEl.textContent = titleCheck.error; descriptionErrorEl.textContent = descriptionCheck.error; - const descLength = Number(descriptionCheck.bytes || 0); - descriptionCounterEl.textContent = `${descLength} / 200 байт`; + titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`; + descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`; - const ok = nameCheck.ok && descriptionCheck.ok; + const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok; submitEl.disabled = submitInFlight || !ok; + renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized); return { ok, name: nameCheck.normalized, + title: titleCheck.normalized, description: descriptionCheck.normalized, }; }; nameEl.addEventListener('input', updateValidation); + titleEl.addEventListener('input', updateValidation); descriptionEl.addEventListener('input', updateValidation); + avatarBtn.addEventListener('click', async () => { + try { + selectedAvatar = await openArweaveAttachmentManager({ + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + gateway: state.entrySettings.arweaveServer, + mode: 'avatar', + historyPurpose: 'avatar', + }); + updateValidation(); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.'); + } + }); form.addEventListener('submit', async (event) => { event.preventDefault(); @@ -130,10 +184,13 @@ export function render({ navigate }) { login, storagePwd, channelName: normalizeChannelDisplayName(check.name), - channelDescription: normalizeChannelDescription(check.description), + channelDescription: check.description, + channelProfileTitle: check.title, + channelAvatar: selectedAvatar, channelType: CHANNEL_TYPE_PUBLIC, channelTypeVersion: 1, }); + if (selectedAvatar?.ar) markArweaveAttachmentPlaced(login, selectedAvatar); persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`); navigate('channels-list'); diff --git a/shine-UI/js/pages/arweave-uploads-view.js b/shine-UI/js/pages/arweave-uploads-view.js new file mode 100644 index 00000000..c9c1fc87 --- /dev/null +++ b/shine-UI/js/pages/arweave-uploads-view.js @@ -0,0 +1,173 @@ +import { + clearArweaveAttachmentHistory, + formatArweaveHistoryTime, + getArweaveAttachmentAvailability, + openArweaveAttachmentManager, + readArweaveAttachmentHistory, +} from '../components/arweave-attachment-manager.js'; +import { formatBytes } from '../services/attachment-format.js'; +import { toUserMessage } from '../services/ui-error-texts.js'; +import { state } from '../state.js'; + +export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' }; + +function closeUploadsMenu(controls) { + const menu = controls?.querySelector?.('[data-menu="true"]'); + if (menu) menu.hidden = true; +} + +function setStatusBadge(node, status, label) { + if (!node) return; + node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`; + node.textContent = label || 'Проверяем...'; +} + +function renderTile(item, index) { + const tile = document.createElement('article'); + tile.className = `ar-attachment-history-tile ar-attachment-history-tile--page${item?.preview?.ar ? ' ar-attachment-history-tile--with-preview' : ''}`; + + const name = document.createElement('strong'); + name.className = 'ar-attachment-history-name'; + name.textContent = item.name; + + const status = document.createElement('span'); + status.className = 'ar-attachment-status ar-attachment-status--pending'; + status.dataset.statusIndex = String(index); + status.textContent = 'Проверяем...'; + + const meta = document.createElement('div'); + meta.className = 'ar-attachment-history-meta-row'; + const size = document.createElement('span'); + size.textContent = formatBytes(item.size); + const time = document.createElement('span'); + time.textContent = formatArweaveHistoryTime(item.uploadedAtMs); + meta.append(status, size, time); + if (item?.preview?.ar) { + const previewFlag = document.createElement('span'); + previewFlag.className = 'ar-attachment-placement-flag'; + previewFlag.textContent = 'С превью'; + meta.append(previewFlag); + } + if (item.pendingPlacement) { + const flag = document.createElement('span'); + flag.className = 'ar-attachment-placement-flag'; + flag.textContent = 'Не добавлен в SHiNE'; + meta.append(flag); + } + + const txId = document.createElement('div'); + txId.className = 'mono-cell ar-attachment-history-txid'; + txId.textContent = item.ar; + + tile.append(name, meta, txId); + return tile; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack arweave-uploads-screen'; + + const controls = document.createElement('div'); + controls.className = 'arweave-uploads-toolbar'; + controls.innerHTML = ` + +
Загрузка файлов в блокчейн
+ + + + `; + + const statusLine = document.createElement('p'); + statusLine.className = 'meta-muted inline-error'; + + const list = document.createElement('div'); + list.className = 'ar-attachment-history-tiles arweave-uploads-list'; + + const uploadFile = async () => { + statusLine.textContent = ''; + closeUploadsMenu(controls); + try { + await openArweaveAttachmentManager({ + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + gateway: state.entrySettings.arweaveServer, + historyOnly: true, + }); + renderHistory(); + } catch (error) { + statusLine.textContent = toUserMessage(error, 'Не удалось загрузить файл.'); + } + }; + + function renderHistory() { + const rows = readArweaveAttachmentHistory(state.session.login).slice().reverse(); + list.innerHTML = ''; + statusLine.textContent = ''; + + if (!rows.length) { + const empty = document.createElement('div'); + empty.className = 'card meta-muted'; + empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.'; + list.append(empty); + return; + } + + rows.forEach((item, index) => { + list.append(renderTile(item, index)); + const statusEl = list.querySelector(`[data-status-index="${index}"]`); + void getArweaveAttachmentAvailability({ + gateway: state.entrySettings.arweaveServer, + attachment: item, + }) + .then((result) => setStatusBadge(statusEl, result.status, result.label)) + .catch(() => setStatusBadge(statusEl, 'unavailable', 'Недоступен')); + }); + + window.setTimeout(() => { + list.scrollTop = list.scrollHeight; + }, 0); + } + + controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => { + void uploadFile(); + }); + controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => { + void uploadFile(); + }); + + controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view')); + controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => { + event.stopPropagation(); + const menu = controls.querySelector('[data-menu="true"]'); + if (menu) menu.hidden = !menu.hidden; + }); + controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => { + const menu = controls.querySelector('[data-menu="true"]'); + if (menu) menu.hidden = true; + const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?'); + if (!confirmed) return; + clearArweaveAttachmentHistory(state.session.login); + renderHistory(); + }); + controls.querySelector('[data-action="help"]')?.addEventListener('click', () => { + const menu = controls.querySelector('[data-menu="true"]'); + if (menu) menu.hidden = true; + window.alert( + 'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n' + + 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. ' + + 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.' + ); + }); + screen.addEventListener('click', (event) => { + if (controls.contains(event.target)) return; + closeUploadsMenu(controls); + }); + + screen.append(controls, statusLine, list); + renderHistory(); + return screen; +} diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index f34170cd..65a62d97 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -10,9 +10,16 @@ import { showToast, softHaptic, } from '../services/channels-ux.js'; -import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { navigateBack } from '../router.js'; import { renderUserAvatar } from '../components/avatar-image.js'; +import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; +import { + composeMessageWithAttachments, + createAttachmentCarouselElement, + escapeHtml, + MAX_MESSAGE_ATTACHMENTS, + parseMessageAttachments, +} from '../services/attachment-format.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; @@ -313,6 +320,101 @@ function latestVersionText(versions) { return ''; } +function bindSubmitOnPlainEnter(textarea, submit) { + if (!(textarea instanceof HTMLTextAreaElement) || typeof submit !== 'function') return; + textarea.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + if (event.shiftKey || event.ctrlKey) return; + event.preventDefault(); + submit(); + }); +} + +function setActionTitle(button, label) { + if (!button) return; + button.title = label; + button.setAttribute('aria-label', label); + const labelEl = button.querySelector('.channel-action-label'); + if (labelEl) labelEl.textContent = label; +} + +async function copyTextToClipboard(text) { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.append(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + return !!ok; +} + +function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) { + const source = raw && typeof raw === 'object' ? raw : {}; + return { + authorLogin, + authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '', + blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '', + blockHash: target?.blockHash || source?.messageRef?.blockHash || '', + localNumber, + msgSubType: msgSubType ?? source.msgSubType ?? '', + createdAtMs: timestampMs || source.createdAtMs || '', + text: String(text || ''), + signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера', + publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера', + raw, + }; +} + +function openBlockchainDetailsModal(details) { + const root = document.getElementById('modal-root'); + const rawText = JSON.stringify(details.raw || details, null, 2); + root.innerHTML = ` + + `; + root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => { + root.innerHTML = ''; + }); + root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => { + await copyTextToClipboard(rawText); + showToast('Данные блокчейна скопированы'); + }); + root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => { + const rawEl = root.querySelector('#thread-blockchain-raw-block'); + if (!rawEl) return; + rawEl.hidden = !rawEl.hidden; + }); +} + function resolveNodeText(node) { return firstNonEmptyText( node?.text, @@ -322,6 +424,25 @@ function resolveNodeText(node) { ); } +function renderDraftAttachments(container, attachments) { + if (!container) return; + container.innerHTML = ''; + (Array.isArray(attachments) ? attachments : []).forEach((item, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'draft-attachment-chip'; + button.textContent = `${item.name} · ${item.ar}`; + button.title = 'Нажмите, чтобы убрать вложение'; + button.addEventListener('click', () => { + const ok = window.confirm('Отменить вложение?'); + if (!ok) return; + attachments.splice(index, 1); + renderDraftAttachments(container, attachments); + }); + container.append(button); + }); +} + function openReplyModal({ onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` @@ -329,9 +450,8 @@ function openReplyModal({ onSubmit, navigate }) {