Compare commits

..
Author SHA256 Message Date
AidarKC 809c216a5c Deploy: обновить production Solana RPC 2026-07-22 20:12:36 +04:00
AidarKC 415a40e436 Версии: поднять main до 1.3.0 2026-07-22 20:12:06 +04:00
AidarKC d233f1d73f Merge branch 'регитсрация' into main 2026-07-22 20:11:30 +04:00
AidarKC 9a7019a2ce Документация: вынести правила коммитов и версий 2026-07-22 20:10:20 +04:00
AidarKC 07b1623c5e UI: уточнить шаг пополнения регистрации 2026-07-22 19:28:46 +04:00
AidarKC 681437acb1 UI: убрать голосовой ввод и связанные настройки 2026-07-22 19:18:56 +04:00
AidarKC a183a586cb UI: индикатор загрузки истории чата 2026-07-22 19:07:15 +04:00
AidarKC af62b51ef5 UI: правки личного чата и настроек 2026-07-22 18:59:09 +04:00
AidarKC f640451257 Доработать профильные кнопки и навигацию 2026-07-22 15:20:05 +04:00
AidarKC a56252c361 Finalize DM docs and queue routing 2026-07-22 15:13:13 +04:00
AidarKC 38141ea5c7 Refresh profile header action icons 2026-07-22 15:12:48 +04:00
AidarKC d24d1c178b Shorten public UI routes 2026-07-22 14:12:10 +04:00
AidarKC 93673e5786 Improve direct message history and SQLite resilience 2026-07-22 13:09:42 +04:00
AidarKC 2f3b1571e5 Упростить просмотр билетов второй и третьей очереди 2026-07-20 22:13:19 +04:00
AidarKC 65703f0fc4 Вернуть прежний логотип стартового экрана 2026-07-20 21:34:44 +04:00
AidarKC 4e674af4ed Убрать артефакты анимации логотипа 2026-07-20 21:23:15 +04:00
AidarKC f9c2d7c63a Оптимизировать логотип стартового экрана 2026-07-20 21:19:55 +04:00
AidarKC 968d85d738 Показывать очередь билетов mainnet на тестовом UI 2026-07-20 21:04:11 +04:00
AidarKC d230b61c0b Сделать backup production устойчивым к отсутствующим файлам 2026-07-20 20:00:29 +04:00
AidarKC 59047a8d0e Вынести продуктовый репозиторий из локальной обвязки агента 2026-07-20 18:49:28 +04:00
AidarKC 81493ac4be Очистить репозиторий перед переносом продукта 2026-07-20 18:46:56 +04:00
753 changed files with 1962 additions and 8682 deletions
+3 -1
View File
@@ -106,7 +106,9 @@ ESP32/**/*.a
deploy/backup/archive/**
!deploy/backup/archive/.gitkeep
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
.agents/
.codex/
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
+7 -11
View File
@@ -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
@@ -78,12 +79,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 +89,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 автоматически.
+1 -1
View File
@@ -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`.
+47
View File
@@ -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`.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -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/<username>/`.
6. Зафиксировать правило: если пользователь говорит «агент MD» или похожую формулировку, считать, что речь про автоматически читаемый `AGENTS.md`.
7. Добавить простой процесс согласования изменений инструкций:
- Дима или другой участник готовит предложение;
- Айдар получает уведомление/заявку;
- Айдар отвечает: одобрить, отклонить или попросить доработать;
- только после одобрения агент вносит изменения в проектные инструкции.
## Предлагаемая логика уведомления Айдару
Минимальный вариант без сложной разработки:
1. Агент готовит текст заявки.
2. Текст отправляется Айдару в Telegram или в общий рабочий чат.
3. В заявке явно указаны варианты ответа:
- `одобрить`;
- `отклонить`;
- `доработать: ...`.
4. После ответа Айдара агент либо выполняет согласованные правки, либо фиксирует, что задача отклонена/нужна доработка.
Более удобный вариант на будущее:
- добавить в Telegram-бота команду или сценарий согласования задач, например:
- `/approve <id>`;
- `/reject <id> причина`;
- `/revise <id> комментарий`.
Но для начала достаточно простого текстового согласования через Telegram.
## Что нужно от Айдара
Подтвердить, что такой порядок подходит:
1. Корневой `AGENTS.md` остается главным правилом проекта.
2. Для Solana, Telegram-агента и игроков сохраняются отдельные локальные правила.
3. Игроки не меняют код напрямую, а готовят материалы и предложения.
4. Изменения инструкций выполняются только после явного одобрения Айдара.
5. Уведомления Айдару на первом этапе можно делать простым текстом в Telegram, без отдельной сложной системы заявок.
## Ожидаемый результат
После одобрения:
- агенты будут стабильнее понимать границы проекта;
- снизится риск случайных изменений не в той части системы;
- появится понятный порядок согласования задач от игроков;
- Айдар будет явно контролировать изменения в инструкциях и правилах работы агентов.
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-19
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
.env
data/
logs/
run/
__pycache__/
-81
View File
@@ -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/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/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 — учти это и продолжи с учётом текущего состояния проекта.
-25
View File
@@ -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, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
-2
View File
@@ -1,2 +0,0 @@
@AGENTS.md
@AGENT.md
@@ -1,26 +0,0 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```
-100
View File
@@ -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/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/<username>/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|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
- `/voice_rewrite_off` — выключить адаптацию текста перед озвучкой.
- `/single_message_on` — вести ответ в личке через одно редактируемое сообщение.
- `/single_message_off` — слать отдельные сообщения по этапам и отдельный финальный ответ.
- `/restart` или `/restart_service` — отложенный рестарт после текущей задачи, до взятия следующей (только для Айдара).
- `/restart_hard` — жёсткий рестарт прямо сейчас (только для Айдара).
File diff suppressed because it is too large Load Diff
@@ -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"
@@ -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
@@ -641,6 +641,7 @@ public final class DatabaseInitializer {
origin_session_id TEXT,
receipt_ref_base_key TEXT,
receipt_ref_type INTEGER,
read_at_ms INTEGER,
FOREIGN KEY (from_login) REFERENCES solana_users(login),
FOREIGN KEY (to_login) REFERENCES solana_users(login)
);
@@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 11;
private static final int LATEST_SCHEMA_VERSION = 12;
private final String jdbcUrl;
@@ -94,6 +94,7 @@ public final class SqliteDbController {
case 9 -> migrateToV9();
case 10 -> migrateToV10();
case 11 -> migrateToV11();
case 12 -> migrateToV12();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@@ -329,6 +330,26 @@ public final class SqliteDbController {
}
}
private void migrateToV12() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureSignedMessagesReadAtColumn(c, st);
backfillSignedMessagesReadAt(st);
setSchemaVersion(c, 12);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v12 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v12 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
@@ -463,6 +484,33 @@ public final class SqliteDbController {
}
}
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
if (!tableExists(c, "signed_messages_v2")) return;
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
}
}
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
st.executeUpdate("""
UPDATE signed_messages_v2 AS content
SET read_at_ms = (
SELECT MIN(receipt.time_ms)
FROM signed_messages_v2 AS receipt
WHERE receipt.message_type IN (3, 4)
AND receipt.receipt_ref_base_key = content.base_key
)
WHERE content.message_type IN (1, 2)
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
AND EXISTS (
SELECT 1
FROM signed_messages_v2 AS receipt
WHERE receipt.message_type IN (3, 4)
AND receipt.receipt_ref_base_key = content.base_key
);
""");
}
/**
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
* старые строки signed_messages_v2 больше не гарантированно совместимы
@@ -7,10 +7,14 @@ import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public final class SignedMessagesV2DAO {
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
public enum ApplyStatus {
APPLIED,
DUPLICATE_OR_OLDER,
@@ -37,6 +41,7 @@ public final class SignedMessagesV2DAO {
}
public ApplyStatus insertIfAbsent(SignedMessageV2Entry 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;
@@ -46,17 +51,23 @@ public final class SignedMessagesV2DAO {
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
receipt_ref_base_key, receipt_ref_type, read_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
if (status.applied()) {
markMessageReadByReceipt(c, e);
}
return status;
}
}
});
}
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
@@ -64,6 +75,8 @@ public final class SignedMessagesV2DAO {
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;
}
@@ -79,9 +92,11 @@ public final class SignedMessagesV2DAO {
c.setAutoCommit(prevAutoCommit);
}
}
});
}
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
@@ -104,6 +119,8 @@ public final class SignedMessagesV2DAO {
upsertMessage(c, incoming);
upsertMessage(c, outgoing);
markMessageReadByReceipt(c, incoming);
markMessageReadByReceipt(c, outgoing);
resetDeliveryRows(c, incoming.getMessageKey());
resetDeliveryRows(c, outgoing.getMessageKey());
@@ -116,9 +133,11 @@ public final class SignedMessagesV2DAO {
c.setAutoCommit(prevAutoCommit);
}
}
});
}
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
@@ -140,6 +159,7 @@ public final class SignedMessagesV2DAO {
}
upsertMessage(c, incoming);
markMessageReadByReceipt(c, incoming);
resetDeliveryRows(c, incoming.getMessageKey());
c.commit();
return ApplyStatus.APPLIED;
@@ -150,9 +170,11 @@ public final class SignedMessagesV2DAO {
c.setAutoCommit(prevAutoCommit);
}
}
});
}
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
@@ -179,9 +201,11 @@ public final class SignedMessagesV2DAO {
c.setAutoCommit(prevAutoCommit);
}
}
});
}
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
boolean prevAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
@@ -205,6 +229,7 @@ public final class SignedMessagesV2DAO {
c.setAutoCommit(prevAutoCommit);
}
}
});
}
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
@@ -214,7 +239,7 @@ public final class SignedMessagesV2DAO {
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM signed_messages_v2
WHERE message_key = ?
""";
@@ -235,6 +260,12 @@ public final class SignedMessagesV2DAO {
}
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
ensureDeliveryRows(messageKey, List.of(sessionId), nowMs);
}
public void ensureDeliveryRows(String messageKey, List<String> sessionIds, long nowMs) throws Exception {
if (sessionIds == null || sessionIds.isEmpty()) return;
withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
String sql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
@@ -242,43 +273,49 @@ public final class SignedMessagesV2DAO {
) VALUES (?, ?, 0, NULL, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (String sessionId : sessionIds) {
if (sessionId == null || sessionId.isBlank()) continue;
ps.setString(1, messageKey);
ps.setString(2, sessionId);
ps.setLong(3, nowMs);
ps.executeUpdate();
ps.addBatch();
}
ps.executeBatch();
}
return null;
}
});
}
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
String insertSql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
String sql = """
INSERT INTO signed_message_session_delivery (
message_key, session_id, delivered, delivered_at_ms, created_at_ms
) VALUES (?, ?, 0, NULL, ?)
) 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(insertSql)) {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, messageKey);
ps.setString(2, sessionId);
ps.setLong(3, deliveredAtMs);
ps.setLong(4, 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();
}
return null;
}
});
}
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
return withBusyRetry(() -> {
try (Connection c = db.getConnection()) {
String fillSql = """
INSERT OR IGNORE INTO signed_message_session_delivery (
@@ -309,7 +346,7 @@ public final class SignedMessagesV2DAO {
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.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
FROM signed_messages_v2 m
JOIN signed_message_session_delivery d
ON d.message_key = m.message_key
@@ -325,6 +362,57 @@ public final class SignedMessagesV2DAO {
}
return out;
}
});
}
public List<SignedMessageV2Entry> 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 signed_messages_v2
WHERE target_login = ? COLLATE NOCASE
AND message_type IN (1, 2)
AND (
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
)
AND (
? <= 0
OR time_ms < ?
OR (time_ms = ? AND (? = '' OR message_key < ?))
)
ORDER BY time_ms DESC, message_key DESC
LIMIT ?
""";
List<SignedMessageV2Entry> 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;
}
}
private void upsertMessage(Connection c, SignedMessageV2Entry e) throws SQLException {
@@ -333,8 +421,8 @@ public final class SignedMessagesV2DAO {
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
receipt_ref_base_key, receipt_ref_type, read_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_key) DO UPDATE SET
base_key = excluded.base_key,
target_login = excluded.target_login,
@@ -350,7 +438,8 @@ public final class SignedMessagesV2DAO {
source_api = excluded.source_api,
origin_session_id = excluded.origin_session_id,
receipt_ref_base_key = excluded.receipt_ref_base_key,
receipt_ref_type = excluded.receipt_ref_type
receipt_ref_type = excluded.receipt_ref_type,
read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
@@ -358,6 +447,32 @@ public final class SignedMessagesV2DAO {
}
}
private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException {
if (entry == null) return;
int messageType = entry.getMessageType();
if (messageType != 3 && messageType != 4) return;
String receiptRefBaseKey = String.valueOf(entry.getReceiptRefBaseKey() == null ? "" : entry.getReceiptRefBaseKey()).trim();
if (receiptRefBaseKey.isEmpty()) return;
long readAtMs = entry.getTimeMs();
if (readAtMs <= 0) return;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE signed_messages_v2
SET read_at_ms = CASE
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
WHEN read_at_ms > ? THEN ?
ELSE read_at_ms
END
WHERE base_key = ?
AND message_type IN (1, 2)
""")) {
ps.setLong(1, readAtMs);
ps.setLong(2, readAtMs);
ps.setLong(3, readAtMs);
ps.setString(4, receiptRefBaseKey);
ps.executeUpdate();
}
}
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
String sql = """
SELECT revision_time_ms, reencrypted_at_ms
@@ -536,8 +651,8 @@ public final class SignedMessagesV2DAO {
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
receipt_ref_base_key, receipt_ref_type, read_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
bindSignedMessage(ps, e);
@@ -563,6 +678,8 @@ public final class SignedMessagesV2DAO {
ps.setString(15, e.getReceiptRefBaseKey());
if (e.getReceiptRefType() == null) ps.setObject(16, null);
else ps.setInt(16, e.getReceiptRefType());
if (e.getReadAtMs() == null) ps.setObject(17, null);
else ps.setLong(17, e.getReadAtMs());
}
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
@@ -586,6 +703,45 @@ public final class SignedMessagesV2DAO {
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
}
private boolean isBusyLock(SQLException ex) {
Throwable current = ex;
while (current != null) {
String msg = String.valueOf(current.getMessage()).toLowerCase();
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
return true;
}
current = current.getCause();
}
return false;
}
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
throw sqlEx;
}
}
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
SQLException lastBusy = null;
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
try {
return work.run();
} catch (SQLException ex) {
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
throw ex;
}
lastBusy = ex;
sleepBeforeBusyRetry(attempt);
}
}
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
}
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
if (revisionCompare != 0) return revisionCompare;
@@ -611,6 +767,8 @@ public final class SignedMessagesV2DAO {
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
int maybeRefType = rs.getInt("receipt_ref_type");
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
long maybeReadAt = rs.getLong("read_at_ms");
e.setReadAtMs(rs.wasNull() ? null : maybeReadAt);
return e;
}
@@ -619,4 +777,9 @@ public final class SignedMessagesV2DAO {
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
}
}
@FunctionalInterface
private interface SqlWork<T> {
T run() throws Exception;
}
}
@@ -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; }
}
@@ -91,6 +91,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
@@ -102,6 +103,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
@@ -200,6 +202,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
@@ -280,6 +283,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
@@ -51,8 +50,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
private static final SecureRandom RANDOM = new SecureRandom();
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
public static final long ALLOWED_SKEW_MS = 30_000L;
@Override
@@ -424,7 +421,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
// --- формируем ответ ---
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
@@ -44,8 +43,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
private static final long ALLOWED_SKEW_MS = 30_000L;
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
@@ -302,7 +299,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
ActiveConnectionsRegistry.getInstance().register(ctx);
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
// ответ
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
@@ -0,0 +1,98 @@
package server.logic.ws_protocol.JSON.messages;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesV2DAO;
import shine.db.entities.SignedMessageV2Entry;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class);
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetDirectMessages_Request req = (Net_GetDirectMessages_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getPeerLogin() == null || req.getPeerLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "peerLogin обязателен");
}
int limit = req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit();
if (limit <= 0 || limit > MAX_LIMIT) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_LIMIT", "limit должен быть в диапазоне 1.." + MAX_LIMIT);
}
String login = ctx.getLogin().trim();
String peerLogin = req.getPeerLogin().trim();
long beforeTimeMs = req.getBeforeTimeMs() == null ? 0L : req.getBeforeTimeMs();
String beforeMessageKey = req.getBeforeMessageKey() == null ? "" : req.getBeforeMessageKey().trim();
try {
List<SignedMessageV2Entry> page = SignedMessagesV2DAO.getInstance().listConversationPage(
login,
peerLogin,
beforeTimeMs,
beforeMessageKey,
limit + 1
);
boolean hasMore = page.size() > limit;
if (hasMore) {
page = new ArrayList<>(page.subList(0, limit));
}
Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setPeerLogin(peerLogin);
resp.setLimit(limit);
resp.setHasMore(hasMore);
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
for (SignedMessageV2Entry entry : page) {
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
item.setMessageKey(entry.getMessageKey());
item.setBaseKey(entry.getBaseKey());
item.setFromLogin(entry.getFromLogin());
item.setToLogin(entry.getToLogin());
item.setMessageType(entry.getMessageType());
item.setTimeMs(entry.getTimeMs());
item.setNonce(entry.getNonce());
item.setRevisionTimeMs(entry.getRevisionTimeMs());
item.setReencryptedAtMs(entry.getReencryptedAtMs());
item.setCreatedAtMs(entry.getCreatedAtMs());
item.setReadAtMs(entry.getReadAtMs());
item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock()));
items.add(item);
}
resp.setMessages(items);
if (hasMore && !items.isEmpty()) {
Net_GetDirectMessages_Response.MessageItem last = items.get(items.size() - 1);
resp.setNextBeforeTimeMs(last.getTimeMs());
resp.setNextBeforeMessageKey(last.getMessageKey());
}
return resp;
} catch (Exception e) {
log.error("GetDirectMessages failed for login={} peerLogin={}", login, peerLogin, e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
}
}
}
@@ -50,12 +50,20 @@ public final class SignedMessagesRealtime {
long now = System.currentTimeMillis();
for (String targetLogin : targetLoginsForMessage(message)) {
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
List<String> sessionIdsToTrack = new ArrayList<>();
for (ActiveSessionEntry s : sessions) {
String sessionId = s.getSessionId();
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
continue;
}
sessionIdsToTrack.add(sessionId);
}
SignedMessagesV2DAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now);
for (ActiveSessionEntry s : sessions) {
String sessionId = s.getSessionId();
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
continue;
}
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
if (deliveredOnline) {
counters.wsDelivered++;
@@ -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; }
}
@@ -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<MessageItem> 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<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> 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; }
}
}
+86
View File
@@ -0,0 +1,86 @@
# Third-party notices
This file lists third-party components and assets used by SHiNE. It is a practical attribution file for repository publication; individual dependency artifacts may include additional license text in their own packages.
## Twemoji
SHiNE UI uses Twemoji graphics for emoji rendering in the chat emoji picker and emoji-only messages.
- Source: https://github.com/twitter/twemoji
- Graphics license: Creative Commons Attribution 4.0 International (CC BY 4.0)
- Code license: MIT
- License text: https://creativecommons.org/licenses/by/4.0/
Attribution:
Emoji graphics by Twemoji, licensed under CC BY 4.0.
Changes in SHiNE:
- Emoji are stored and sent as normal Unicode text.
- Twemoji SVG graphics are used only as a visual rendering layer in the web UI.
- The UI loads Twemoji SVG assets from the pinned `twitter/twemoji@14.0.2` package via jsDelivr.
## QR Code Generator
SHiNE UI includes `shine-UI/js/vendor-qrcode-generator.js`.
- Project: QR Code Generator for JavaScript
- Copyright: Kazuhiko Arase
- License: MIT
- Source: http://www.d-project.com/
## SHiNE project-owned visual assets
SHiNE logos, icons and generated visual assets in `shine-UI/assets/` and `shine-UI/img/` are project-owned assets, unless a specific file says otherwise.
## Solana JavaScript libraries
SHiNE UI and Solana tooling use Solana JavaScript libraries, including `@solana/web3.js`.
- Package: `@solana/web3.js`
- License: MIT
- Source: https://github.com/solana-foundation/solana-web3.js
Some Solana JavaScript dependency trees include additional packages under permissive licenses such as MIT, Apache-2.0, BSD, ISC, 0BSD and CC0-1.0.
Known stricter dependency:
- Package: `rpc-websockets`
- License: LGPL-3.0-only
- Used transitively through Solana JavaScript dependencies in `shine-solana/shine` and `SHiNE-browser-plugin-wallet`.
## Noble cryptography libraries
SHiNE browser wallet/vendor bundles include Noble cryptography code.
- Packages: `@noble/curves`, `@noble/hashes`
- License: MIT
- Source: https://github.com/paulmillr/noble-curves and https://github.com/paulmillr/noble-hashes
## Java server dependencies
The SHiNE Java server uses third-party dependencies from Maven Central, including:
- Eclipse Jetty (`org.eclipse.jetty:*`) - EPL-2.0 / Apache-2.0 family licensing
- Bouncy Castle (`org.bouncycastle:bcprov-jdk18on`) - Bouncy Castle permissive license
- Jackson (`com.fasterxml.jackson.core:jackson-databind`) - Apache-2.0
- Logback (`ch.qos.logback:logback-classic`) - EPL-1.0 / LGPL-2.1
- SLF4J (`org.slf4j:slf4j-api`) - MIT
- SQLite JDBC (`org.xerial:sqlite-jdbc`) - Apache-2.0
- Web Push Java library (`nl.martijndwars:web-push`) - Apache-2.0
- JUnit (`org.junit:*`) - EPL-2.0
## Gradle Wrapper
The repository includes Gradle wrapper scripts.
- License: Apache License 2.0
- Source: https://gradle.org/
## Espressif code snippets
The ESP32 prototype area includes Espressif audio codec helper files with SPDX/license headers.
- Files include `ESPRESSIF MIT License` and `SPDX-License-Identifier: Apache-2.0` notices.
- Original copyright notices are preserved in the source files.
+1
View File
@@ -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_запуск
@@ -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** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
- **Аутентификация** — схема `AuthChallenge``CreateAuthSession` через Ed25519 уже полностью реализована.
## Оценка сложности
| Компонент | Сложность |
|---|---|
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
| ESP32: обработчик входящих + UI подтверждений | Средняя |
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
| Браузерное расширение | Высокая (отдельный этап) |
**Итого фазы ESP32 + сервер: ~11.5 недели.**
## С чего начинать
1. Серверная часть проще и быстрее — начать с добавления `sessionType` и `DeviceApprovalRequest/Response`.
2. Затем ESP32: WiFi → WebSocket → авторизация → обработчик входящих → UI.
3. Браузерное расширение — отдельная итерация после того как ESP32 + сервер работают.
@@ -0,0 +1,36 @@
# Переход с SQLite на PostgreSQL
## Зачем
Текущая серверная база на `SQLite` удобна для простого односерверного режима, но она хуже подходит для большого числа параллельных записей, роста нагрузки и дальнейшего масштабирования сервера.
`PostgreSQL` нужен как следующий уровень серверной БД для более надёжной конкурентной записи, более предсказуемой работы под нагрузкой и дальнейшего роста проекта.
## Что сделать
- Подготовить план переноса серверной БД с `SQLite` на `PostgreSQL`.
- Найти все места, где код завязан на особенности `SQLite`.
- Проверить все DAO и SQL-запросы на совместимость с `PostgreSQL`.
- Продумать схему миграции существующей production/test базы без потери данных.
- Отдельно проверить транзакции, `UPSERT`, индексы, case-insensitive сравнения и миграции схемы.
- После этого подготовить отдельный этап внедрения и переключения сервера.
## Что уже есть в коде
- Доступ к БД в основном проходит через DAO-слой, а не полностью размазан по проекту.
- Основная серверная логика уже разделена по модулям.
- Но SQL и миграции сейчас написаны под `SQLite` и потребуют отдельного прохода.
## Откуда продолжать
- Начать с инвентаризации всех DAO и схемы БД.
- После этого сделать отдельный документ с оценкой объёма работ по переносу.
- Затем решить, будет ли это:
- полный перевод сервера на `PostgreSQL`;
- или поддержка двух драйверов на переходный период.
## Что потом обновить
- Серверную документацию по БД и миграциям.
- Инструкции по локальному запуску сервера.
- Скрипты деплоя и настройки окружения.
-41
View File
@@ -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 на будущее:
- временная межсерверная авторизация/сессия для старта звонка,
- отправка сигнальных сообщений между разными серверами пользователей,
- аккуратное завершение временной сессии после установления/завершения звонка.
@@ -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`) для офлайн-сессий.
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.331
server.version=1.2.303
client.version=1.3.0
server.version=1.3.0
-31
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
.env
data/
logs/
run/
__pycache__/
-86
View File
@@ -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 его не пропустит.
@@ -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
@@ -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/<username>/`.
- Архив истории после `/new`: `data/history/<username>/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|all>` — удалить задачу по id или очистить очередь.
- `/restart` и `/restart_service` — отложенный рестарт после текущей задачи.
- `/restart_hard`, `/restart_now`, `/restart_force` — жёсткий рестарт прямо сейчас.
## Правила ответа
- Пиши содержательно и коротко.
- Не упоминай внутренние служебные детали, файловую систему и технические логи, если это не нужно пользователю.
- Если запрос требует действий с кодом или файлами, выполняй их в рабочей директории `CODEX_WORKDIR`.
- Если данных недостаточно, задай ровно один уточняющий вопрос.
- Если в промпте есть пометка retry, учитывай текущее состояние и продолжай аккуратно, а не начинай заново без причины.
File diff suppressed because it is too large Load Diff
@@ -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
-32
View File
@@ -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`
-246
View File
@@ -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 <<JSON
{
"name": "${REPO_NAME}",
"private": ${REPO_PRIVATE},
"auto_init": false
}
JSON
)"
)"
if [[ "$http_code" != "201" ]]; then
echo
echo "GitHub API вернул ошибку. HTTP code: $http_code"
echo "Ответ сервера:"
cat "$response_body_file"
rm -f "$response_body_file"
abort "Не удалось создать репозиторий '${GITHUB_USER}/${REPO_NAME}'."
fi
rm -f "$response_body_file"
echo "Репозиторий успешно создан: https://github.com/${GITHUB_USER}/${REPO_NAME}"
}
get_script_paths() {
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
PROJECT_PATH="$(pwd -P)"
SCRIPT_INSIDE_PROJECT="false"
SCRIPT_RELATIVE_PATH=""
case "$SCRIPT_PATH" in
"$PROJECT_PATH"/*)
SCRIPT_INSIDE_PROJECT="true"
SCRIPT_RELATIVE_PATH="${SCRIPT_PATH#$PROJECT_PATH/}"
;;
*)
SCRIPT_INSIDE_PROJECT="false"
;;
esac
}
prepare_git_repo() {
echo
echo "Подготавливаю git в текущей папке..."
if git rev-parse --is-inside-work-tree >/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 "$@"
+10 -4
View File
@@ -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
```
Скрипт:
@@ -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/"
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] Создаю манифест"
{
+1 -1
View File
@@ -8,7 +8,7 @@ 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/" \
TARGET_URL="https://server2.shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
+1 -1
View File
@@ -8,7 +8,7 @@ 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/" \
TARGET_URL="https://shineup.me" \
REQUIRE_BACKUP="1" \
bash "$(dirname "$0")/deploy_ui.sh"
+1
View File
@@ -62,6 +62,7 @@
| `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 удаления истории переписки |
| `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` | сигнал звонка в конкретную сессию |
+68 -3
View File
@@ -176,7 +176,72 @@
}
```
## 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. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию.
@@ -192,7 +257,7 @@
}
```
## 8. Событие `SignedMessageArrived`
## 9. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
@@ -217,7 +282,7 @@
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 9. `CallInviteBroadcast`
## 10. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
@@ -509,6 +509,35 @@ UI-следствие для клиента:
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
### 10.5. `GetDirectMessages`
Назначение:
- постранично отдавать историю одного личного диалога;
- не вываливать весь старый backlog автоматически в момент логина;
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
Правила:
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
- клиент сам запрашивает первую страницу истории у нужного собеседника;
- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`;
- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`.
Содержимое страницы:
- сервер возвращает только контентные DM типов `1/2`;
- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются;
- сообщения идут страницами от новых к старым;
- лимит страницы считается по самим chat-сообщениям диалога.
Поле прочтения:
- для каждого контентного сообщения сервер может вернуть `readAtMs`;
- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу;
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
@@ -547,9 +576,12 @@ UI-следствие для клиента:
В ней должны сохраняться:
- обычные контентные DM;
- read-receipt DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
-17
View File
@@ -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).
-9
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Some files were not shown because too many files have changed in this diff Show More