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:
2026-07-21 20:40:32 +03:00
commit be09de2ad7
15 changed files with 764 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:io';
import '../lib/config.dart';
import '../lib/telegram.dart';
import '../lib/claude_runner.dart';
void main() async {
final config = Config.fromEnv();
if (config.telegramToken.isEmpty) {
stderr.writeln('ERROR: TELEGRAM_BOT_TOKEN must be set');
exit(1);
}
if (config.allowedChatIds.isEmpty) {
stderr.writeln('ERROR: ALLOWED_CHAT_IDS must be set (comma-separated chat ids)');
exit(1);
}
final telegram = TelegramClient(config.telegramToken);
final claude = ClaudeRunner(config);
stderr.writeln('agent-news-bot starting, allowed chats: ${config.allowedChatIds}');
var offset = 0;
while (true) {
final updates = await telegram.getUpdates(offset: offset);
for (final update in updates) {
offset = (update['update_id'] as int) + 1;
final message = update['message'];
if (message == null) continue;
final chatId = message['chat']['id'] as int;
final messageId = message['message_id'] as int;
final text = message['text']?.toString().trim();
if (text == null || text.isEmpty) continue;
if (!config.allowedChatIds.contains(chatId)) {
stderr.writeln(' → отклонено, чат $chatId не в ALLOWED_CHAT_IDS');
continue;
}
if (text == '/start') {
await telegram.sendMessage(
chatId,
'Привет! Я agent-news. Команда /news — свежая выжимка по ИИ '
'(ресёрч делает Claude Code, займёт 1-3 минуты).',
);
continue;
}
if (text == '/news' || text.startsWith('/news ')) {
final topic = text.startsWith('/news ') ? text.substring(6).trim() : null;
if (claude.isBusy) {
await telegram.sendMessage(chatId, 'Уже выполняю другой запрос, подожди.',
replyToMessageId: messageId);
continue;
}
await telegram.sendMessage(
chatId,
'🔎 Ищу свежую информацию${topic != null ? ' по теме «$topic»' : ' по ИИ'}, '
'это займёт пару минут…',
replyToMessageId: messageId,
);
await telegram.sendTyping(chatId);
unawaited(_runNews(claude, telegram, chatId, topic));
continue;
}
}
}
}
Future<void> _runNews(
ClaudeRunner claude,
TelegramClient telegram,
int chatId,
String? topic,
) async {
final result = await claude.runNews(chatId: chatId, topic: topic);
switch (result.outcome) {
case ClaudeOutcome.success:
stderr.writeln('news for $chatId done');
break;
case ClaudeOutcome.busy:
break; // уже сообщили выше
case ClaudeOutcome.timeout:
await telegram.sendMessage(chatId, 'Не успел уложиться по времени, попробуй позже.');
break;
case ClaudeOutcome.failure:
stderr.writeln('news for $chatId failed: ${result.detail}');
await telegram.sendMessage(chatId, 'Не получилось выполнить ресёрч, попробуй позже.');
break;
}
}

View 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
View 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
View 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();
}

93
bot/pubspec.lock Normal file
View File

@@ -0,0 +1,93 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.11.0 <4.0.0"

10
bot/pubspec.yaml Normal file
View File

@@ -0,0 +1,10 @@
name: agent_news_bot
description: Telegram-бот — по /news запускает Claude Code для ресёрча по ИИ
version: 0.1.0
publish_to: none
environment:
sdk: ^3.11.0
dependencies:
http: ^1.2.0