Compare commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
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/.gitkeep
|
||||
|
||||
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
.codex/
|
||||
.claude/
|
||||
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
|
||||
*.bak.png
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||
- точка входа `shine-UI/server-ui.html`;
|
||||
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
||||
- Локальный Telegram-бот агента-кодера живёт рядом с репозиторием продукта, обычно в `../SHiNE-agent-bot-coder/`, и не входит в публичный код основного приложения.
|
||||
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||
|
||||
## Сервис агента-кодера
|
||||
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
||||
- Локальный Telegram-бот-сервис агента-кодера находится вне этого git-репозитория, обычно в `../SHiNE-agent-bot-coder/`.
|
||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
|
||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- Рабочая папка Codex для сервиса должна указывать на этот продуктовый репозиторий: `CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product`.
|
||||
- Автоматически читаемые инструкции для Codex внутри сервиса держать в `../SHiNE-agent-bot-coder/AGENTS.md`.
|
||||
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
|
||||
|
||||
## ESP32 UI homeserver
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@shine-UI/AGENTS.md
|
||||
|
||||
## Справка по подпроектам
|
||||
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- При работе с локальным агентом-кодером — читать внешние файлы `../SHiNE-agent-bot-coder/AGENTS.md` и `../SHiNE-agent-bot-coder/AGENT.md`.
|
||||
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
|
||||
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||
|
||||
@@ -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,
|
||||
receipt_ref_base_key TEXT,
|
||||
receipt_ref_type INTEGER,
|
||||
read_at_ms INTEGER,
|
||||
FOREIGN KEY (from_login) REFERENCES solana_users(login),
|
||||
FOREIGN KEY (to_login) REFERENCES solana_users(login)
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.sql.Statement;
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 11;
|
||||
private static final int LATEST_SCHEMA_VERSION = 12;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
@@ -94,6 +94,7 @@ public final class SqliteDbController {
|
||||
case 9 -> migrateToV9();
|
||||
case 10 -> migrateToV10();
|
||||
case 11 -> migrateToV11();
|
||||
case 12 -> migrateToV12();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
@@ -329,6 +330,26 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV12() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesReadAtColumn(c, st);
|
||||
backfillSignedMessagesReadAt(st);
|
||||
setSchemaVersion(c, 12);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v12 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v12 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
@@ -463,6 +484,33 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesReadAtColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "read_at_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN read_at_ms INTEGER");
|
||||
}
|
||||
}
|
||||
|
||||
private static void backfillSignedMessagesReadAt(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
UPDATE signed_messages_v2 AS content
|
||||
SET read_at_ms = (
|
||||
SELECT MIN(receipt.time_ms)
|
||||
FROM signed_messages_v2 AS receipt
|
||||
WHERE receipt.message_type IN (3, 4)
|
||||
AND receipt.receipt_ref_base_key = content.base_key
|
||||
)
|
||||
WHERE content.message_type IN (1, 2)
|
||||
AND (content.read_at_ms IS NULL OR content.read_at_ms <= 0)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM signed_messages_v2 AS receipt
|
||||
WHERE receipt.message_type IN (3, 4)
|
||||
AND receipt.receipt_ref_base_key = content.base_key
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/**
|
||||
* Временная одноразовая миграция на переходе к SHiNE_DM v1:
|
||||
* старые строки signed_messages_v2 больше не гарантированно совместимы
|
||||
|
||||
@@ -7,10 +7,14 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
private static final int SQLITE_BUSY_MAX_RETRIES = 6;
|
||||
private static final long SQLITE_BUSY_RETRY_BASE_DELAY_MS = 40L;
|
||||
|
||||
public enum ApplyStatus {
|
||||
APPLIED,
|
||||
DUPLICATE_OR_OLDER,
|
||||
@@ -37,174 +41,195 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
|
||||
public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) {
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
ApplyStatus status = ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
if (status.applied()) {
|
||||
markMessageReadByReceipt(c, e);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean insertPairBothOrNothing(SignedMessageV2Entry first, SignedMessageV2Entry second) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
int insertedFirst = insertStrict(c, first);
|
||||
int insertedSecond = insertStrict(c, second);
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
c.rollback();
|
||||
return false;
|
||||
} catch (SQLException sqlEx) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (isConstraintViolation(sqlEx)) {
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
int insertedFirst = insertStrict(c, first);
|
||||
int insertedSecond = insertStrict(c, second);
|
||||
if (insertedFirst == 1 && insertedSecond == 1) {
|
||||
markMessageReadByReceipt(c, first);
|
||||
markMessageReadByReceipt(c, second);
|
||||
c.commit();
|
||||
return true;
|
||||
}
|
||||
c.rollback();
|
||||
return false;
|
||||
} catch (SQLException sqlEx) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (isConstraintViolation(sqlEx)) {
|
||||
return false;
|
||||
}
|
||||
throw sqlEx;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
throw sqlEx;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
upsertMessage(c, outgoing);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
markMessageReadByReceipt(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
upsertMessage(c, outgoing);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
resetDeliveryRows(c, outgoing.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE;
|
||||
}
|
||||
|
||||
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey());
|
||||
RevisionMarker nextMarker = RevisionMarker.of(incoming);
|
||||
if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
upsertMessage(c, incoming);
|
||||
markMessageReadByReceipt(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||
c.rollback();
|
||||
return ApplyStatus.DUPLICATE_OR_OLDER;
|
||||
}
|
||||
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return ApplyStatus.APPLIED;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
@@ -214,7 +239,7 @@ public final class SignedMessagesV2DAO {
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
""";
|
||||
@@ -235,90 +260,153 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
|
||||
public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.executeUpdate();
|
||||
ensureDeliveryRows(messageKey, List.of(sessionId), nowMs);
|
||||
}
|
||||
|
||||
public void ensureDeliveryRows(String messageKey, List<String> sessionIds, long nowMs) throws Exception {
|
||||
if (sessionIds == null || sessionIds.isEmpty()) return;
|
||||
withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (String sessionId : sessionIds) {
|
||||
if (sessionId == null || sessionId.isBlank()) continue;
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, nowMs);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void markDelivered(String messageKey, String sessionId, long deliveredAtMs) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String insertSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 0, NULL, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(insertSql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, deliveredAtMs);
|
||||
ps.executeUpdate();
|
||||
withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
INSERT INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(message_key, session_id) DO UPDATE SET
|
||||
delivered = 1,
|
||||
delivered_at_ms = CASE
|
||||
WHEN signed_message_session_delivery.delivered_at_ms IS NULL THEN excluded.delivered_at_ms
|
||||
WHEN signed_message_session_delivery.delivered_at_ms > excluded.delivered_at_ms THEN excluded.delivered_at_ms
|
||||
ELSE signed_message_session_delivery.delivered_at_ms
|
||||
END
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, messageKey);
|
||||
ps.setString(2, sessionId);
|
||||
ps.setLong(3, deliveredAtMs);
|
||||
ps.setLong(4, deliveredAtMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String updateSql = """
|
||||
UPDATE signed_message_session_delivery
|
||||
SET delivered = 1, delivered_at_ms = ?
|
||||
WHERE message_key = ? AND session_id = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(updateSql)) {
|
||||
ps.setLong(1, deliveredAtMs);
|
||||
ps.setString(2, messageKey);
|
||||
ps.setString(3, sessionId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM signed_messages_v2 m
|
||||
WHERE (
|
||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (5, 6, 7, 8)
|
||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||
)
|
||||
""";
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, login);
|
||||
ps.setString(6, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
return withBusyRetry(() -> {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String fillSql = """
|
||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||
message_key, session_id, delivered, delivered_at_ms, created_at_ms
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM signed_messages_v2 m
|
||||
WHERE (
|
||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (5, 6, 7, 8)
|
||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||
)
|
||||
""";
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, login);
|
||||
ps.setString(6, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||
m.receipt_ref_base_key, m.receipt_ref_type, m.read_at_ms
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||
""";
|
||||
List<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 = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||
m.receipt_ref_base_key, m.receipt_ref_type
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
FROM signed_messages_v2
|
||||
WHERE target_login = ? COLLATE NOCASE
|
||||
AND message_type IN (1, 2)
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
AND (
|
||||
? <= 0
|
||||
OR time_ms < ?
|
||||
OR (time_ms = ? AND (? = '' OR message_key < ?))
|
||||
)
|
||||
ORDER BY time_ms DESC, message_key DESC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setString(1, login);
|
||||
ps.setString(2, login);
|
||||
ps.setString(3, peerLogin);
|
||||
ps.setString(4, peerLogin);
|
||||
ps.setString(5, login);
|
||||
ps.setLong(6, beforeTimeMs);
|
||||
ps.setLong(7, beforeTimeMs);
|
||||
ps.setLong(8, beforeTimeMs);
|
||||
ps.setString(9, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||
ps.setString(10, beforeMessageKey == null ? "" : beforeMessageKey);
|
||||
ps.setInt(11, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
}
|
||||
@@ -333,8 +421,8 @@ public final class SignedMessagesV2DAO {
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_key) DO UPDATE SET
|
||||
base_key = excluded.base_key,
|
||||
target_login = excluded.target_login,
|
||||
@@ -350,7 +438,8 @@ public final class SignedMessagesV2DAO {
|
||||
source_api = excluded.source_api,
|
||||
origin_session_id = excluded.origin_session_id,
|
||||
receipt_ref_base_key = excluded.receipt_ref_base_key,
|
||||
receipt_ref_type = excluded.receipt_ref_type
|
||||
receipt_ref_type = excluded.receipt_ref_type,
|
||||
read_at_ms = COALESCE(signed_messages_v2.read_at_ms, excluded.read_at_ms)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
@@ -358,6 +447,32 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private void markMessageReadByReceipt(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
||||
if (entry == null) return;
|
||||
int messageType = entry.getMessageType();
|
||||
if (messageType != 3 && messageType != 4) return;
|
||||
String receiptRefBaseKey = String.valueOf(entry.getReceiptRefBaseKey() == null ? "" : entry.getReceiptRefBaseKey()).trim();
|
||||
if (receiptRefBaseKey.isEmpty()) return;
|
||||
long readAtMs = entry.getTimeMs();
|
||||
if (readAtMs <= 0) return;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_messages_v2
|
||||
SET read_at_ms = CASE
|
||||
WHEN read_at_ms IS NULL OR read_at_ms <= 0 THEN ?
|
||||
WHEN read_at_ms > ? THEN ?
|
||||
ELSE read_at_ms
|
||||
END
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
""")) {
|
||||
ps.setLong(1, readAtMs);
|
||||
ps.setLong(2, readAtMs);
|
||||
ps.setLong(3, readAtMs);
|
||||
ps.setString(4, receiptRefBaseKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT revision_time_ms, reencrypted_at_ms
|
||||
@@ -536,8 +651,8 @@ public final class SignedMessagesV2DAO {
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
receipt_ref_base_key, receipt_ref_type, read_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
@@ -563,6 +678,8 @@ public final class SignedMessagesV2DAO {
|
||||
ps.setString(15, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||
else ps.setInt(16, e.getReceiptRefType());
|
||||
if (e.getReadAtMs() == null) ps.setObject(17, null);
|
||||
else ps.setLong(17, e.getReadAtMs());
|
||||
}
|
||||
|
||||
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||
@@ -586,6 +703,45 @@ public final class SignedMessagesV2DAO {
|
||||
return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key");
|
||||
}
|
||||
|
||||
private boolean isBusyLock(SQLException ex) {
|
||||
Throwable current = ex;
|
||||
while (current != null) {
|
||||
String msg = String.valueOf(current.getMessage()).toLowerCase();
|
||||
if (msg.contains("sqlite_busy") || msg.contains("database is locked") || msg.contains("database table is locked")) {
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sleepBeforeBusyRetry(int attempt) throws SQLException {
|
||||
long delayMs = SQLITE_BUSY_RETRY_BASE_DELAY_MS * (1L << Math.min(attempt, 4));
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
SQLException sqlEx = new SQLException("Interrupted while retrying SQLite busy lock", ie);
|
||||
throw sqlEx;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withBusyRetry(SqlWork<T> work) throws Exception {
|
||||
SQLException lastBusy = null;
|
||||
for (int attempt = 0; attempt < SQLITE_BUSY_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return work.run();
|
||||
} catch (SQLException ex) {
|
||||
if (!isBusyLock(ex) || attempt >= SQLITE_BUSY_MAX_RETRIES - 1) {
|
||||
throw ex;
|
||||
}
|
||||
lastBusy = ex;
|
||||
sleepBeforeBusyRetry(attempt);
|
||||
}
|
||||
}
|
||||
throw lastBusy == null ? new SQLException("SQLite busy retry failed") : lastBusy;
|
||||
}
|
||||
|
||||
private int compareMarkers(RevisionMarker left, RevisionMarker right) {
|
||||
int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs);
|
||||
if (revisionCompare != 0) return revisionCompare;
|
||||
@@ -611,6 +767,8 @@ public final class SignedMessagesV2DAO {
|
||||
e.setReceiptRefBaseKey(rs.getString("receipt_ref_base_key"));
|
||||
int maybeRefType = rs.getInt("receipt_ref_type");
|
||||
e.setReceiptRefType(rs.wasNull() ? null : maybeRefType);
|
||||
long maybeReadAt = rs.getLong("read_at_ms");
|
||||
e.setReadAtMs(rs.wasNull() ? null : maybeReadAt);
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -619,4 +777,9 @@ public final class SignedMessagesV2DAO {
|
||||
return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs());
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SqlWork<T> {
|
||||
T run() throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public class SignedMessageV2Entry {
|
||||
private String originSessionId;
|
||||
private String receiptRefBaseKey;
|
||||
private Integer receiptRefType;
|
||||
private Long readAtMs;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||
@@ -50,4 +51,6 @@ public class SignedMessageV2Entry {
|
||||
public void setReceiptRefBaseKey(String receiptRefBaseKey) { this.receiptRefBaseKey = receiptRefBaseKey; }
|
||||
public Integer getReceiptRefType() { return receiptRefType; }
|
||||
public void setReceiptRefType(Integer receiptRefType) { this.receiptRefType = receiptRefType; }
|
||||
public Long getReadAtMs() { return readAtMs; }
|
||||
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
@@ -102,6 +103,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
@@ -200,6 +202,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||
@@ -280,6 +283,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||
|
||||
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response;
|
||||
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
|
||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
@@ -51,8 +50,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L;
|
||||
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
||||
|
||||
public static final long ALLOWED_SKEW_MS = 30_000L;
|
||||
|
||||
@Override
|
||||
@@ -424,7 +421,6 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
||||
|
||||
// --- формируем ответ ---
|
||||
Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response();
|
||||
|
||||
@@ -10,7 +10,6 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response;
|
||||
import server.logic.ws_protocol.JSON.messages.SignedMessagesRealtime;
|
||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
@@ -44,8 +43,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class);
|
||||
|
||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||
private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
|
||||
Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq;
|
||||
@@ -302,7 +299,6 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler {
|
||||
ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER);
|
||||
|
||||
ActiveConnectionsRegistry.getInstance().register(ctx);
|
||||
SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS);
|
||||
|
||||
// ответ
|
||||
Net_SessionLogin_Response resp = new Net_SessionLogin_Response();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.SignedMessagesV2DAO;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetDirectMessages_Handler.class);
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetDirectMessages_Request req = (Net_GetDirectMessages_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getPeerLogin() == null || req.getPeerLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "peerLogin обязателен");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit();
|
||||
if (limit <= 0 || limit > MAX_LIMIT) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_LIMIT", "limit должен быть в диапазоне 1.." + MAX_LIMIT);
|
||||
}
|
||||
|
||||
String login = ctx.getLogin().trim();
|
||||
String peerLogin = req.getPeerLogin().trim();
|
||||
long beforeTimeMs = req.getBeforeTimeMs() == null ? 0L : req.getBeforeTimeMs();
|
||||
String beforeMessageKey = req.getBeforeMessageKey() == null ? "" : req.getBeforeMessageKey().trim();
|
||||
|
||||
try {
|
||||
List<SignedMessageV2Entry> page = SignedMessagesV2DAO.getInstance().listConversationPage(
|
||||
login,
|
||||
peerLogin,
|
||||
beforeTimeMs,
|
||||
beforeMessageKey,
|
||||
limit + 1
|
||||
);
|
||||
|
||||
boolean hasMore = page.size() > limit;
|
||||
if (hasMore) {
|
||||
page = new ArrayList<>(page.subList(0, limit));
|
||||
}
|
||||
|
||||
Net_GetDirectMessages_Response resp = new Net_GetDirectMessages_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(login);
|
||||
resp.setPeerLogin(peerLogin);
|
||||
resp.setLimit(limit);
|
||||
resp.setHasMore(hasMore);
|
||||
|
||||
List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
|
||||
for (SignedMessageV2Entry entry : page) {
|
||||
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
|
||||
item.setMessageKey(entry.getMessageKey());
|
||||
item.setBaseKey(entry.getBaseKey());
|
||||
item.setFromLogin(entry.getFromLogin());
|
||||
item.setToLogin(entry.getToLogin());
|
||||
item.setMessageType(entry.getMessageType());
|
||||
item.setTimeMs(entry.getTimeMs());
|
||||
item.setNonce(entry.getNonce());
|
||||
item.setRevisionTimeMs(entry.getRevisionTimeMs());
|
||||
item.setReencryptedAtMs(entry.getReencryptedAtMs());
|
||||
item.setCreatedAtMs(entry.getCreatedAtMs());
|
||||
item.setReadAtMs(entry.getReadAtMs());
|
||||
item.setBlobB64(Base64.getEncoder().encodeToString(entry.getRawBlock()));
|
||||
items.add(item);
|
||||
}
|
||||
resp.setMessages(items);
|
||||
|
||||
if (hasMore && !items.isEmpty()) {
|
||||
Net_GetDirectMessages_Response.MessageItem last = items.get(items.size() - 1);
|
||||
resp.setNextBeforeTimeMs(last.getTimeMs());
|
||||
resp.setNextBeforeMessageKey(last.getMessageKey());
|
||||
}
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("GetDirectMessages failed for login={} peerLogin={}", login, peerLogin, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,12 +50,20 @@ public final class SignedMessagesRealtime {
|
||||
long now = System.currentTimeMillis();
|
||||
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||
List<String> sessionIdsToTrack = new ArrayList<>();
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
String sessionId = s.getSessionId();
|
||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
sessionIdsToTrack.add(sessionId);
|
||||
}
|
||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRows(message.getMessageKey(), sessionIdsToTrack, now);
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
String sessionId = s.getSessionId();
|
||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||
if (deliveredOnline) {
|
||||
counters.wsDelivered++;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetDirectMessages_Request extends Net_Request {
|
||||
private String peerLogin;
|
||||
private Integer limit;
|
||||
private Long beforeTimeMs;
|
||||
private String beforeMessageKey;
|
||||
|
||||
public String getPeerLogin() { return peerLogin; }
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
public Long getBeforeTimeMs() { return beforeTimeMs; }
|
||||
public void setBeforeTimeMs(Long beforeTimeMs) { this.beforeTimeMs = beforeTimeMs; }
|
||||
public String getBeforeMessageKey() { return beforeMessageKey; }
|
||||
public void setBeforeMessageKey(String beforeMessageKey) { this.beforeMessageKey = beforeMessageKey; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetDirectMessages_Response extends Net_Response {
|
||||
private String login;
|
||||
private String peerLogin;
|
||||
private int limit;
|
||||
private boolean hasMore;
|
||||
private Long nextBeforeTimeMs;
|
||||
private String nextBeforeMessageKey;
|
||||
private List<MessageItem> messages = new ArrayList<>();
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getPeerLogin() { return peerLogin; }
|
||||
public void setPeerLogin(String peerLogin) { this.peerLogin = peerLogin; }
|
||||
public int getLimit() { return limit; }
|
||||
public void setLimit(int limit) { this.limit = limit; }
|
||||
public boolean isHasMore() { return hasMore; }
|
||||
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
|
||||
public Long getNextBeforeTimeMs() { return nextBeforeTimeMs; }
|
||||
public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
|
||||
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
|
||||
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; }
|
||||
public List<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
|
||||
|
||||
public static class MessageItem {
|
||||
private String messageKey;
|
||||
private String baseKey;
|
||||
private String fromLogin;
|
||||
private String toLogin;
|
||||
private int messageType;
|
||||
private long timeMs;
|
||||
private long nonce;
|
||||
private long revisionTimeMs;
|
||||
private long reencryptedAtMs;
|
||||
private long createdAtMs;
|
||||
private Long readAtMs;
|
||||
private String blobB64;
|
||||
|
||||
public String getMessageKey() { return messageKey; }
|
||||
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
|
||||
public String getBaseKey() { return baseKey; }
|
||||
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
|
||||
public String getFromLogin() { return fromLogin; }
|
||||
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public int getMessageType() { return messageType; }
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getTimeMs() { return timeMs; }
|
||||
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
|
||||
public long getNonce() { return nonce; }
|
||||
public void setNonce(long nonce) { this.nonce = nonce; }
|
||||
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||
public long getReencryptedAtMs() { return reencryptedAtMs; }
|
||||
public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public Long getReadAtMs() { return readAtMs; }
|
||||
public void setReadAtMs(Long readAtMs) { this.readAtMs = readAtMs; }
|
||||
public String getBlobB64() { return blobB64; }
|
||||
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||
}
|
||||
}
|
||||
@@ -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-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
|
||||
- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
|
||||
- `medium/2026-07-22_переход_с_sqlite_на_postgresql.md` - подготовить перевод серверной БД с `SQLite` на `PostgreSQL` для более серьёзной конкурентной нагрузки и дальнейшего масштабирования.
|
||||
|
||||
### dao_запуск
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# ESP32 как аппаратный кошелёк (device-сессия)
|
||||
|
||||
## Суть фичи
|
||||
|
||||
ESP32 становится аппаратным HSM (hardware security module): хранит ключи, постоянно подключён к SHiNE-серверу как device-сессия, подтверждает операции нажатием на экране. Другие устройства (браузер, телефон) взаимодействуют с ESP32 через сервер — без прямого соединения.
|
||||
|
||||
## Два ключевых сценария
|
||||
|
||||
### Сценарий 1 — Создание делегированной сессии
|
||||
1. Браузер/телефон → сервер: «хочу делегированную сессию от имени пользователя X»
|
||||
2. Сервер → ESP32 (device-сессия): «запрос на одобрение»
|
||||
3. Пользователь нажимает «Да» на сенсорном экране ESP32
|
||||
4. ESP32 → сервер: одобрено → сервер создаёт делегированную сессию для браузера
|
||||
|
||||
### Сценарий 2 — Подпись транзакции / блока
|
||||
1. Браузер (через делегированную сессию) → сервер → ESP32: «подпиши вот это»
|
||||
2. ESP32 показывает запрос на экране, пользователь подтверждает
|
||||
3. ESP32 подписывает нужным ключом → ответ через сервер → браузер
|
||||
|
||||
## Что нужно сделать
|
||||
|
||||
### ESP32 (основная работа)
|
||||
- [ ] Инициализация WiFi (SSID/пароль в NVS)
|
||||
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
|
||||
- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS
|
||||
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
|
||||
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
|
||||
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)
|
||||
- [ ] Переподключение при разрыве (reconnect loop)
|
||||
|
||||
### Сервер (минимальные изменения)
|
||||
- [ ] Добавить поле `sessionType` (`USER` / `DEVICE`) в таблицу `active_sessions`
|
||||
- [ ] Новая операция `DeviceApprovalRequest` — браузер запрашивает одобрение у device-сессии
|
||||
- [ ] Новая операция `DeviceApprovalResponse` — ESP32 отвечает (одобрено/отклонено)
|
||||
- [ ] Новые операции `SignRequest` / `SignResponse` — запрос подписи и ответ
|
||||
- [ ] Роутинг: при получении запроса найти device-сессию через `ActiveConnectionsRegistry.getByLogin(login)` + фильтр по `sessionType=DEVICE`, переслать туда
|
||||
|
||||
### Клиент (отдельный этап)
|
||||
- [ ] Браузерное расширение или UI: создание делегированной сессии, отправка `SignRequest`
|
||||
|
||||
## Что уже готово (переиспользуем)
|
||||
|
||||
- **Роутинг сообщений** — `SendDirectMessage` с `TARGET_ONE_SESSION` и `CallSignalToSession` уже умеют точечно доставлять в конкретный `sessionId`. Механизм готов, нужно добавить только новые op-коды поверх него.
|
||||
- **Ed25519 на ESP32** — библиотека `<Ed25519.h>` уже используется в скетче. Подписи работают.
|
||||
- **NVS** — уже хранит логин, мастер-секрет, 3 пары ключей. Расширяется легко.
|
||||
- **`ActiveConnectionsRegistry`** — поиск по `login` и `sessionId` уже есть на сервере.
|
||||
- **Аутентификация** — схема `AuthChallenge` → `CreateAuthSession` через Ed25519 уже полностью реализована.
|
||||
|
||||
## Оценка сложности
|
||||
|
||||
| Компонент | Сложность |
|
||||
|---|---|
|
||||
| ESP32: WiFi + WebSocket-клиент + авторизация | Средняя |
|
||||
| ESP32: обработчик входящих + UI подтверждений | Средняя |
|
||||
| Сервер: флаг sessionType + 4 новых op-а + роутинг | Низкая–средняя |
|
||||
| Браузерное расширение | Высокая (отдельный этап) |
|
||||
|
||||
**Итого фазы ESP32 + сервер: ~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
|
||||
server.version=1.2.303
|
||||
client.version=1.2.342
|
||||
server.version=1.2.314
|
||||
|
||||
@@ -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/`
|
||||
- Systemd unit: `SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
|
||||
- Скрипт установки: `SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
||||
- Папка сервиса находится рядом с репозиторием продукта: `../SHiNE-agent-bot-coder/`
|
||||
- Systemd unit: `../SHiNE-agent-bot-coder/scripts/systemd/shine-agent-bot-coder.service`
|
||||
- Скрипт установки: `../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh`
|
||||
|
||||
## Предусловия
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
bash SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh
|
||||
bash ../SHiNE-agent-bot-coder/scripts/systemd/install-local-systemd.sh
|
||||
```
|
||||
|
||||
В `.env` агента `CODEX_WORKDIR` должен указывать на этот репозиторий продукта:
|
||||
|
||||
```env
|
||||
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-product
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
|
||||
@@ -10,6 +10,32 @@ RSYNC_REMOTE_SUDO=(--rsync-path="sudo -n rsync")
|
||||
|
||||
mkdir -p "${DEST_DIR}"
|
||||
|
||||
remote_exists() {
|
||||
ssh "${REMOTE_HOST}" "test -e '$1'"
|
||||
}
|
||||
|
||||
copy_optional_dir() {
|
||||
local remote_path="$1"
|
||||
local local_path="$2"
|
||||
if remote_exists "$remote_path"; then
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}/" "${local_path}/"
|
||||
else
|
||||
mkdir -p "${local_path}"
|
||||
echo "missing: ${remote_path}" > "${local_path}/MISSING.txt"
|
||||
fi
|
||||
}
|
||||
|
||||
copy_optional_file() {
|
||||
local remote_path="$1"
|
||||
local local_path="$2"
|
||||
if remote_exists "$remote_path"; then
|
||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:${remote_path}" "${local_path}"
|
||||
else
|
||||
mkdir -p "$(dirname "${local_path}")"
|
||||
echo "missing: ${remote_path}" > "${local_path}.MISSING.txt"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "[1/4] Создаю структуру бэкапа: ${DEST_DIR}"
|
||||
mkdir -p "${DEST_DIR}/home-player" "${DEST_DIR}/etc-system" "${DEST_DIR}/var-lib/docker-images"
|
||||
|
||||
@@ -21,17 +47,21 @@ rsync -aH --delete \
|
||||
--exclude='**/*.log' \
|
||||
"${REMOTE_HOST}:/home/player/SHiNE/" "${DEST_DIR}/home-player/SHiNE/"
|
||||
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/sites/" "${DEST_DIR}/home-player/sites/"
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/gitea/" "${DEST_DIR}/home-player/gitea/"
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/home/player/agent-memory/" "${DEST_DIR}/home-player/agent-memory/"
|
||||
copy_optional_dir "/home/player/sites" "${DEST_DIR}/home-player/sites"
|
||||
copy_optional_dir "/home/player/gitea" "${DEST_DIR}/home-player/gitea"
|
||||
copy_optional_dir "/home/player/agent-memory" "${DEST_DIR}/home-player/agent-memory"
|
||||
|
||||
echo "[3/4] Копирую системные конфиги"
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/caddy/" "${DEST_DIR}/etc-system/caddy/"
|
||||
rsync -aH --delete "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/var/lib/caddy/" "${DEST_DIR}/var-lib/caddy/"
|
||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/"
|
||||
rsync -aH "${RSYNC_REMOTE_SUDO[@]}" "${REMOTE_HOST}:/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/"
|
||||
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
||||
copy_optional_dir "/etc/caddy" "${DEST_DIR}/etc-system/caddy"
|
||||
copy_optional_dir "/var/lib/caddy" "${DEST_DIR}/var-lib/caddy"
|
||||
copy_optional_file "/etc/turnserver.conf" "${DEST_DIR}/etc-system/turnserver.conf"
|
||||
copy_optional_file "/etc/systemd/system/shine-server.service" "${DEST_DIR}/etc-system/shine-server.service"
|
||||
copy_optional_file "/etc/systemd/system/agent-memory.service" "${DEST_DIR}/etc-system/agent-memory.service"
|
||||
if ssh "${REMOTE_HOST}" 'command -v docker >/dev/null 2>&1 && sudo -n docker image inspect gitea/gitea:1.22.6 >/dev/null 2>&1'; then
|
||||
ssh "${REMOTE_HOST}" 'sudo -n docker image save gitea/gitea:1.22.6' > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar"
|
||||
else
|
||||
echo "missing: docker image gitea/gitea:1.22.6" > "${DEST_DIR}/var-lib/docker-images/gitea_gitea_1.22.6.tar.MISSING.txt"
|
||||
fi
|
||||
|
||||
echo "[4/4] Создаю манифест"
|
||||
{
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
|
||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
||||
|
||||
@@ -176,7 +176,72 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 7. `AckSessionDelivery`
|
||||
## 7. `GetDirectMessages`
|
||||
|
||||
Требует авторизации. Возвращает историю диалога с конкретным собеседником страницами.
|
||||
|
||||
Важно:
|
||||
|
||||
- начиная с 22 июля 2026 года сервер больше не высылает старую DM-историю автоматически при логине;
|
||||
- после подключения клиент должен сам запросить первую страницу диалога;
|
||||
- новые realtime-сообщения по-прежнему приходят событием `SignedMessageArrived`.
|
||||
- в `GetDirectMessages` сервер отдаёт только обычные chat-сообщения (`type=1/2`), без read-receipt и delete/tombstone.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetDirectMessages",
|
||||
"requestId": "dm-history-001",
|
||||
"payload": {
|
||||
"peerLogin": "bob",
|
||||
"limit": 50,
|
||||
"beforeTimeMs": 1774700000123,
|
||||
"beforeMessageKey": "alice|bob|1774700000123|123456789|1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`beforeTimeMs` и `beforeMessageKey` необязательны. Если их нет, сервер вернёт самую новую страницу.
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "GetDirectMessages",
|
||||
"requestId": "dm-history-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"login": "alice",
|
||||
"peerLogin": "bob",
|
||||
"limit": 50,
|
||||
"hasMore": true,
|
||||
"nextBeforeTimeMs": 1774699999000,
|
||||
"nextBeforeMessageKey": "alice|bob|1774699999000|123456780|2",
|
||||
"messages": [
|
||||
{
|
||||
"messageKey": "alice|bob|1774700000123|123456789|1",
|
||||
"baseKey": "alice|bob|1774700000123|123456789",
|
||||
"fromLogin": "alice",
|
||||
"toLogin": "bob",
|
||||
"messageType": 1,
|
||||
"timeMs": 1774700000123,
|
||||
"nonce": 123456789,
|
||||
"revisionTimeMs": 0,
|
||||
"reencryptedAtMs": 0,
|
||||
"createdAtMs": 1774700001123,
|
||||
"readAtMs": 1774700001456,
|
||||
"blobB64": "BASE64_SIGNED_BLOCK"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
|
||||
|
||||
## 8. `AckSessionDelivery`
|
||||
|
||||
Требует авторизации. Подтверждает доставку в текущую сессию.
|
||||
|
||||
@@ -192,7 +257,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Событие `SignedMessageArrived`
|
||||
## 9. Событие `SignedMessageArrived`
|
||||
|
||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||
|
||||
@@ -217,7 +282,7 @@
|
||||
|
||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||
|
||||
## 9. `CallInviteBroadcast`
|
||||
## 10. `CallInviteBroadcast`
|
||||
|
||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||
|
||||
|
||||
@@ -509,6 +509,35 @@ UI-следствие для клиента:
|
||||
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
||||
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
|
||||
|
||||
### 10.5. `GetDirectMessages`
|
||||
|
||||
Назначение:
|
||||
|
||||
- постранично отдавать историю одного личного диалога;
|
||||
- не вываливать весь старый backlog автоматически в момент логина;
|
||||
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
|
||||
|
||||
Правила:
|
||||
|
||||
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
|
||||
- клиент сам запрашивает первую страницу истории у нужного собеседника;
|
||||
- последующие страницы клиент запрашивает по курсору `beforeTimeMs` + `beforeMessageKey`;
|
||||
- realtime-новые сообщения по-прежнему приходят отдельно через `SignedMessageArrived`.
|
||||
|
||||
Содержимое страницы:
|
||||
|
||||
- сервер возвращает только контентные DM типов `1/2`;
|
||||
- read-receipt (`3/4`) и tombstone удаления (`5/6/7/8`) в историю страницы не включаются;
|
||||
- сообщения идут страницами от новых к старым;
|
||||
- лимит страницы считается по самим chat-сообщениям диалога.
|
||||
|
||||
Поле прочтения:
|
||||
|
||||
- для каждого контентного сообщения сервер может вернуть `readAtMs`;
|
||||
- `readAtMs` означает точное время прочтения сообщения, если оно уже известно серверу;
|
||||
- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
|
||||
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
|
||||
|
||||
## 11. Межсерверная доставка
|
||||
|
||||
### 11.1. Клиентская сторона
|
||||
@@ -547,9 +576,12 @@ UI-следствие для клиента:
|
||||
В ней должны сохраняться:
|
||||
|
||||
- обычные контентные DM;
|
||||
- read-receipt DM;
|
||||
- tombstone одного сообщения;
|
||||
- tombstone удаления переписки.
|
||||
|
||||
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле `read_at_ms`, если для этого сообщения уже было принято read-receipt событие.
|
||||
|
||||
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
|
||||
|
||||
- защищать от повторного приёма старых версий;
|
||||
|
||||
@@ -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 |