Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
a183a586cb | ||
|
|
af62b51ef5 | ||
|
|
f640451257 | ||
|
|
a56252c361 | ||
|
|
38141ea5c7 | ||
|
|
d24d1c178b | ||
|
|
93673e5786 | ||
|
|
2f3b1571e5 | ||
|
|
65703f0fc4 | ||
|
|
4e674af4ed | ||
|
|
f9c2d7c63a | ||
|
|
968d85d738 | ||
|
|
d230b61c0b | ||
|
|
59047a8d0e | ||
|
|
81493ac4be |
@@ -106,7 +106,9 @@ ESP32/**/*.a
|
|||||||
deploy/backup/archive/**
|
deploy/backup/archive/**
|
||||||
!deploy/backup/archive/.gitkeep
|
!deploy/backup/archive/.gitkeep
|
||||||
|
|
||||||
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
|
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
.claude/
|
.claude/
|
||||||
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
||||||
*.bak.png
|
*.bak.png
|
||||||
|
|||||||
@@ -14,14 +14,15 @@
|
|||||||
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||||
- точка входа `shine-UI/server-ui.html`;
|
- точка входа `shine-UI/server-ui.html`;
|
||||||
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||||
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
|
||||||
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||||
|
|
||||||
## Сервис агента-кодера
|
## Сервис агента-кодера
|
||||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
|
||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
|
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
|
||||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
|
||||||
|
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||||
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
||||||
|
|
||||||
## ESP32 UI homeserver
|
## ESP32 UI homeserver
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@shine-UI/AGENTS.md
|
@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-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
||||||
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||||
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.env
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
run/
|
|
||||||
__pycache__/
|
|
||||||
@@ -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 — учти это и продолжи с учётом текущего состояния проекта.
|
|
||||||
@@ -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, когда это уместно.
|
|
||||||
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@AGENT.md
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Промпты для режима игроков (на согласование)
|
|
||||||
|
|
||||||
## 1) Базовый служебный промпт (добавка к задаче игрока)
|
|
||||||
|
|
||||||
```text
|
|
||||||
Режим игрока (обязательно):
|
|
||||||
- Пользователь: <Имя> (@<username>).
|
|
||||||
- Рабочая папка игрока: <project>/Players/<username>
|
|
||||||
- Код проекта не изменять.
|
|
||||||
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
|
|
||||||
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2) Приветственное сообщение игроку (один раз)
|
|
||||||
|
|
||||||
```text
|
|
||||||
Привет, <Имя>.
|
|
||||||
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
|
|
||||||
Команда /new начинает новую сессию и архивирует текущую историю.
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3) Отказ неизвестному пользователю
|
|
||||||
|
|
||||||
```text
|
|
||||||
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
|
|
||||||
```
|
|
||||||
@@ -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` — жёсткий рестарт прямо сейчас (только для Айдара).
|
|
||||||
@@ -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,
|
origin_session_id TEXT,
|
||||||
receipt_ref_base_key TEXT,
|
receipt_ref_base_key TEXT,
|
||||||
receipt_ref_type INTEGER,
|
receipt_ref_type INTEGER,
|
||||||
|
read_at_ms INTEGER,
|
||||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||||
FOREIGN KEY (to_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 {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
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;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ public final class SqliteDbController {
|
|||||||
case 9 -> migrateToV9();
|
case 9 -> migrateToV9();
|
||||||
case 10 -> migrateToV10();
|
case 10 -> migrateToV10();
|
||||||
case 11 -> migrateToV11();
|
case 11 -> migrateToV11();
|
||||||
|
case 12 -> migrateToV12();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
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 {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
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:
|
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
||||||
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
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 {
|
public enum ApplyStatus {
|
||||||
APPLIED,
|
APPLIED,
|
||||||
DUPLICATE_OR_OLDER,
|
DUPLICATE_OR_OLDER,
|
||||||
@@ -37,174 +41,195 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
try (Connection c = db.getConnection()) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||||
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
|
}
|
||||||
|
String sql = """
|
||||||
|
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||||
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
bindSignedMessage(ps, e);
|
||||||
|
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
if (status.applied()) {
|
||||||
|
markMessageReadByReceipt(c, e);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
String sql = """
|
});
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
|
||||||
receipt_ref_base_key, receipt_ref_type
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
|
||||||
bindSignedMessage(ps, e);
|
|
||||||
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
try {
|
c.setAutoCommit(false);
|
||||||
int insertedFirst = insertStrict(c, first);
|
try {
|
||||||
int insertedSecond = insertStrict(c, second);
|
int insertedFirst = insertStrict(c, first);
|
||||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
int insertedSecond = insertStrict(c, second);
|
||||||
c.commit();
|
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||||
return true;
|
markMessageReadByReceipt(c, first);
|
||||||
}
|
markMessageReadByReceipt(c, second);
|
||||||
c.rollback();
|
c.commit();
|
||||||
return false;
|
return true;
|
||||||
} catch (SQLException sqlEx) {
|
}
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
c.rollback();
|
||||||
if (isConstraintViolation(sqlEx)) {
|
|
||||||
return false;
|
return false;
|
||||||
|
} catch (SQLException sqlEx) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (isConstraintViolation(sqlEx)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw sqlEx;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
throw sqlEx;
|
|
||||||
} finally {
|
|
||||||
c.setAutoCommit(prevAutoCommit);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
try {
|
c.setAutoCommit(false);
|
||||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
try {
|
||||||
c.rollback();
|
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
}
|
||||||
c.rollback();
|
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||||
|
}
|
||||||
|
|
||||||
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMessage(c, incoming);
|
||||||
|
upsertMessage(c, outgoing);
|
||||||
|
markMessageReadByReceipt(c, incoming);
|
||||||
|
markMessageReadByReceipt(c, outgoing);
|
||||||
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
|
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return ApplyStatus.APPLIED;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertMessage(c, incoming);
|
|
||||||
upsertMessage(c, outgoing);
|
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
|
||||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
return ApplyStatus.APPLIED;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw ex;
|
|
||||||
} finally {
|
|
||||||
c.setAutoCommit(prevAutoCommit);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
try {
|
c.setAutoCommit(false);
|
||||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
try {
|
||||||
c.rollback();
|
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
}
|
||||||
c.rollback();
|
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||||
|
}
|
||||||
|
|
||||||
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertMessage(c, incoming);
|
upsertMessage(c, incoming);
|
||||||
resetDeliveryRows(c, incoming.getMessageKey());
|
markMessageReadByReceipt(c, incoming);
|
||||||
c.commit();
|
resetDeliveryRows(c, incoming.getMessageKey());
|
||||||
return ApplyStatus.APPLIED;
|
c.commit();
|
||||||
} catch (Exception ex) {
|
return ApplyStatus.APPLIED;
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
} catch (Exception ex) {
|
||||||
throw ex;
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
} finally {
|
throw ex;
|
||||||
c.setAutoCommit(prevAutoCommit);
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
try {
|
c.setAutoCommit(false);
|
||||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
try {
|
||||||
c.rollback();
|
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
}
|
||||||
c.rollback();
|
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
c.rollback();
|
||||||
}
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||||
upsertMessage(c, tombstone);
|
upsertMessage(c, tombstone);
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
|
||||||
c.commit();
|
c.commit();
|
||||||
return ApplyStatus.APPLIED;
|
return ApplyStatus.APPLIED;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
throw ex;
|
throw ex;
|
||||||
} finally {
|
} finally {
|
||||||
c.setAutoCommit(prevAutoCommit);
|
c.setAutoCommit(prevAutoCommit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
boolean prevAutoCommit = c.getAutoCommit();
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
try {
|
c.setAutoCommit(false);
|
||||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
try {
|
||||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||||
c.rollback();
|
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
c.rollback();
|
||||||
|
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||||
|
upsertMessage(c, tombstone);
|
||||||
|
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||||
|
|
||||||
|
c.commit();
|
||||||
|
return ApplyStatus.APPLIED;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(prevAutoCommit);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
|
||||||
upsertMessage(c, tombstone);
|
|
||||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
|
||||||
|
|
||||||
c.commit();
|
|
||||||
return ApplyStatus.APPLIED;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
try { c.rollback(); } catch (Exception ignored) {}
|
|
||||||
throw ex;
|
|
||||||
} finally {
|
|
||||||
c.setAutoCommit(prevAutoCommit);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
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,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
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
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
""";
|
""";
|
||||||
@@ -235,90 +260,153 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
ensureDeliveryRows(messageKey, List.of(sessionId), nowMs);
|
||||||
String sql = """
|
}
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
public void ensureDeliveryRows(String messageKey, List<String> sessionIds, long nowMs) throws Exception {
|
||||||
) VALUES (?, ?, 0, NULL, ?)
|
if (sessionIds == null || sessionIds.isEmpty()) return;
|
||||||
""";
|
withBusyRetry(() -> {
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (Connection c = db.getConnection()) {
|
||||||
ps.setString(1, messageKey);
|
String sql = """
|
||||||
ps.setString(2, sessionId);
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
ps.setLong(3, nowMs);
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
ps.executeUpdate();
|
) VALUES (?, ?, 0, NULL, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (String sessionId : sessionIds) {
|
||||||
|
if (sessionId == null || sessionId.isBlank()) continue;
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.setString(2, sessionId);
|
||||||
|
ps.setLong(3, nowMs);
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
withBusyRetry(() -> {
|
||||||
String insertSql = """
|
try (Connection c = db.getConnection()) {
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
String sql = """
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
INSERT INTO signed_message_session_delivery (
|
||||||
) VALUES (?, ?, 0, NULL, ?)
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
""";
|
) VALUES (?, ?, 1, ?, ?)
|
||||||
try (PreparedStatement ps = c.prepareStatement(insertSql)) {
|
ON CONFLICT(message_key, session_id) DO UPDATE SET
|
||||||
ps.setString(1, messageKey);
|
delivered = 1,
|
||||||
ps.setString(2, sessionId);
|
delivered_at_ms = CASE
|
||||||
ps.setLong(3, deliveredAtMs);
|
WHEN signed_message_session_delivery.delivered_at_ms IS NULL THEN excluded.delivered_at_ms
|
||||||
ps.executeUpdate();
|
WHEN signed_message_session_delivery.delivered_at_ms > excluded.delivered_at_ms THEN excluded.delivered_at_ms
|
||||||
|
ELSE signed_message_session_delivery.delivered_at_ms
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, messageKey);
|
||||||
|
ps.setString(2, sessionId);
|
||||||
|
ps.setLong(3, deliveredAtMs);
|
||||||
|
ps.setLong(4, deliveredAtMs);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
String updateSql = """
|
|
||||||
UPDATE signed_message_session_delivery
|
|
||||||
SET delivered = 1, delivered_at_ms = ?
|
|
||||||
WHERE message_key = ? AND session_id = ?
|
|
||||||
""";
|
|
||||||
try (PreparedStatement ps = c.prepareStatement(updateSql)) {
|
|
||||||
ps.setLong(1, deliveredAtMs);
|
|
||||||
ps.setString(2, messageKey);
|
|
||||||
ps.setString(3, sessionId);
|
|
||||||
ps.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
return withBusyRetry(() -> {
|
||||||
String fillSql = """
|
try (Connection c = db.getConnection()) {
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
String fillSql = """
|
||||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
)
|
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||||
SELECT m.message_key, ?, 0, NULL, ?
|
)
|
||||||
FROM signed_messages_v2 m
|
SELECT m.message_key, ?, 0, NULL, ?
|
||||||
WHERE (
|
FROM signed_messages_v2 m
|
||||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
WHERE (
|
||||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||||
OR (m.message_type IN (5, 6, 7, 8)
|
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
OR (m.message_type IN (5, 6, 7, 8)
|
||||||
)
|
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||||
""";
|
)
|
||||||
long now = System.currentTimeMillis();
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
long now = System.currentTimeMillis();
|
||||||
ps.setString(1, sessionId);
|
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||||
ps.setLong(2, now);
|
ps.setString(1, sessionId);
|
||||||
ps.setString(3, login);
|
ps.setLong(2, now);
|
||||||
ps.setString(4, login);
|
ps.setString(3, login);
|
||||||
ps.setString(5, login);
|
ps.setString(4, login);
|
||||||
ps.setString(6, login);
|
ps.setString(5, login);
|
||||||
ps.executeUpdate();
|
ps.setString(6, login);
|
||||||
}
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||||
|
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||||
|
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||||
|
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
||||||
|
FROM signed_messages_v2 m
|
||||||
|
JOIN signed_message_session_delivery d
|
||||||
|
ON d.message_key = m.message_key
|
||||||
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
|
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||||
|
""";
|
||||||
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, sessionId);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SignedMessageV2Entry> listConversationPage(
|
||||||
|
String login,
|
||||||
|
String peerLogin,
|
||||||
|
long beforeTimeMs,
|
||||||
|
String beforeMessageKey,
|
||||||
|
int limit
|
||||||
|
) throws Exception {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
m.receipt_ref_base_key, m.receipt_ref_type
|
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||||
FROM signed_messages_v2 m
|
FROM signed_messages_v2
|
||||||
JOIN signed_message_session_delivery d
|
WHERE target_login = ? COLLATE NOCASE
|
||||||
ON d.message_key = m.message_key
|
AND message_type IN (1, 2)
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
AND (
|
||||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
(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<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, login);
|
||||||
|
ps.setString(2, login);
|
||||||
|
ps.setString(3, peerLogin);
|
||||||
|
ps.setString(4, peerLogin);
|
||||||
|
ps.setString(5, login);
|
||||||
|
ps.setLong(6, beforeTimeMs);
|
||||||
|
ps.setLong(7, beforeTimeMs);
|
||||||
|
ps.setLong(8, beforeTimeMs);
|
||||||
|
ps.setString(9, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||||
|
ps.setString(10, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||||
|
ps.setInt(11, limit);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
@@ -333,8 +421,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
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
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(message_key) DO UPDATE SET
|
ON CONFLICT(message_key) DO UPDATE SET
|
||||||
base_key = excluded.base_key,
|
base_key = excluded.base_key,
|
||||||
target_login = excluded.target_login,
|
target_login = excluded.target_login,
|
||||||
@@ -350,7 +438,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
source_api = excluded.source_api,
|
source_api = excluded.source_api,
|
||||||
origin_session_id = excluded.origin_session_id,
|
origin_session_id = excluded.origin_session_id,
|
||||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
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)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
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 {
|
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT revision_time_ms, reencrypted_at_ms
|
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,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
raw_block, created_at_ms, source_api, origin_session_id,
|
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
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -563,6 +678,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
ps.setString(15, e.getReceiptRefBaseKey());
|
ps.setString(15, e.getReceiptRefBaseKey());
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||||
else ps.setInt(16, e.getReceiptRefType());
|
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 {
|
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");
|
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) {
|
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||||
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||||
if (revisionCompare != 0) return revisionCompare;
|
if (revisionCompare != 0) return revisionCompare;
|
||||||
@@ -611,6 +767,8 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||||
|
long maybeReadAt = rs.getLong("read_at_ms");
|
||||||
|
e.setReadAtMs(rs.wasNull() ? null : maybeReadAt);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,4 +777,9 @@ public final class SignedMessagesV2DAO {
|
|||||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
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 originSessionId;
|
||||||
private String receiptRefBaseKey;
|
private String receiptRefBaseKey;
|
||||||
private Integer receiptRefType;
|
private Integer receiptRefType;
|
||||||
|
private Long readAtMs;
|
||||||
|
|
||||||
public String getMessageKey() { return messageKey; }
|
public String getMessageKey() { return messageKey; }
|
||||||
public void setMessageKey(String messageKey) { this.messageKey = 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 void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
||||||
public Integer getReceiptRefType() { return receiptRefType; }
|
public Integer getReceiptRefType() { return receiptRefType; }
|
||||||
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = 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_CallSignalToSession_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_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_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_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_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_CallSignalToSession_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_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_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_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_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("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||||
|
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_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("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||||
Map.entry("DeleteConversation", Net_DeleteConversation_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("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_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.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
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.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.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
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 Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
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;
|
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -424,7 +421,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
|||||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
|
||||||
|
|
||||||
// --- формируем ответ ---
|
// --- формируем ответ ---
|
||||||
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
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.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
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.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.AuthKeyUtils;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
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 Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
||||||
|
|
||||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||||
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||||
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
|
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);
|
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||||
|
|
||||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
|
||||||
|
|
||||||
// ответ
|
// ответ
|
||||||
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
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();
|
long now = System.currentTimeMillis();
|
||||||
for (String targetLogin : targetLoginsForMessage(message)) {
|
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
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) {
|
for (ActiveSessionEntry s : sessions) {
|
||||||
String sessionId = s.getSessionId();
|
String sessionId = s.getSessionId();
|
||||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
|
||||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||||
if (deliveredOnline) {
|
if (deliveredOnline) {
|
||||||
counters.wsDelivered++;
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
- `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||||
|
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - подготовить перевод серверной БД с `SQLite` на `PostgreSQL` для более серьёзной конкурентной нагрузки и дальнейшего масштабирования.
|
||||||
|
|
||||||
### dao_запуск
|
### 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 + сервер: ~1–1.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`;
|
||||||
|
- или поддержка двух драйверов на переходный период.
|
||||||
|
|
||||||
|
## Что потом обновить
|
||||||
|
|
||||||
|
- Серверную документацию по БД и миграциям.
|
||||||
|
- Инструкции по локальному запуску сервера.
|
||||||
|
- Скрипты деплоя и настройки окружения.
|
||||||
@@ -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`) для офлайн-сессий.
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.331
|
client.version=1.2.344
|
||||||
server.version=1.2.303
|
server.version=1.2.316
|
||||||
|
|||||||
@@ -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,5 +0,0 @@
|
|||||||
.env
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
run/
|
|
||||||
__pycache__/
|
|
||||||
@@ -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, учитывай текущее состояние и продолжай аккуратно, а не начинай заново без причины.
|
|
||||||
@@ -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
|
|
||||||
@@ -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`
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Где находится сервис
|
## Где находится сервис
|
||||||
|
|
||||||
- Папка сервиса: `SHiNE-agent-bot-coder/`
|
- Папка сервиса находится рядом с репозиторием продукта: `../SHiNE-agent-bot-coder/`
|
||||||
- Systemd unit: `SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
|
- 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/scripts/systemd/install-local-systemd.sh`
|
||||||
|
|
||||||
## Предусловия
|
## Предусловия
|
||||||
|
|
||||||
@@ -18,7 +18,13 @@
|
|||||||
Из корня репозитория:
|
Из корня репозитория:
|
||||||
|
|
||||||
```bash
|
```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}"
|
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}"
|
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
|
||||||
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
|
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' \
|
--exclude='**/*.log' \
|
||||||
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
|
"${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/"
|
copy_optional_dir "/home/player/sites" "${DEST_DIR}/home-player/sites"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/"
|
copy_optional_dir "/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/agent-memory" "${DEST_DIR}/home-player/agent-memory"
|
||||||
|
|
||||||
echo "[3/4] Копирую системные конфиги"
|
echo "[3/4] Копирую системные конфиги"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/"
|
copy_optional_dir "/etc/caddy" "${DEST_DIR}/etc-system/caddy"
|
||||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/"
|
copy_optional_dir "/var/lib/caddy" "${DEST_DIR}/var-lib/caddy"
|
||||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
copy_optional_file "/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/"
|
copy_optional_file "/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/shine-server.service"
|
||||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/"
|
copy_optional_file "/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/agent-memory.service"
|
||||||
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"
|
if ssh "${REMOTE_HOST}" 'command -v docker >/dev/null 2>&1 && sudo -n docker image inspect gitea/gitea:1.22.6 >/dev/null 2>&1'; then
|
||||||
|
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
||||||
|
else
|
||||||
|
echo "missing: docker image gitea/gitea:1.22.6" > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar.MISSING.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[4/4] Создаю манифест"
|
echo "[4/4] Создаю манифест"
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||||
| `DeleteConversation` | `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` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
||||||
|
|||||||
@@ -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 в активные сессии адресата.
|
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||||
|
|
||||||
@@ -217,7 +282,7 @@
|
|||||||
|
|
||||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||||
|
|
||||||
## 9. `CallInviteBroadcast`
|
## 10. `CallInviteBroadcast`
|
||||||
|
|
||||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,35 @@ UI-следствие для клиента:
|
|||||||
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
||||||
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
|
- отдельное 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. Межсерверная доставка
|
||||||
|
|
||||||
### 11.1. Клиентская сторона
|
### 11.1. Клиентская сторона
|
||||||
@@ -547,9 +576,12 @@ UI-следствие для клиента:
|
|||||||
В ней должны сохраняться:
|
В ней должны сохраняться:
|
||||||
|
|
||||||
- обычные контентные DM;
|
- обычные контентные DM;
|
||||||
|
- read-receipt DM;
|
||||||
- tombstone одного сообщения;
|
- tombstone одного сообщения;
|
||||||
- tombstone удаления переписки.
|
- tombstone удаления переписки.
|
||||||
|
|
||||||
|
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
|
||||||
|
|
||||||
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
|
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
|
||||||
|
|
||||||
- защищать от повторного приёма старых версий;
|
- защищать от повторного приёма старых версий;
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |