- Volume was mounted at ~/.claude only, but claude-code writes auth to ~/.claude.json too — that file lived on the ephemeral container layer and was lost on every recreate. Now the whole $HOME is on the named volume, and claude-config moved outside HOME (/app) so it isn't shadowed by it. - Default /news topic was hardcoded to AI; now it's a configurable DEFAULT_TOPIC (plain "главные новости дня"), topic is fully free-form. - Prompt now instructs Claude to send interim "⏳ ..." progress updates via send_to_telegram while researching, not just the final digest — a bare request like "call this tool" was already taking 3+ minutes end-to-end, so silence read as broken. - All user-facing/prompt text moved into bot/lib/lexicon.dart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
2.8 KiB
Dart
85 lines
2.8 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'config.dart';
|
||
import 'lexicon.dart';
|
||
|
||
enum ClaudeOutcome { success, busy, timeout, failure }
|
||
|
||
class ClaudeResult {
|
||
final ClaudeOutcome outcome;
|
||
final String detail;
|
||
|
||
ClaudeResult._(this.outcome, this.detail);
|
||
|
||
factory ClaudeResult.success(String detail) => ClaudeResult._(ClaudeOutcome.success, detail);
|
||
factory ClaudeResult.busy() => ClaudeResult._(ClaudeOutcome.busy, 'уже выполняется другой запрос');
|
||
factory ClaudeResult.timeout() => ClaudeResult._(ClaudeOutcome.timeout, 'превышено время ожидания');
|
||
factory ClaudeResult.failure(String detail) => ClaudeResult._(ClaudeOutcome.failure, detail);
|
||
}
|
||
|
||
// Спавнит claude-code CLI как сабпроцесс. Финальный текст в чат уходит НЕ отсюда —
|
||
// сам claude вызывает MCP-тул send_to_telegram (см. mcp_server/). Этот раннер только
|
||
// следит, что процесс успешно завершился, и сериализует запросы (одна сессия за раз).
|
||
class ClaudeRunner {
|
||
final Config config;
|
||
bool _busy = false;
|
||
|
||
ClaudeRunner(this.config);
|
||
|
||
bool get isBusy => _busy;
|
||
|
||
Future<ClaudeResult> runNews({required int chatId, String? topic}) async {
|
||
if (_busy) return ClaudeResult.busy();
|
||
_busy = true;
|
||
try {
|
||
final prompt = _buildPrompt(chatId: chatId, topic: topic);
|
||
final process = await Process.start(
|
||
'claude',
|
||
[
|
||
'-p',
|
||
prompt,
|
||
'--mcp-config',
|
||
config.mcpConfigPath,
|
||
'--strict-mcp-config',
|
||
'--dangerously-skip-permissions',
|
||
],
|
||
environment: {'TELEGRAM_BOT_TOKEN': config.telegramToken},
|
||
);
|
||
|
||
final stdoutFuture = process.stdout.transform(utf8.decoder).join();
|
||
final stderrFuture = process.stderr.transform(utf8.decoder).join();
|
||
|
||
int exitCode;
|
||
try {
|
||
exitCode = await process.exitCode.timeout(
|
||
Duration(seconds: config.claudeTimeoutSeconds),
|
||
);
|
||
} on TimeoutException {
|
||
process.kill(ProcessSignal.sigterm);
|
||
return ClaudeResult.timeout();
|
||
}
|
||
|
||
final out = await stdoutFuture;
|
||
final err = await stderrFuture;
|
||
|
||
if (exitCode != 0) {
|
||
return ClaudeResult.failure(err.isNotEmpty ? err : out);
|
||
}
|
||
return ClaudeResult.success(out);
|
||
} finally {
|
||
_busy = false;
|
||
}
|
||
}
|
||
|
||
String _buildPrompt({required int chatId, String? topic}) {
|
||
final effectiveTopic = (topic == null || topic.isEmpty) ? config.defaultTopic : topic;
|
||
return [
|
||
Lexicon.researchInstruction(effectiveTopic),
|
||
Lexicon.progressInstruction(chatId),
|
||
Lexicon.finalDeliveryInstruction(chatId),
|
||
].join('\n\n');
|
||
}
|
||
}
|