Initial agent-news: Dart Telegram bot + Claude Code runner + MCP bridge
/news spawns claude-code as a local subprocess (single pod, no cross-container exec on K3s); Claude delivers the digest itself via the send_to_telegram MCP tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
87
bot/lib/claude_runner.dart
Normal file
87
bot/lib/claude_runner.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'config.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,
|
||||
'--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 base = topic == null || topic.isEmpty
|
||||
? config.newsPrompt
|
||||
: 'Проведи ресёрч последних новостей и трендов по теме "$topic" за последние '
|
||||
'7 дней. Используй поиск в интернете. Составь краткую выжимку: 5-8 пунктов, '
|
||||
'у каждого — суть в 1-2 предложениях и ссылка на источник.';
|
||||
|
||||
return '$base\n\n'
|
||||
'Когда выжимка готова, ОБЯЗАТЕЛЬНО вызови инструмент send_to_telegram с '
|
||||
'параметрами chat_id=$chatId и text=<готовый текст в Markdown, с заголовком>. '
|
||||
'Это единственный способ доставить материал — не выводи финальный ответ никак '
|
||||
'иначе, только через вызов этого инструмента.';
|
||||
}
|
||||
}
|
||||
40
bot/lib/config.dart
Normal file
40
bot/lib/config.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:io';
|
||||
|
||||
class Config {
|
||||
final String telegramToken;
|
||||
final Set<int> allowedChatIds;
|
||||
final String newsPrompt;
|
||||
final int claudeTimeoutSeconds;
|
||||
final String mcpConfigPath;
|
||||
|
||||
Config({
|
||||
required this.telegramToken,
|
||||
required this.allowedChatIds,
|
||||
required this.newsPrompt,
|
||||
required this.claudeTimeoutSeconds,
|
||||
required this.mcpConfigPath,
|
||||
});
|
||||
|
||||
factory Config.fromEnv() {
|
||||
final e = Platform.environment;
|
||||
final allowed = (e['ALLOWED_CHAT_IDS'] ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(int.parse)
|
||||
.toSet();
|
||||
|
||||
return Config(
|
||||
telegramToken: e['TELEGRAM_BOT_TOKEN'] ?? '',
|
||||
allowedChatIds: allowed,
|
||||
newsPrompt: e['NEWS_PROMPT'] ??
|
||||
'Проведи ресёрч последних новостей и трендов в области искусственного '
|
||||
'интеллекта за последние 7 дней. Используй поиск в интернете. '
|
||||
'Составь краткую выжимку: 5-8 пунктов, у каждого — суть в 1-2 '
|
||||
'предложениях и ссылка на источник.',
|
||||
claudeTimeoutSeconds:
|
||||
int.tryParse(e['CLAUDE_TIMEOUT_SECONDS'] ?? '360') ?? 360,
|
||||
mcpConfigPath: e['MCP_CONFIG_PATH'] ?? '/home/agent/claude-config/mcp.json',
|
||||
);
|
||||
}
|
||||
}
|
||||
85
bot/lib/telegram.dart
Normal file
85
bot/lib/telegram.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class TelegramClient {
|
||||
final String token;
|
||||
final http.Client _http;
|
||||
final String _base;
|
||||
|
||||
TelegramClient(this.token)
|
||||
: _http = http.Client(),
|
||||
_base = 'https://api.telegram.org/bot$token';
|
||||
|
||||
// Долгий polling — ждёт до timeout секунд, возвращает новые апдейты
|
||||
Future<List<Map<String, dynamic>>> getUpdates({
|
||||
required int offset,
|
||||
int timeout = 30,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.get(
|
||||
Uri.parse('$_base/getUpdates?offset=$offset&timeout=$timeout&allowed_updates=["message"]'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
).timeout(Duration(seconds: timeout + 5));
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (data['ok'] != true) return [];
|
||||
return (data['result'] as List<dynamic>).cast<Map<String, dynamic>>();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(int chatId, String text, {int? replyToMessageId}) async {
|
||||
final chunks = _splitMessage(text);
|
||||
for (final chunk in chunks) {
|
||||
final payload = <String, dynamic>{
|
||||
'chat_id': chatId,
|
||||
'text': chunk,
|
||||
'parse_mode': 'Markdown',
|
||||
};
|
||||
if (replyToMessageId != null) payload['reply_to_message_id'] = replyToMessageId;
|
||||
try {
|
||||
await _http.post(
|
||||
Uri.parse('$_base/sendMessage'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
} catch (_) {
|
||||
try {
|
||||
final plain = <String, dynamic>{'chat_id': chatId, 'text': chunk};
|
||||
if (replyToMessageId != null) plain['reply_to_message_id'] = replyToMessageId;
|
||||
await _http.post(
|
||||
Uri.parse('$_base/sendMessage'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: jsonEncode(plain),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendTyping(int chatId) async {
|
||||
try {
|
||||
await _http.post(
|
||||
Uri.parse('$_base/sendChatAction'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: jsonEncode({'chat_id': chatId, 'action': 'typing'}),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
List<String> _splitMessage(String text) {
|
||||
const maxLen = 4000;
|
||||
if (text.length <= maxLen) return [text];
|
||||
final chunks = <String>[];
|
||||
var start = 0;
|
||||
while (start < text.length) {
|
||||
final end = (start + maxLen).clamp(0, text.length);
|
||||
chunks.add(text.substring(start, end));
|
||||
start = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
void dispose() => _http.close();
|
||||
}
|
||||
Reference in New Issue
Block a user