Удалить Java-реализацию агента-кодера
This commit is contained in:
parent
35565845ca
commit
abdce05136
@ -1,18 +0,0 @@
|
||||
# SHiNE-agent-bot-coder: очередь, voice, codex, systemd
|
||||
|
||||
- краткое описание фичи:
|
||||
Добавлен новый сервис `SHiNE-agent-bot-coder` (Java), который обрабатывает сообщения от `@AidarKC`, ведёт JSONL-историю, использует файловую очередь, распознаёт voice через OpenAI, вызывает Codex CLI и поддерживает запуск как `systemd`-сервис.
|
||||
|
||||
- что именно проверять:
|
||||
1. Бот принимает текст от `@AidarKC`, ставит задачу в очередь и отправляет ответ от Codex.
|
||||
2. Бот принимает voice, отправляет текст распознавания и затем ответ от Codex.
|
||||
3. Одновременные сообщения обрабатываются строго по одному (без параллельных запусков Codex).
|
||||
4. После рестарта сервиса незавершённая активная задача повторно уходит в обработку.
|
||||
5. Команда `/new` архивирует текущую историю и создаёт новую.
|
||||
6. `systemd`-сервис стартует и автоматически перезапускается.
|
||||
|
||||
- ожидаемый результат:
|
||||
Все пункты выше отрабатывают без потери сообщений, с корректным обновлением `data/queue.jsonl`, `data/state.json` и `data/history/*.jsonl`.
|
||||
|
||||
- статус:
|
||||
`pending`
|
||||
@ -1,22 +0,0 @@
|
||||
# Агент-бот coder: устранение дублей и зависаний
|
||||
|
||||
- краткое описание фичи:
|
||||
- добавлен lock-файл `data/app.lock`, чтобы гарантировать единственный инстанс бота;
|
||||
- доработано завершение worker/codex при stop/restart, чтобы не было ложных retry после штатной остановки;
|
||||
- синхронизирован systemd-профиль под `--user` для `ai`;
|
||||
- улучшена отправка промежуточных статусов по событиям `codex --json`.
|
||||
|
||||
- что проверять:
|
||||
- при запущенном сервисе повторный запуск jar вручную завершается сразу с сообщением про занятый lock;
|
||||
- команда `/status` отвечает один раз (без дублей);
|
||||
- тестовая текстовая задача от `@AidarKC` обрабатывается и возвращает ответ;
|
||||
- при `/stop` активная задача завершается без последующего спама retry-ошибками;
|
||||
- в логах нет `409 Conflict` из-за второго poller после чистого перезапуска.
|
||||
|
||||
- ожидаемый результат:
|
||||
- одновременно работает только один процесс бота;
|
||||
- бот не дублирует ответы;
|
||||
- очередь не застревает на interrupted после штатного stop/restart.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -0,0 +1,16 @@
|
||||
# Удаление старой Java-реализации агента-кодера
|
||||
|
||||
- краткое описание фичи:
|
||||
Старая Java-реализация `SHiNE-agent-bot-coder` удалена, потому что рабочим вариантом сервиса является Python-скрипт `py_bot_service.py`.
|
||||
|
||||
- что именно проверять:
|
||||
1. Gradle-проект больше не содержит подпроект `SHiNE-agent-bot-coder`.
|
||||
2. Локальный systemd-сервис `shine-agent-bot-coder` запускает `py_bot_service.py`.
|
||||
3. Telegram-бот принимает сообщение от Айдара и отвечает через Python-сервис.
|
||||
4. Команды `/status`, `/queue`, `/new` и `/restart_service` работают как раньше.
|
||||
|
||||
- ожидаемый результат:
|
||||
Удаление Java-кода не влияет на текущую работу Python-сервиса агента-кодера.
|
||||
|
||||
- статус:
|
||||
`pending`
|
||||
@ -9,7 +9,7 @@
|
||||
- История диалога хранится в JSONL-файле, путь передаётся в промпте.
|
||||
- Сообщение может быть текстом или результатом распознавания голосового.
|
||||
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
|
||||
- Рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; Java-реализацию не считать основной и не использовать как точку запуска без отдельного указания.
|
||||
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
|
||||
|
||||
## Авторитет команд и история
|
||||
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
|
||||
|
||||
@ -11,10 +11,10 @@
|
||||
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
|
||||
- принимает сообщения из канала `@shine_writing`, выполняет команды только от `@AidarKC`, а сообщения других авторов сохраняет как контекст.
|
||||
|
||||
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
|
||||
|
||||
## Структура
|
||||
- `.env` — локальные секреты и параметры запуска (не коммитится);
|
||||
- `data/queue.jsonl` — очередь задач;
|
||||
- `data/state.json` — текущее состояние (active job + текущий history-файл);
|
||||
- `data/py_queue.jsonl` — очередь Python-сервиса;
|
||||
- `data/py_state.json` — текущее состояние Python-сервиса;
|
||||
- `data/py_processed_updates.log` — дедуп входящих update;
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'application'
|
||||
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||
}
|
||||
|
||||
group = 'shine.agent'
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.telegram:telegrambots:6.9.7.1'
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||
runtimeOnly 'org.slf4j:slf4j-simple:2.0.16'
|
||||
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
|
||||
implementation 'org.apache.httpcomponents:httpcore:4.4.16'
|
||||
implementation 'commons-codec:commons-codec:1.17.0'
|
||||
|
||||
testImplementation platform('org.junit:junit-bom:5.10.2')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'shine.agent.botcoder.BotCoderApplication'
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
shadowJar {
|
||||
archiveBaseName.set('shine-agent-bot-coder')
|
||||
archiveClassifier.set('')
|
||||
archiveVersion.set('')
|
||||
mergeServiceFiles()
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
package shine.agent.botcoder;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.telegram.telegrambots.meta.TelegramBotsApi;
|
||||
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
|
||||
import shine.agent.botcoder.codex.CodexClient;
|
||||
import shine.agent.botcoder.config.AppConfig;
|
||||
import shine.agent.botcoder.history.HistoryManager;
|
||||
import shine.agent.botcoder.openai.OpenAiTranscriber;
|
||||
import shine.agent.botcoder.queue.QueueStore;
|
||||
import shine.agent.botcoder.state.RuntimeStateStore;
|
||||
import shine.agent.botcoder.state.SingleInstanceLock;
|
||||
import shine.agent.botcoder.telegram.ProcessedUpdatesStore;
|
||||
import shine.agent.botcoder.telegram.ShineAgentBot;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
public class BotCoderApplication {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BotCoderApplication.class);
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path serviceRoot = Path.of("").toAbsolutePath().normalize();
|
||||
AppConfig config = AppConfig.load(serviceRoot);
|
||||
Files.createDirectories(config.dataDir());
|
||||
SingleInstanceLock appLock = SingleInstanceLock.tryAcquire(config.dataDir().resolve("app.lock"));
|
||||
if (appLock == null) {
|
||||
log.error("SHiNE-agent-bot-coder уже запущен: lock занят {}", config.dataDir().resolve("app.lock"));
|
||||
return;
|
||||
}
|
||||
|
||||
RuntimeStateStore stateStore = new RuntimeStateStore(config.dataDir().resolve("state.json"));
|
||||
QueueStore queueStore = new QueueStore(config.dataDir().resolve("queue.jsonl"), stateStore);
|
||||
HistoryManager historyManager = new HistoryManager(
|
||||
config.dataDir().resolve("history"),
|
||||
config.dataDir().resolve("history").resolve("archive"),
|
||||
stateStore
|
||||
);
|
||||
|
||||
List<String> recovered = queueStore.recoverActiveJobs();
|
||||
if (!recovered.isEmpty()) {
|
||||
historyManager.appendSystemEvent("active_jobs_recovered", java.util.Map.of("jobIds", recovered));
|
||||
}
|
||||
|
||||
OpenAiTranscriber transcriber = new OpenAiTranscriber(config.openAiApiKey(), config.openAiTranscribeModel());
|
||||
CodexClient codexClient = new CodexClient(config.codexBin(), config.codexWorkDir(), config.codexTimeoutSeconds());
|
||||
ProcessedUpdatesStore processedUpdatesStore = new ProcessedUpdatesStore(
|
||||
config.dataDir().resolve("processed_updates.log"),
|
||||
5000
|
||||
);
|
||||
|
||||
ShineAgentBot bot = new ShineAgentBot(config, queueStore, historyManager, transcriber, codexClient, processedUpdatesStore);
|
||||
bot.startWorkers();
|
||||
|
||||
TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class);
|
||||
botsApi.registerBot(bot);
|
||||
|
||||
log.info("SHiNE-agent-bot-coder запущен. allowed user: @{}", config.allowedTelegramUsername());
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
bot.shutdown();
|
||||
try {
|
||||
appLock.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось закрыть lock-файл", e);
|
||||
}
|
||||
latch.countDown();
|
||||
}, "shine-agent-bot-shutdown"));
|
||||
latch.await();
|
||||
}
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
package shine.agent.botcoder.codex;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class CodexClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CodexClient.class);
|
||||
|
||||
private final Path codexBin;
|
||||
private final Path codexWorkDir;
|
||||
private final int timeoutSeconds;
|
||||
private final AtomicReference<Process> activeProcess = new AtomicReference<>();
|
||||
|
||||
public CodexClient(Path codexBin, Path codexWorkDir, int timeoutSeconds) {
|
||||
this.codexBin = codexBin;
|
||||
this.codexWorkDir = codexWorkDir;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
public String executePrompt(String prompt, CodexStatusListener statusListener) throws IOException, InterruptedException {
|
||||
Path lastMessageFile = Files.createTempFile("shine-codex-last-message-", ".txt");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(codexBin.toString());
|
||||
command.add("exec");
|
||||
command.add("--dangerously-bypass-approvals-and-sandbox");
|
||||
command.add("--json");
|
||||
command.add("-C");
|
||||
command.add(codexWorkDir.toString());
|
||||
command.add("-o");
|
||||
command.add(lastMessageFile.toString());
|
||||
command.add(prompt);
|
||||
log.info("Запуск codex exec, bin={}, workdir={}", codexBin, codexWorkDir);
|
||||
|
||||
ProcessBuilder builder = new ProcessBuilder(command);
|
||||
builder.redirectErrorStream(true);
|
||||
Process process = builder.start();
|
||||
activeProcess.set(process);
|
||||
if (statusListener != null) {
|
||||
statusListener.onStatus("Codex запущен");
|
||||
}
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
Thread outputThread = new Thread(() -> readOutput(process, output, statusListener));
|
||||
outputThread.setDaemon(true);
|
||||
outputThread.start();
|
||||
|
||||
boolean finished;
|
||||
try {
|
||||
finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
process.destroyForcibly();
|
||||
joinOutputThread(outputThread);
|
||||
activeProcess.compareAndSet(process, null);
|
||||
Thread.currentThread().interrupt();
|
||||
throw interrupted;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
joinOutputThread(outputThread);
|
||||
log.error("Codex timeout after {}s", timeoutSeconds);
|
||||
throw new IOException("Codex timeout after " + timeoutSeconds + "s");
|
||||
}
|
||||
|
||||
joinOutputThread(outputThread);
|
||||
int exitCode = process.exitValue();
|
||||
String lastMessage = "";
|
||||
if (Files.exists(lastMessageFile)) {
|
||||
lastMessage = Files.readString(lastMessageFile, StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
|
||||
if (exitCode != 0) {
|
||||
log.error("Codex exit code={}, outputTail={}", exitCode, tail(output.toString(), 500));
|
||||
throw new IOException("Codex exited with code " + exitCode + ". Output: " + tail(output.toString(), 1800));
|
||||
}
|
||||
|
||||
if (!lastMessage.isBlank()) {
|
||||
return lastMessage;
|
||||
}
|
||||
|
||||
String fallback = extractFallbackMessage(output.toString());
|
||||
if (fallback.isBlank()) {
|
||||
throw new IOException("Codex returned empty response");
|
||||
}
|
||||
return fallback;
|
||||
} finally {
|
||||
activeProcess.compareAndSet(process, null);
|
||||
try {
|
||||
Files.deleteIfExists(lastMessageFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean stopActiveProcess() {
|
||||
Process process = activeProcess.getAndSet(null);
|
||||
if (process == null) {
|
||||
return false;
|
||||
}
|
||||
process.destroy();
|
||||
try {
|
||||
if (!process.waitFor(2, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
process.destroyForcibly();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void readOutput(Process process, StringBuilder output, CodexStatusListener statusListener) {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append('\n');
|
||||
String status = normalizeStatusLine(line);
|
||||
if (status != null && statusListener != null) {
|
||||
statusListener.onStatus(status);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeStatusLine(String line) {
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"thread.started\"")) {
|
||||
return "Codex: инициализировал сессию";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"turn.started\"")) {
|
||||
return "Codex: начал обработку запроса";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"item.completed\"") && trimmed.contains("\"type\":\"agent_message\"")) {
|
||||
return "Codex: формирует финальный ответ";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"turn.completed\"")) {
|
||||
return "Codex: завершил шаг";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"error\"")) {
|
||||
return "Codex: ошибка выполнения";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"reasoning\"")) {
|
||||
return "Codex: анализирует задачу";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"function_call\"")) {
|
||||
return "Codex: вызывает инструмент";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"function_call_output\"")) {
|
||||
return "Codex: получил результат инструмента";
|
||||
}
|
||||
if (trimmed.contains("\"type\":\"message\"") && trimmed.contains("\"role\":\"assistant\"")) {
|
||||
return "Codex: формирует ответ";
|
||||
}
|
||||
if (trimmed.startsWith("mcp")) {
|
||||
return "Codex: инициализирует MCP";
|
||||
}
|
||||
if (trimmed.startsWith("tokens used")) {
|
||||
return "Codex: завершает обработку";
|
||||
}
|
||||
if (trimmed.startsWith("ERROR:")) {
|
||||
return "Codex: ошибка выполнения";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void joinOutputThread(Thread outputThread) throws InterruptedException {
|
||||
try {
|
||||
outputThread.join(Duration.ofSeconds(2).toMillis());
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw interrupted;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractFallbackMessage(String rawOutput) {
|
||||
String[] lines = rawOutput.split("\\R");
|
||||
for (int i = lines.length - 1; i >= 0; i--) {
|
||||
String line = lines[i].trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("tokens used")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("OpenAI Codex")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("workdir:") || line.startsWith("model:") || line.startsWith("provider:")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("approval:") || line.startsWith("sandbox:") || line.startsWith("reasoning")) {
|
||||
continue;
|
||||
}
|
||||
if (line.equals("user") || line.equals("exec") || line.equals("--------")) {
|
||||
continue;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String tail(String value, int maxLen) {
|
||||
if (value.length() <= maxLen) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(value.length() - maxLen);
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
package shine.agent.botcoder.codex;
|
||||
|
||||
public interface CodexStatusListener {
|
||||
void onStatus(String message);
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
package shine.agent.botcoder.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
public record AppConfig(
|
||||
String telegramBotToken,
|
||||
String botUsername,
|
||||
String allowedTelegramUsername,
|
||||
String openAiApiKey,
|
||||
String openAiTranscribeModel,
|
||||
Path codexBin,
|
||||
Path codexWorkDir,
|
||||
int codexTimeoutSeconds,
|
||||
int maxRetries,
|
||||
Path dataDir,
|
||||
Path agentInstructionsFile
|
||||
) {
|
||||
|
||||
public static AppConfig load(Path serviceRoot) throws IOException {
|
||||
Map<String, String> env = EnvLoader.load(serviceRoot.resolve(".env"));
|
||||
String telegramBotToken = required(env, "TELEGRAM_BOT_TOKEN");
|
||||
String openAiApiKey = required(env, "OPENAI_API_KEY");
|
||||
|
||||
String botUsername = env.getOrDefault("BOT_USERNAME", "aidar_su_bot");
|
||||
String allowed = normalizeUsername(env.getOrDefault("ALLOWED_TELEGRAM_USERNAME", "AidarKC"));
|
||||
String transcribeModel = env.getOrDefault("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe");
|
||||
|
||||
Path codexBin = Path.of(env.getOrDefault(
|
||||
"CODEX_BIN",
|
||||
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
|
||||
));
|
||||
Path codexWorkDir = Path.of(env.getOrDefault(
|
||||
"CODEX_WORKDIR",
|
||||
"/home/ai/work/SHiNE/SHiNE-server-sha256"
|
||||
));
|
||||
|
||||
int timeout = parseInt(env.getOrDefault("CODEX_TIMEOUT_SECONDS", "900"), 900);
|
||||
int retries = parseInt(env.getOrDefault("MAX_RETRIES", "3"), 3);
|
||||
if (retries < 1) {
|
||||
retries = 1;
|
||||
}
|
||||
Path dataDir = serviceRoot.resolve(env.getOrDefault("DATA_DIR", "./data")).normalize();
|
||||
Path agentInstructions = serviceRoot.resolve("AGENT.md").normalize();
|
||||
|
||||
return new AppConfig(
|
||||
telegramBotToken,
|
||||
botUsername,
|
||||
allowed,
|
||||
openAiApiKey,
|
||||
transcribeModel,
|
||||
codexBin,
|
||||
codexWorkDir,
|
||||
timeout,
|
||||
retries,
|
||||
dataDir,
|
||||
agentInstructions
|
||||
);
|
||||
}
|
||||
|
||||
public static String normalizeUsername(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = value.trim();
|
||||
if (normalized.startsWith("@")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
private static String required(Map<String, String> env, String key) {
|
||||
String value = env.get(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Не задан обязательный параметр: " + key);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
package shine.agent.botcoder.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class EnvLoader {
|
||||
|
||||
private EnvLoader() {
|
||||
}
|
||||
|
||||
public static Map<String, String> load(Path envFile) throws IOException {
|
||||
Map<String, String> values = new HashMap<>();
|
||||
if (Files.exists(envFile)) {
|
||||
for (String rawLine : Files.readAllLines(envFile)) {
|
||||
String line = rawLine.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
int idx = line.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
String key = line.substring(0, idx).trim();
|
||||
String value = line.substring(idx + 1).trim();
|
||||
values.put(key, stripQuotes(value));
|
||||
}
|
||||
}
|
||||
System.getenv().forEach(values::put);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String stripQuotes(String value) {
|
||||
if (value.length() >= 2) {
|
||||
char first = value.charAt(0);
|
||||
char last = value.charAt(value.length() - 1);
|
||||
if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
package shine.agent.botcoder.history;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import shine.agent.botcoder.state.RuntimeState;
|
||||
import shine.agent.botcoder.state.RuntimeStateStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class HistoryManager {
|
||||
|
||||
private static final DateTimeFormatter FILE_TS = DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss");
|
||||
private final Path historyDir;
|
||||
private final Path archiveDir;
|
||||
private final RuntimeStateStore stateStore;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public HistoryManager(Path historyDir, Path archiveDir, RuntimeStateStore stateStore) throws IOException {
|
||||
this.historyDir = historyDir;
|
||||
this.archiveDir = archiveDir;
|
||||
this.stateStore = stateStore;
|
||||
this.mapper = new ObjectMapper();
|
||||
Files.createDirectories(historyDir);
|
||||
Files.createDirectories(archiveDir);
|
||||
ensureCurrentFile();
|
||||
}
|
||||
|
||||
public synchronized Path currentHistoryFile() throws IOException {
|
||||
return ensureCurrentFile();
|
||||
}
|
||||
|
||||
public synchronized Path rotateHistory(String reason, String requestedBy) throws IOException {
|
||||
Path current = ensureCurrentFile();
|
||||
Path archived = archiveDir.resolve(current.getFileName().toString());
|
||||
Files.move(current, archived, StandardCopyOption.REPLACE_EXISTING);
|
||||
Path next = newHistoryFile();
|
||||
appendSystemEvent(
|
||||
"history_rotated",
|
||||
Map.of(
|
||||
"reason", reason,
|
||||
"requestedBy", requestedBy,
|
||||
"archivedFile", archived.toString()
|
||||
)
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
public synchronized void appendIncomingText(long chatId, int messageId, String username, String text) throws IOException {
|
||||
Map<String, Object> payload = basePayload("incoming_text");
|
||||
payload.put("chatId", chatId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("username", username);
|
||||
payload.put("text", text);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendIncomingVoice(long chatId, int messageId, String username, String fileId) throws IOException {
|
||||
Map<String, Object> payload = basePayload("incoming_voice");
|
||||
payload.put("chatId", chatId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("username", username);
|
||||
payload.put("telegramFileId", fileId);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendTranscription(String jobId, String text) throws IOException {
|
||||
Map<String, Object> payload = basePayload("voice_transcription");
|
||||
payload.put("jobId", jobId);
|
||||
payload.put("text", text);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendCodexRequest(String jobId, String prompt) throws IOException {
|
||||
Map<String, Object> payload = basePayload("codex_request");
|
||||
payload.put("jobId", jobId);
|
||||
payload.put("prompt", prompt);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendCodexResponse(String jobId, String response) throws IOException {
|
||||
Map<String, Object> payload = basePayload("codex_response");
|
||||
payload.put("jobId", jobId);
|
||||
payload.put("response", response);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendOutgoingMessage(String jobId, long chatId, String text) throws IOException {
|
||||
Map<String, Object> payload = basePayload("outgoing_message");
|
||||
payload.put("jobId", jobId);
|
||||
payload.put("chatId", chatId);
|
||||
payload.put("text", text);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendJobError(String jobId, String error, boolean willRetry, int attempts, int maxRetries) throws IOException {
|
||||
Map<String, Object> payload = basePayload("job_error");
|
||||
payload.put("jobId", jobId);
|
||||
payload.put("error", error);
|
||||
payload.put("willRetry", willRetry);
|
||||
payload.put("attempts", attempts);
|
||||
payload.put("maxRetries", maxRetries);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
public synchronized void appendSystemEvent(String event, Map<String, Object> fields) throws IOException {
|
||||
Map<String, Object> payload = basePayload(event);
|
||||
payload.putAll(fields);
|
||||
append(payload);
|
||||
}
|
||||
|
||||
private Map<String, Object> basePayload(String type) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("timestamp", Instant.now().toString());
|
||||
payload.put("type", type);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private void append(Map<String, Object> payload) throws IOException {
|
||||
Path current = ensureCurrentFile();
|
||||
Files.writeString(
|
||||
current,
|
||||
mapper.writeValueAsString(payload) + System.lineSeparator(),
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND
|
||||
);
|
||||
}
|
||||
|
||||
private Path ensureCurrentFile() throws IOException {
|
||||
RuntimeState snapshot = stateStore.snapshot();
|
||||
if (snapshot.currentHistoryFile != null && !snapshot.currentHistoryFile.isBlank()) {
|
||||
Path configured = Path.of(snapshot.currentHistoryFile);
|
||||
if (!configured.isAbsolute()) {
|
||||
configured = historyDir.resolve(snapshot.currentHistoryFile).normalize();
|
||||
}
|
||||
Files.createDirectories(configured.getParent());
|
||||
if (!Files.exists(configured)) {
|
||||
Files.createFile(configured);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
return newHistoryFile();
|
||||
}
|
||||
|
||||
private Path newHistoryFile() throws IOException {
|
||||
String name = FILE_TS.format(LocalDateTime.now()) + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jsonl";
|
||||
Path file = historyDir.resolve(name);
|
||||
Files.createFile(file);
|
||||
stateStore.setCurrentHistoryFile(file.toString());
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
package shine.agent.botcoder.openai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
public class OpenAiTranscriber {
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper mapper;
|
||||
private final String apiKey;
|
||||
private final String model;
|
||||
|
||||
public OpenAiTranscriber(String apiKey, String model) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public String transcribe(byte[] audioBytes, String fileName) throws IOException, InterruptedException {
|
||||
String boundary = "----shine-boundary-" + UUID.randomUUID();
|
||||
byte[] body = buildMultipartBody(boundary, audioBytes, fileName);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://api.openai.com/v1/audio/transcriptions"))
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() >= 300) {
|
||||
throw new IOException("OpenAI transcription error HTTP " + response.statusCode() + ": " + response.body());
|
||||
}
|
||||
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
JsonNode text = root.get("text");
|
||||
if (text == null || text.asText().isBlank()) {
|
||||
throw new IOException("OpenAI transcription returned empty text");
|
||||
}
|
||||
return text.asText().trim();
|
||||
}
|
||||
|
||||
private byte[] buildMultipartBody(String boundary, byte[] audioBytes, String fileName) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
String lineEnd = "\r\n";
|
||||
String prefix = "--" + boundary + lineEnd;
|
||||
|
||||
out.write(prefix.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(("Content-Disposition: form-data; name=\"model\"" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
|
||||
out.write(model.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
out.write(prefix.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(("Content-Disposition: form-data; name=\"language\"" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
|
||||
out.write("ru".getBytes(StandardCharsets.UTF_8));
|
||||
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
out.write(prefix.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"" + lineEnd).getBytes(StandardCharsets.UTF_8));
|
||||
out.write(("Content-Type: audio/ogg" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
|
||||
out.write(audioBytes);
|
||||
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
out.write(("--" + boundary + "--" + lineEnd).getBytes(StandardCharsets.UTF_8));
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
package shine.agent.botcoder.queue;
|
||||
|
||||
public record FailureResult(boolean willRetry, int attempts, int maxRetries) {
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
package shine.agent.botcoder.queue;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class QueueJob {
|
||||
public String id;
|
||||
public QueueStatus status;
|
||||
public String type;
|
||||
public long chatId;
|
||||
public int messageId;
|
||||
public String username;
|
||||
public String text;
|
||||
public String telegramFileId;
|
||||
public String historyFile;
|
||||
public String createdAt;
|
||||
public String updatedAt;
|
||||
public String activeSince;
|
||||
public int attempts;
|
||||
public String retryReason;
|
||||
public String lastError;
|
||||
|
||||
public static QueueJob textJob(long chatId, int messageId, String username, String text, String historyFile) {
|
||||
QueueJob job = baseJob(chatId, messageId, username, historyFile);
|
||||
job.type = "text";
|
||||
job.text = text;
|
||||
return job;
|
||||
}
|
||||
|
||||
public static QueueJob voiceJob(long chatId, int messageId, String username, String fileId, String historyFile) {
|
||||
QueueJob job = baseJob(chatId, messageId, username, historyFile);
|
||||
job.type = "voice";
|
||||
job.telegramFileId = fileId;
|
||||
return job;
|
||||
}
|
||||
|
||||
private static QueueJob baseJob(long chatId, int messageId, String username, String historyFile) {
|
||||
QueueJob job = new QueueJob();
|
||||
String now = Instant.now().toString();
|
||||
job.id = UUID.randomUUID().toString();
|
||||
job.status = QueueStatus.PENDING;
|
||||
job.chatId = chatId;
|
||||
job.messageId = messageId;
|
||||
job.username = username;
|
||||
job.historyFile = historyFile;
|
||||
job.createdAt = now;
|
||||
job.updatedAt = now;
|
||||
job.attempts = 0;
|
||||
return job;
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package shine.agent.botcoder.queue;
|
||||
|
||||
public enum QueueStatus {
|
||||
PENDING,
|
||||
ACTIVE
|
||||
}
|
||||
@ -1,203 +0,0 @@
|
||||
package shine.agent.botcoder.queue;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import shine.agent.botcoder.state.RuntimeStateStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class QueueStore {
|
||||
|
||||
private final Path queueFile;
|
||||
private final RuntimeStateStore stateStore;
|
||||
private final ObjectMapper mapper;
|
||||
private final List<QueueJob> jobs;
|
||||
|
||||
public QueueStore(Path queueFile, RuntimeStateStore stateStore) throws IOException {
|
||||
this.queueFile = queueFile;
|
||||
this.stateStore = stateStore;
|
||||
this.mapper = new ObjectMapper();
|
||||
Path parent = queueFile.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
this.jobs = loadQueue();
|
||||
persistQueue();
|
||||
}
|
||||
|
||||
public synchronized void enqueue(QueueJob job) throws IOException {
|
||||
jobs.add(job);
|
||||
persistQueue();
|
||||
}
|
||||
|
||||
public synchronized List<String> recoverActiveJobs() throws IOException {
|
||||
List<String> recovered = new ArrayList<>();
|
||||
String now = Instant.now().toString();
|
||||
for (QueueJob job : jobs) {
|
||||
if (job.status == QueueStatus.ACTIVE) {
|
||||
job.status = QueueStatus.PENDING;
|
||||
job.retryReason = "service_restart_recovery";
|
||||
job.updatedAt = now;
|
||||
recovered.add(job.id);
|
||||
}
|
||||
}
|
||||
stateStore.setActiveJobId(null);
|
||||
persistQueue();
|
||||
return recovered;
|
||||
}
|
||||
|
||||
public synchronized Optional<QueueJob> activateNext() throws IOException {
|
||||
for (QueueJob job : jobs) {
|
||||
if (job.status == QueueStatus.PENDING) {
|
||||
job.status = QueueStatus.ACTIVE;
|
||||
job.activeSince = Instant.now().toString();
|
||||
job.updatedAt = job.activeSince;
|
||||
stateStore.setActiveJobId(job.id);
|
||||
persistQueue();
|
||||
return Optional.of(job);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public synchronized void markDone(String jobId) throws IOException {
|
||||
Iterator<QueueJob> iterator = jobs.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
QueueJob job = iterator.next();
|
||||
if (job.id.equals(jobId)) {
|
||||
iterator.remove();
|
||||
break;
|
||||
}
|
||||
}
|
||||
stateStore.setActiveJobId(null);
|
||||
persistQueue();
|
||||
}
|
||||
|
||||
public synchronized Optional<QueueJob> getActiveJob() {
|
||||
return jobs.stream().filter(j -> j.status == QueueStatus.ACTIVE).findFirst();
|
||||
}
|
||||
|
||||
public synchronized int pendingCount() {
|
||||
int count = 0;
|
||||
for (QueueJob job : jobs) {
|
||||
if (job.status == QueueStatus.PENDING) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public synchronized int totalCount() {
|
||||
return jobs.size();
|
||||
}
|
||||
|
||||
public synchronized List<QueueJob> snapshot() {
|
||||
return new ArrayList<>(jobs);
|
||||
}
|
||||
|
||||
public synchronized boolean cancelActiveJob(String reason) throws IOException {
|
||||
for (Iterator<QueueJob> iterator = jobs.iterator(); iterator.hasNext(); ) {
|
||||
QueueJob job = iterator.next();
|
||||
if (job.status == QueueStatus.ACTIVE) {
|
||||
iterator.remove();
|
||||
stateStore.setActiveJobId(null);
|
||||
persistQueue();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public synchronized int cancelAll(String reason) throws IOException {
|
||||
int size = jobs.size();
|
||||
if (size == 0) {
|
||||
return 0;
|
||||
}
|
||||
jobs.clear();
|
||||
stateStore.setActiveJobId(null);
|
||||
persistQueue();
|
||||
return size;
|
||||
}
|
||||
|
||||
public synchronized boolean cancelByIdPrefix(String idPrefix) throws IOException {
|
||||
if (idPrefix == null || idPrefix.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String normalized = idPrefix.trim().toLowerCase();
|
||||
for (Iterator<QueueJob> iterator = jobs.iterator(); iterator.hasNext(); ) {
|
||||
QueueJob job = iterator.next();
|
||||
if (job.id != null && job.id.toLowerCase().startsWith(normalized)) {
|
||||
if (job.status == QueueStatus.ACTIVE) {
|
||||
stateStore.setActiveJobId(null);
|
||||
}
|
||||
iterator.remove();
|
||||
persistQueue();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public synchronized FailureResult markFailed(String jobId, String error, int maxRetries) throws IOException {
|
||||
for (Iterator<QueueJob> it = jobs.iterator(); it.hasNext(); ) {
|
||||
QueueJob job = it.next();
|
||||
if (!job.id.equals(jobId)) {
|
||||
continue;
|
||||
}
|
||||
job.attempts = job.attempts + 1;
|
||||
job.lastError = error;
|
||||
job.updatedAt = Instant.now().toString();
|
||||
stateStore.setActiveJobId(null);
|
||||
|
||||
if (job.attempts < maxRetries) {
|
||||
job.status = QueueStatus.PENDING;
|
||||
job.retryReason = error;
|
||||
persistQueue();
|
||||
return new FailureResult(true, job.attempts, maxRetries);
|
||||
}
|
||||
|
||||
it.remove();
|
||||
persistQueue();
|
||||
return new FailureResult(false, job.attempts, maxRetries);
|
||||
}
|
||||
|
||||
stateStore.setActiveJobId(null);
|
||||
persistQueue();
|
||||
return new FailureResult(false, maxRetries, maxRetries);
|
||||
}
|
||||
|
||||
private List<QueueJob> loadQueue() throws IOException {
|
||||
List<QueueJob> loaded = new ArrayList<>();
|
||||
if (!Files.exists(queueFile)) {
|
||||
return loaded;
|
||||
}
|
||||
for (String line : Files.readAllLines(queueFile, StandardCharsets.UTF_8)) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
loaded.add(mapper.readValue(trimmed, QueueJob.class));
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private void persistQueue() throws IOException {
|
||||
Files.writeString(queueFile, "", StandardCharsets.UTF_8);
|
||||
for (QueueJob job : jobs) {
|
||||
Files.writeString(
|
||||
queueFile,
|
||||
mapper.writeValueAsString(job) + System.lineSeparator(),
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.APPEND
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package shine.agent.botcoder.state;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class RuntimeState {
|
||||
public String activeJobId;
|
||||
public String currentHistoryFile;
|
||||
public String updatedAt;
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
package shine.agent.botcoder.state;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
public class RuntimeStateStore {
|
||||
|
||||
private final Path stateFile;
|
||||
private final ObjectMapper mapper;
|
||||
private RuntimeState state;
|
||||
|
||||
public RuntimeStateStore(Path stateFile) throws IOException {
|
||||
this.stateFile = stateFile;
|
||||
this.mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
Path parent = stateFile.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
this.state = loadOrCreate();
|
||||
persist();
|
||||
}
|
||||
|
||||
public synchronized RuntimeState snapshot() {
|
||||
RuntimeState copy = new RuntimeState();
|
||||
copy.activeJobId = state.activeJobId;
|
||||
copy.currentHistoryFile = state.currentHistoryFile;
|
||||
copy.updatedAt = state.updatedAt;
|
||||
return copy;
|
||||
}
|
||||
|
||||
public synchronized void setActiveJobId(String activeJobId) throws IOException {
|
||||
state.activeJobId = activeJobId;
|
||||
state.updatedAt = Instant.now().toString();
|
||||
persist();
|
||||
}
|
||||
|
||||
public synchronized void setCurrentHistoryFile(String historyFile) throws IOException {
|
||||
state.currentHistoryFile = historyFile;
|
||||
state.updatedAt = Instant.now().toString();
|
||||
persist();
|
||||
}
|
||||
|
||||
private RuntimeState loadOrCreate() throws IOException {
|
||||
if (!Files.exists(stateFile)) {
|
||||
RuntimeState created = new RuntimeState();
|
||||
created.updatedAt = Instant.now().toString();
|
||||
return created;
|
||||
}
|
||||
String raw = Files.readString(stateFile, StandardCharsets.UTF_8).trim();
|
||||
if (raw.isEmpty()) {
|
||||
RuntimeState created = new RuntimeState();
|
||||
created.updatedAt = Instant.now().toString();
|
||||
return created;
|
||||
}
|
||||
RuntimeState loaded = mapper.readValue(raw, RuntimeState.class);
|
||||
if (loaded.updatedAt == null) {
|
||||
loaded.updatedAt = Instant.now().toString();
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private void persist() throws IOException {
|
||||
Files.writeString(
|
||||
stateFile,
|
||||
mapper.writeValueAsString(state),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
package shine.agent.botcoder.state;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
public final class SingleInstanceLock implements Closeable {
|
||||
|
||||
private final FileChannel channel;
|
||||
private final FileLock lock;
|
||||
private final Path path;
|
||||
|
||||
private SingleInstanceLock(FileChannel channel, FileLock lock, Path path) {
|
||||
this.channel = channel;
|
||||
this.lock = lock;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public static SingleInstanceLock tryAcquire(Path lockFile) throws IOException {
|
||||
FileChannel channel = FileChannel.open(
|
||||
lockFile,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
FileLock lock = channel.tryLock();
|
||||
if (lock == null) {
|
||||
channel.close();
|
||||
return null;
|
||||
}
|
||||
return new SingleInstanceLock(channel, lock, lockFile);
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
if (lock != null && lock.isValid()) {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
channel.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,75 +0,0 @@
|
||||
package shine.agent.botcoder.telegram;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Iterator;
|
||||
|
||||
public class ProcessedUpdatesStore {
|
||||
|
||||
private final Path file;
|
||||
private final LinkedHashSet<String> ids = new LinkedHashSet<>();
|
||||
private final int maxEntries;
|
||||
|
||||
public ProcessedUpdatesStore(Path file, int maxEntries) throws IOException {
|
||||
this.file = file;
|
||||
this.maxEntries = Math.max(100, maxEntries);
|
||||
Path parent = file.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
if (Files.exists(file)) {
|
||||
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
|
||||
for (String line : lines) {
|
||||
String id = line.trim();
|
||||
if (!id.isEmpty()) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
trimIfNeeded();
|
||||
persistAll();
|
||||
}
|
||||
|
||||
public synchronized boolean isDuplicateAndMark(String id) throws IOException {
|
||||
if (id == null || id.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String normalized = id.trim();
|
||||
if (ids.contains(normalized)) {
|
||||
return true;
|
||||
}
|
||||
ids.add(normalized);
|
||||
trimIfNeeded();
|
||||
Files.writeString(file, normalized + System.lineSeparator(), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void trimIfNeeded() throws IOException {
|
||||
if (ids.size() <= maxEntries) {
|
||||
return;
|
||||
}
|
||||
int toRemove = ids.size() - maxEntries;
|
||||
int removed = 0;
|
||||
Iterator<String> it = ids.iterator();
|
||||
while (it.hasNext() && removed < toRemove) {
|
||||
it.next();
|
||||
it.remove();
|
||||
removed++;
|
||||
}
|
||||
persistAll();
|
||||
}
|
||||
|
||||
private void persistAll() throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String id : ids) {
|
||||
sb.append(id).append(System.lineSeparator());
|
||||
}
|
||||
Files.writeString(file, sb.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@ -1,645 +0,0 @@
|
||||
package shine.agent.botcoder.telegram;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
|
||||
import org.telegram.telegrambots.meta.api.methods.GetFile;
|
||||
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
|
||||
import org.telegram.telegrambots.meta.api.objects.Message;
|
||||
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||
import org.telegram.telegrambots.meta.api.objects.User;
|
||||
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
|
||||
import shine.agent.botcoder.codex.CodexStatusListener;
|
||||
import shine.agent.botcoder.codex.CodexClient;
|
||||
import shine.agent.botcoder.config.AppConfig;
|
||||
import shine.agent.botcoder.history.HistoryManager;
|
||||
import shine.agent.botcoder.openai.OpenAiTranscriber;
|
||||
import shine.agent.botcoder.queue.FailureResult;
|
||||
import shine.agent.botcoder.queue.QueueJob;
|
||||
import shine.agent.botcoder.queue.QueueStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class ShineAgentBot extends TelegramLongPollingBot {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ShineAgentBot.class);
|
||||
|
||||
private final AppConfig config;
|
||||
private final QueueStore queueStore;
|
||||
private final HistoryManager historyManager;
|
||||
private final OpenAiTranscriber transcriber;
|
||||
private final CodexClient codexClient;
|
||||
private final ExecutorService worker;
|
||||
private final ExecutorService notifier;
|
||||
private final AtomicBoolean running;
|
||||
private final HttpClient httpClient;
|
||||
private final ProcessedUpdatesStore processedUpdatesStore;
|
||||
private final AtomicReference<QueueJob> activeJobRef = new AtomicReference<>();
|
||||
private final AtomicLong activeJobStartedAt = new AtomicLong(0L);
|
||||
private final ScheduledExecutorService heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "shine-agent-heartbeat");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
public ShineAgentBot(
|
||||
AppConfig config,
|
||||
QueueStore queueStore,
|
||||
HistoryManager historyManager,
|
||||
OpenAiTranscriber transcriber,
|
||||
CodexClient codexClient,
|
||||
ProcessedUpdatesStore processedUpdatesStore
|
||||
) {
|
||||
this.config = config;
|
||||
this.queueStore = queueStore;
|
||||
this.historyManager = historyManager;
|
||||
this.transcriber = transcriber;
|
||||
this.codexClient = codexClient;
|
||||
this.processedUpdatesStore = processedUpdatesStore;
|
||||
this.worker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "shine-agent-bot-worker");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.notifier = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "shine-agent-bot-notifier");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.running = new AtomicBoolean(true);
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.build();
|
||||
}
|
||||
|
||||
public void startWorkers() {
|
||||
worker.submit(this::processLoop);
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
running.set(false);
|
||||
codexClient.stopActiveProcess();
|
||||
worker.shutdown();
|
||||
notifier.shutdown();
|
||||
heartbeatScheduler.shutdownNow();
|
||||
try {
|
||||
if (!worker.awaitTermination(10, TimeUnit.SECONDS)) {
|
||||
worker.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
try {
|
||||
if (!notifier.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
notifier.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
notifier.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotUsername() {
|
||||
return config.botUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotToken() {
|
||||
return config.telegramBotToken();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdateReceived(Update update) {
|
||||
if (update == null || !update.hasMessage()) {
|
||||
return;
|
||||
}
|
||||
Message message = update.getMessage();
|
||||
try {
|
||||
String updateId = message.getChatId() + ":" + message.getMessageId();
|
||||
if (processedUpdatesStore.isDuplicateAndMark(updateId)) {
|
||||
log.info("Дубликат update пропущен: {}", updateId);
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Не удалось проверить дубликат update", e);
|
||||
}
|
||||
|
||||
User from = message.getFrom();
|
||||
if (from == null) {
|
||||
return;
|
||||
}
|
||||
String username = AppConfig.normalizeUsername(from.getUserName());
|
||||
if (!username.equals(config.allowedTelegramUsername())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (message.hasText() && "/new".equalsIgnoreCase(message.getText().trim())) {
|
||||
handleNewCommand(message, username);
|
||||
return;
|
||||
}
|
||||
if (message.hasText() && handleControlCommand(message)) {
|
||||
return;
|
||||
}
|
||||
if (message.hasText() && !message.getText().isBlank()) {
|
||||
enqueueText(message, username);
|
||||
return;
|
||||
}
|
||||
if (message.hasVoice()) {
|
||||
enqueueVoice(message, username);
|
||||
return;
|
||||
}
|
||||
if (message.hasAudio()) {
|
||||
enqueueAudio(message, username);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка обработки update", e);
|
||||
safeSendText(message.getChatId(), "Ошибка обработки входящего сообщения: " + shortError(e), message.getMessageId());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleNewCommand(Message message, String username) throws IOException {
|
||||
historyManager.appendSystemEvent(
|
||||
"command_new_received",
|
||||
Map.of(
|
||||
"chatId", message.getChatId(),
|
||||
"messageId", message.getMessageId(),
|
||||
"username", username
|
||||
)
|
||||
);
|
||||
var archived = historyManager.rotateHistory("command_new", username);
|
||||
String response = "История очищена. Новый диалог начат.\nАрхив: " + archived.getFileName();
|
||||
safeSendText(message.getChatId(), response, message.getMessageId());
|
||||
}
|
||||
|
||||
private void enqueueText(Message message, String username) throws IOException {
|
||||
QueueJob job = QueueJob.textJob(
|
||||
message.getChatId(),
|
||||
message.getMessageId(),
|
||||
username,
|
||||
message.getText(),
|
||||
historyManager.currentHistoryFile().toString()
|
||||
);
|
||||
historyManager.appendIncomingText(message.getChatId(), message.getMessageId(), username, message.getText());
|
||||
queueStore.enqueue(job);
|
||||
safeSendText(message.getChatId(), "Принял в очередь: " + shortId(job.id), message.getMessageId());
|
||||
}
|
||||
|
||||
private void enqueueVoice(Message message, String username) throws IOException {
|
||||
String fileId = message.getVoice().getFileId();
|
||||
QueueJob job = QueueJob.voiceJob(
|
||||
message.getChatId(),
|
||||
message.getMessageId(),
|
||||
username,
|
||||
fileId,
|
||||
historyManager.currentHistoryFile().toString()
|
||||
);
|
||||
historyManager.appendIncomingVoice(message.getChatId(), message.getMessageId(), username, fileId);
|
||||
queueStore.enqueue(job);
|
||||
safeSendText(message.getChatId(), "Голосовое принято в очередь: " + shortId(job.id), message.getMessageId());
|
||||
}
|
||||
|
||||
private void enqueueAudio(Message message, String username) throws IOException {
|
||||
String fileId = message.getAudio().getFileId();
|
||||
QueueJob job = QueueJob.voiceJob(
|
||||
message.getChatId(),
|
||||
message.getMessageId(),
|
||||
username,
|
||||
fileId,
|
||||
historyManager.currentHistoryFile().toString()
|
||||
);
|
||||
historyManager.appendIncomingVoice(message.getChatId(), message.getMessageId(), username, fileId);
|
||||
queueStore.enqueue(job);
|
||||
safeSendText(message.getChatId(), "Аудио принято в очередь: " + shortId(job.id), message.getMessageId());
|
||||
}
|
||||
|
||||
private void processLoop() {
|
||||
while (running.get()) {
|
||||
try {
|
||||
Optional<QueueJob> next = queueStore.activateNext();
|
||||
if (next.isEmpty()) {
|
||||
Thread.sleep(500);
|
||||
continue;
|
||||
}
|
||||
processJob(next.get());
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка worker loop", e);
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processJob(QueueJob job) {
|
||||
ScheduledFuture<?> heartbeat = null;
|
||||
AtomicLong lastJobNotificationAt = new AtomicLong(0L);
|
||||
try {
|
||||
log.info("Начало обработки jobId={}, type={}, chatId={}, attempts={}", job.id, job.type, job.chatId, job.attempts);
|
||||
activeJobRef.set(job);
|
||||
long startedAt = System.currentTimeMillis();
|
||||
activeJobStartedAt.set(startedAt);
|
||||
safeSendText(job.chatId, "Задача " + shortId(job.id) + " взята в работу.", job.messageId);
|
||||
lastJobNotificationAt.set(startedAt);
|
||||
String userText = resolveUserText(job);
|
||||
String prompt = buildPrompt(job, userText);
|
||||
historyManager.appendCodexRequest(job.id, prompt);
|
||||
|
||||
log.info("Вызов Codex для jobId={}", job.id);
|
||||
heartbeat = heartbeatScheduler.scheduleAtFixedRate(
|
||||
() -> {
|
||||
long now = System.currentTimeMillis();
|
||||
long elapsed = now - startedAt;
|
||||
long silence = now - lastJobNotificationAt.get();
|
||||
if (elapsed >= 120_000L && silence >= 120_000L) {
|
||||
lastJobNotificationAt.set(now);
|
||||
notifier.submit(() -> safeSendText(
|
||||
job.chatId,
|
||||
"Статус " + shortId(job.id) + ": задача ещё выполняется, работает уже "
|
||||
+ formatDuration(elapsedSeconds())
|
||||
+ ". От Codex давно нет сообщений.",
|
||||
job.messageId
|
||||
));
|
||||
}
|
||||
},
|
||||
120, 10, TimeUnit.SECONDS
|
||||
);
|
||||
String answer;
|
||||
answer = codexClient.executePrompt(prompt, buildStatusListener(job, lastJobNotificationAt));
|
||||
log.info("Codex завершился для jobId={}, длина ответа={}", job.id, answer.length());
|
||||
safeSendText(job.chatId, "Codex завершил обработку, отправляю результат.", job.messageId);
|
||||
sendLongMessage(job.chatId, answer, job.messageId);
|
||||
historyManager.appendCodexResponse(job.id, answer);
|
||||
historyManager.appendOutgoingMessage(job.id, job.chatId, answer);
|
||||
queueStore.markDone(job.id);
|
||||
log.info("Задача завершена jobId={}", job.id);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
handleInterruptedJob(job, e);
|
||||
} catch (Exception e) {
|
||||
handleFailedJob(job, e);
|
||||
} finally {
|
||||
if (heartbeat != null) {
|
||||
heartbeat.cancel(true);
|
||||
}
|
||||
activeJobRef.set(null);
|
||||
activeJobStartedAt.set(0L);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInterruptedJob(QueueJob job, InterruptedException e) {
|
||||
if (!running.get()) {
|
||||
log.info("Обработка jobId={} прервана из-за остановки сервиса", job.id);
|
||||
try {
|
||||
historyManager.appendSystemEvent("job_interrupted_on_shutdown", Map.of(
|
||||
"jobId", job.id,
|
||||
"reason", shortError(e)
|
||||
));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleFailedJob(job, e);
|
||||
}
|
||||
|
||||
private void handleFailedJob(QueueJob job, Exception e) {
|
||||
String error = shortError(e);
|
||||
log.error("Ошибка обработки jobId={}: {}", job.id, error, e);
|
||||
try {
|
||||
if (!isJobStillInQueue(job.id)) {
|
||||
log.info("Задача {} уже удалена из очереди, ошибка не ретраится", job.id);
|
||||
return;
|
||||
}
|
||||
FailureResult failure = queueStore.markFailed(job.id, error, config.maxRetries());
|
||||
historyManager.appendJobError(job.id, error, failure.willRetry(), failure.attempts(), failure.maxRetries());
|
||||
String message = failure.willRetry()
|
||||
? "Ошибка выполнения задачи " + shortId(job.id) + ", повтор: " + failure.attempts() + "/" + failure.maxRetries()
|
||||
: "Ошибка выполнения задачи " + shortId(job.id) + ". Лимит попыток исчерпан.";
|
||||
safeSendText(job.chatId, message, job.messageId);
|
||||
} catch (Exception inner) {
|
||||
log.error("Не удалось зафиксировать ошибку задачи {}", job.id, inner);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isJobStillInQueue(String jobId) {
|
||||
for (QueueJob item : queueStore.snapshot()) {
|
||||
if (jobId.equals(item.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private CodexStatusListener buildStatusListener(QueueJob job, AtomicLong lastJobNotificationAt) {
|
||||
AtomicReference<String> lastStatus = new AtomicReference<>("");
|
||||
AtomicLong lastSentAt = new AtomicLong(0L);
|
||||
return status -> {
|
||||
long now = System.currentTimeMillis();
|
||||
String prev = lastStatus.get();
|
||||
boolean changed = !status.equals(prev);
|
||||
if (changed && now - lastSentAt.get() > 8_000) {
|
||||
String text = "Статус " + shortId(job.id) + ": " + status;
|
||||
notifier.submit(() -> safeSendText(job.chatId, text, job.messageId));
|
||||
lastStatus.set(status);
|
||||
lastSentAt.set(now);
|
||||
lastJobNotificationAt.set(now);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private String resolveUserText(QueueJob job) throws IOException, InterruptedException, TelegramApiException {
|
||||
if (!"voice".equals(job.type)) {
|
||||
return job.text;
|
||||
}
|
||||
byte[] audio = downloadTelegramFile(job.telegramFileId);
|
||||
String transcription = transcriber.transcribe(audio, job.id + ".ogg");
|
||||
historyManager.appendTranscription(job.id, transcription);
|
||||
safeSendText(job.chatId, "Распознано:\n" + transcription, job.messageId);
|
||||
return transcription;
|
||||
}
|
||||
|
||||
private byte[] downloadTelegramFile(String fileId) throws IOException, InterruptedException, TelegramApiException {
|
||||
GetFile getFile = new GetFile(fileId);
|
||||
org.telegram.telegrambots.meta.api.objects.File tgFile = execute(getFile);
|
||||
String fileUrl = tgFile.getFileUrl(getBotToken());
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(fileUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() >= 300) {
|
||||
throw new IOException("Telegram file download failed HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
}
|
||||
|
||||
private String buildPrompt(QueueJob job, String text) {
|
||||
String retryBlock = "";
|
||||
if (job.retryReason != null && !job.retryReason.isBlank()) {
|
||||
retryBlock = "\n\nПометка retry: " + job.retryReason;
|
||||
}
|
||||
|
||||
return """
|
||||
Пришло сообщение в Telegram.
|
||||
Тип: %s
|
||||
Username отправителя: @%s
|
||||
Текст для обработки:
|
||||
%s
|
||||
|
||||
История диалога (JSONL): %s
|
||||
Инструкции агента: %s
|
||||
Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.%s
|
||||
""".formatted(
|
||||
job.type,
|
||||
job.username,
|
||||
text,
|
||||
job.historyFile,
|
||||
config.agentInstructionsFile(),
|
||||
retryBlock
|
||||
);
|
||||
}
|
||||
|
||||
private boolean handleControlCommand(Message message) throws IOException {
|
||||
String text = message.getText().trim();
|
||||
String lower = text.toLowerCase();
|
||||
|
||||
if ("/start".equals(lower) || "/help".equals(lower)) {
|
||||
safeSendText(message.getChatId(), helpText(), message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
if ("/status".equals(lower)) {
|
||||
safeSendText(message.getChatId(), buildStatusText(), message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
if ("/queue".equals(lower)) {
|
||||
safeSendText(message.getChatId(), buildQueueText(), message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
if ("/restart_service".equals(lower) || "/restart".equals(lower)) {
|
||||
historyManager.appendSystemEvent("restart_service_requested", Map.of(
|
||||
"chatId", message.getChatId(),
|
||||
"messageId", message.getMessageId(),
|
||||
"timestamp", Instant.now().toString()
|
||||
));
|
||||
safeSendText(
|
||||
message.getChatId(),
|
||||
"Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится.",
|
||||
message.getMessageId()
|
||||
);
|
||||
scheduleSelfRestart();
|
||||
return true;
|
||||
}
|
||||
if ("/stop".equals(lower)) {
|
||||
boolean stopped = codexClient.stopActiveProcess();
|
||||
if (stopped) {
|
||||
queueStore.cancelActiveJob("stopped_by_user");
|
||||
historyManager.appendSystemEvent("job_stopped_by_user", Map.of(
|
||||
"timestamp", Instant.now().toString()
|
||||
));
|
||||
safeSendText(message.getChatId(), "Текущая задача остановлена и удалена из очереди.", message.getMessageId());
|
||||
} else {
|
||||
safeSendText(message.getChatId(), "Сейчас нет активной задачи.", message.getMessageId());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (lower.startsWith("/cancel")) {
|
||||
String[] parts = text.split("\\s+", 2);
|
||||
if (parts.length < 2) {
|
||||
safeSendText(message.getChatId(), "Использование: /cancel <id|all>", message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
String arg = parts[1].trim();
|
||||
if ("all".equalsIgnoreCase(arg)) {
|
||||
codexClient.stopActiveProcess();
|
||||
int cancelled = queueStore.cancelAll("cancel_all_by_user");
|
||||
safeSendText(message.getChatId(), "Удалено задач из очереди: " + cancelled, message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
Optional<QueueJob> active = queueStore.getActiveJob();
|
||||
if (active.isPresent() && active.get().id != null
|
||||
&& active.get().id.toLowerCase().startsWith(arg.toLowerCase())) {
|
||||
codexClient.stopActiveProcess();
|
||||
}
|
||||
boolean cancelled = queueStore.cancelByIdPrefix(arg);
|
||||
safeSendText(message.getChatId(),
|
||||
cancelled ? "Задача удалена: " + arg : "Задача не найдена: " + arg,
|
||||
message.getMessageId());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String buildStatusText() {
|
||||
Optional<QueueJob> active = queueStore.getActiveJob();
|
||||
int pending = queueStore.pendingCount();
|
||||
if (active.isEmpty()) {
|
||||
return "Статус: активной задачи нет.\nВ очереди pending: " + pending;
|
||||
}
|
||||
QueueJob job = active.get();
|
||||
return "Статус: активная задача " + shortId(job.id) +
|
||||
"\nТип: " + job.type +
|
||||
"\nПопытка: " + (job.attempts + 1) + "/" + config.maxRetries() +
|
||||
"\nВыполняется: " + elapsedSeconds() + "с" +
|
||||
"\nPending: " + pending;
|
||||
}
|
||||
|
||||
private String buildQueueText() {
|
||||
List<QueueJob> jobs = queueStore.snapshot();
|
||||
if (jobs.isEmpty()) {
|
||||
return "Очередь пуста.";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Очередь: ").append(jobs.size()).append('\n');
|
||||
int limit = Math.min(jobs.size(), 10);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
QueueJob job = jobs.get(i);
|
||||
sb.append(i + 1).append(") ")
|
||||
.append(shortId(job.id))
|
||||
.append(" [").append(job.status).append("] ")
|
||||
.append(job.type)
|
||||
.append(" attempts=").append(job.attempts)
|
||||
.append('\n');
|
||||
}
|
||||
if (jobs.size() > limit) {
|
||||
sb.append("...и ещё ").append(jobs.size() - limit).append(" задач");
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private String helpText() {
|
||||
return """
|
||||
Доступные команды:
|
||||
/status — активная задача и размер очереди
|
||||
/queue — список задач в очереди
|
||||
/stop — остановить текущую задачу
|
||||
/cancel <id|all> — удалить задачу по id (префикс) или все
|
||||
/new — архивировать историю и начать новую
|
||||
/restart_service — перезапустить сервис через systemd
|
||||
/help — эта справка
|
||||
""";
|
||||
}
|
||||
|
||||
private void scheduleSelfRestart() {
|
||||
Thread restartThread = new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(1500);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
log.info("Перезапуск сервиса запрошен командой Telegram");
|
||||
System.exit(0);
|
||||
}, "shine-agent-self-restart");
|
||||
restartThread.setDaemon(true);
|
||||
restartThread.start();
|
||||
}
|
||||
|
||||
private void safeSendText(long chatId, String text, Integer replyToMessageId) {
|
||||
try {
|
||||
SendMessage message = new SendMessage();
|
||||
message.setChatId(String.valueOf(chatId));
|
||||
message.setText(trimForTelegram(text));
|
||||
if (replyToMessageId != null) {
|
||||
message.setReplyToMessageId(replyToMessageId);
|
||||
}
|
||||
execute(message);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось отправить сообщение в Telegram", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendLongMessage(long chatId, String text, Integer replyToMessageId) {
|
||||
String normalized = text == null ? "" : text.strip();
|
||||
if (normalized.isEmpty()) {
|
||||
safeSendText(chatId, "(пустой ответ)", replyToMessageId);
|
||||
return;
|
||||
}
|
||||
int max = 3500;
|
||||
int start = 0;
|
||||
while (start < normalized.length()) {
|
||||
int end = Math.min(start + max, normalized.length());
|
||||
String part = normalized.substring(start, end);
|
||||
safeSendText(chatId, part, replyToMessageId);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private String trimForTelegram(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String text = value.strip();
|
||||
int max = 3900;
|
||||
if (text.length() <= max) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, max) + "\n...[обрезано]";
|
||||
}
|
||||
|
||||
private String shortId(String id) {
|
||||
if (id == null || id.length() < 8) {
|
||||
return id;
|
||||
}
|
||||
return id.substring(0, 8);
|
||||
}
|
||||
|
||||
private long elapsedSeconds() {
|
||||
long started = activeJobStartedAt.get();
|
||||
if (started <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (System.currentTimeMillis() - started) / 1000L;
|
||||
}
|
||||
|
||||
private String formatDuration(long seconds) {
|
||||
long safeSeconds = Math.max(0, seconds);
|
||||
long hours = safeSeconds / 3600;
|
||||
long minutes = (safeSeconds % 3600) / 60;
|
||||
long sec = safeSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return hours + "ч " + minutes + "м " + sec + "с";
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return minutes + "м " + sec + "с";
|
||||
}
|
||||
return sec + "с";
|
||||
}
|
||||
|
||||
private String shortError(Throwable e) {
|
||||
String message = e.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return e.getClass().getSimpleName();
|
||||
}
|
||||
String normalized = message.replace('\n', ' ').replace('\r', ' ').trim();
|
||||
if (normalized.length() > 600) {
|
||||
return normalized.substring(0, 600);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.86
|
||||
server.version=1.2.80
|
||||
client.version=1.2.87
|
||||
server.version=1.2.81
|
||||
|
||||
@ -8,4 +8,3 @@ include 'shine-server-blockchain'
|
||||
include 'shine-server-db'
|
||||
include 'shine-server-net-protocol'
|
||||
include 'shine-server-net-server'
|
||||
include 'SHiNE-agent-bot-coder'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user