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:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
bot/.dart_tool
|
||||||
|
bot/build
|
||||||
|
mcp_server/.dart_tool
|
||||||
|
mcp_server/build
|
||||||
37
.gitea/workflows/build.yaml
Normal file
37
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: registry.service.bearsdev.tech
|
||||||
|
IMAGE: agent-news
|
||||||
|
REPO: platformteam-projects/agent-news
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [ubuntu-latest]
|
||||||
|
steps:
|
||||||
|
- name: Build & Push
|
||||||
|
run: |
|
||||||
|
git clone https://gitea_admin:${{ secrets.CI_TOKEN }}@gitea.service.bearsdev.tech/${{ env.REPO }}.git app
|
||||||
|
cd app && git checkout ${{ gitea.sha }}
|
||||||
|
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.NEXUS_USERNAME }}" --password-stdin
|
||||||
|
docker buildx build \
|
||||||
|
--builder default \
|
||||||
|
-f runner/Dockerfile \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ gitea.run_number }} \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
- name: Update deploy repo
|
||||||
|
run: |
|
||||||
|
git clone https://gitea_admin:${{ secrets.CI_TOKEN }}@gitea.service.bearsdev.tech/deploy/${{ env.IMAGE }}.git deploy
|
||||||
|
cd deploy
|
||||||
|
sed -i "s|image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ gitea.run_number }}|" deployment.yaml
|
||||||
|
git config user.email "ci@bearsdev.tech"
|
||||||
|
git config user.name "Gitea CI"
|
||||||
|
git add deployment.yaml
|
||||||
|
git commit -m "ci: update image to ${{ env.IMAGE }}:${{ gitea.run_number }}" || echo "no changes"
|
||||||
|
git push https://gitea_admin:${{ secrets.DEPLOY_TOKEN }}@gitea.service.bearsdev.tech/deploy/${{ env.IMAGE }}.git main
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# agent-news
|
||||||
|
|
||||||
|
Telegram-бот на Dart: по команде `/news` (или `/news <тема>`) запускает Claude Code
|
||||||
|
для ресёрча и присылает выжимку в чат. Учебный проект для platformteam — архитектура
|
||||||
|
и урок описаны в [aidoc](https://aidoc.platformteam.tech), страница `practices/agent-news-bot`.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
Один под, один контейнер, три процесса:
|
||||||
|
|
||||||
|
1. `agent-news-bot` (Dart, long polling) — слушает `/news`, спавнит `claude` как сабпроцесс.
|
||||||
|
2. `claude` (Claude Code CLI) — делает ресёрч, в конце вызывает MCP-тул `send_to_telegram`.
|
||||||
|
3. `agent-news-mcp` (Dart, stdio MCP-сервер) — спавнится самим `claude`, шлёт готовый
|
||||||
|
текст в Telegram Bot API.
|
||||||
|
|
||||||
|
Подробности и диаграммы — в уроке aidoc.
|
||||||
|
|
||||||
|
## Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bot && dart pub get && dart analyze
|
||||||
|
cd ../mcp_server && dart pub get && dart analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Сборка образа (контекст — корень репозитория):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f runner/Dockerfile -t agent-news:dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | да | токен бота, используется и ботом, и MCP-сервером |
|
||||||
|
| `ALLOWED_CHAT_IDS` | да | список chat_id через запятую — кому отвечает бот |
|
||||||
|
| `NEWS_PROMPT` | нет | промпт по умолчанию для `/news` без темы |
|
||||||
|
| `CLAUDE_TIMEOUT_SECONDS` | нет | таймаут на ресёрч (по умолчанию 360) |
|
||||||
|
| `MCP_CONFIG_PATH` | нет | путь к `mcp.json` (по умолчанию `/home/agent/claude-config/mcp.json`) |
|
||||||
|
|
||||||
|
## Первичная авторизация Claude Code
|
||||||
|
|
||||||
|
Claude Code CLI использует OAuth-логин через Claude.ai (подписка, не API-ключ). После
|
||||||
|
первого деплоя нужно один раз пройти логин внутри пода — иначе `claude -p ...` будет
|
||||||
|
падать с ошибкой авторизации:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it -n platformteam deploy/agent-news -- claude
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI напечатает ссылку — открой её в браузере, авторизуйся. Токен сохранится в
|
||||||
|
`~/.claude` (persist через PVC `agent-news-home`, см. `deploy/agent-news`), так что
|
||||||
|
после рестарта пода логин не слетит.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Стандартный BearsDev CI/CD (см. `bearsdev-systems/ai-agents/knowledge/cicd.md`):
|
||||||
|
`git push` → `.gitea/workflows/build.yaml` собирает и пушит образ → бампит тег в
|
||||||
|
`deploy/agent-news` → `.gitea/workflows/deploy.yaml` применяет манифест в K3s.
|
||||||
|
|
||||||
|
Перед первым деплоем вручную (см. план и `cicd.md`):
|
||||||
|
1. Создать `deploy/agent-news` репо с `deployment.yaml` + `.gitea/workflows/deploy.yaml`.
|
||||||
|
2. `kubectl apply -f pvc.yaml` — PVC `agent-news-home`, один раз, не через CI.
|
||||||
|
3. `kubectl create secret generic agent-news-secrets -n platformteam --from-literal=TELEGRAM_BOT_TOKEN=... --from-literal=ALLOWED_CHAT_IDS=...`
|
||||||
|
4. После первого деплоя — авторизация Claude Code (см. выше).
|
||||||
98
bot/bin/agent_news_bot.dart
Normal file
98
bot/bin/agent_news_bot.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
93
bot/pubspec.lock
Normal file
93
bot/pubspec.lock
Normal 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
10
bot/pubspec.yaml
Normal 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
|
||||||
82
mcp_server/bin/agent_news_mcp.dart
Normal file
82
mcp_server/bin/agent_news_mcp.dart
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:mcp_dart/mcp_dart.dart';
|
||||||
|
|
||||||
|
final _token = Platform.environment['TELEGRAM_BOT_TOKEN'] ?? '';
|
||||||
|
final _http = HttpClient();
|
||||||
|
|
||||||
|
CallToolResult _ok(String text) => CallToolResult(content: [TextContent(text: text)]);
|
||||||
|
|
||||||
|
CallToolResult _err(String text) =>
|
||||||
|
CallToolResult(content: [TextContent(text: 'Ошибка: $text')], isError: true);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage(int chatId, String text) async {
|
||||||
|
for (final chunk in _splitMessage(text)) {
|
||||||
|
final req = await _http.postUrl(
|
||||||
|
Uri.parse('https://api.telegram.org/bot$_token/sendMessage'),
|
||||||
|
);
|
||||||
|
req.headers.contentType = ContentType.json;
|
||||||
|
req.write(jsonEncode({
|
||||||
|
'chat_id': chatId,
|
||||||
|
'text': chunk,
|
||||||
|
'parse_mode': 'Markdown',
|
||||||
|
}));
|
||||||
|
final res = await req.close();
|
||||||
|
final body = await res.transform(utf8.decoder).join();
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw Exception('Telegram sendMessage → ${res.statusCode}: $body');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final server = McpServer(
|
||||||
|
const Implementation(name: 'agent-news', version: '0.1.0'),
|
||||||
|
options: const McpServerOptions(
|
||||||
|
capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'send_to_telegram',
|
||||||
|
description:
|
||||||
|
'Отправляет готовый текст (Markdown) в указанный Telegram-чат. '
|
||||||
|
'Единственный способ доставить результат ресёрча пользователю — '
|
||||||
|
'вызывай этот тул с финальным оформленным текстом.',
|
||||||
|
inputSchema: JsonSchema.object(
|
||||||
|
properties: {
|
||||||
|
'chat_id': JsonSchema.integer(description: 'ID чата Telegram, куда отправить сообщение'),
|
||||||
|
'text': JsonSchema.string(description: 'Готовый текст в Markdown-разметке Telegram'),
|
||||||
|
},
|
||||||
|
required: ['chat_id', 'text'],
|
||||||
|
),
|
||||||
|
callback: (args, _) async {
|
||||||
|
if (_token.isEmpty) {
|
||||||
|
return _err('TELEGRAM_BOT_TOKEN не задан в окружении MCP-сервера');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final chatId = args['chat_id'] as int;
|
||||||
|
final text = args['text'].toString();
|
||||||
|
await _sendMessage(chatId, text);
|
||||||
|
return _ok('Отправлено в чат $chatId (${text.length} символов).');
|
||||||
|
} catch (e) {
|
||||||
|
return _err('$e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.connect(StdioServerTransport());
|
||||||
|
}
|
||||||
101
mcp_server/pubspec.lock
Normal file
101
mcp_server/pubspec.lock
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 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: transitive
|
||||||
|
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"
|
||||||
|
mcp_dart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mcp_dart
|
||||||
|
sha256: "106118d1e7628becc72c8485dc1a3e2c8ed62a48527efc6d70b8576d3b18083f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
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
mcp_server/pubspec.yaml
Normal file
10
mcp_server/pubspec.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: agent_news_mcp
|
||||||
|
description: agent-news — MCP server exposing send_to_telegram for Claude Code
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.11.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
mcp_dart: ^1.2.0
|
||||||
42
runner/Dockerfile
Normal file
42
runner/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# agent-news — бот + claude-code CLI + MCP-мост в одном контейнере (Decision: см. план).
|
||||||
|
# Контекст сборки = корень репозитория (нужны bot/ и mcp_server/).
|
||||||
|
# docker build -f runner/Dockerfile -t agent-news:dev .
|
||||||
|
|
||||||
|
# ── stage 1: компиляция agent-news-bot ──────────────────────────────────
|
||||||
|
FROM mirror.service.bearsdev.tech/library/dart:stable AS bot-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY bot/pubspec.* ./
|
||||||
|
RUN dart pub get
|
||||||
|
COPY bot/ ./
|
||||||
|
RUN dart pub get --offline \
|
||||||
|
&& dart compile exe bin/agent_news_bot.dart -o /build/agent-news-bot
|
||||||
|
|
||||||
|
# ── stage 2: компиляция agent-news-mcp ──────────────────────────────────
|
||||||
|
FROM mirror.service.bearsdev.tech/library/dart:stable AS mcp-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY mcp_server/pubspec.* ./
|
||||||
|
RUN dart pub get
|
||||||
|
COPY mcp_server/ ./
|
||||||
|
RUN dart pub get --offline \
|
||||||
|
&& dart compile exe bin/agent_news_mcp.dart -o /build/agent-news-mcp
|
||||||
|
|
||||||
|
# ── stage 3: runtime — node (для claude-code CLI) + оба dart-бинарника ──
|
||||||
|
FROM mirror.service.bearsdev.tech/library/node:22-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& npm install -g @anthropic-ai/claude-code \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=bot-build /build/agent-news-bot /usr/local/bin/agent-news-bot
|
||||||
|
COPY --from=mcp-build /build/agent-news-mcp /usr/local/bin/agent-news-mcp
|
||||||
|
RUN chmod +x /usr/local/bin/agent-news-bot /usr/local/bin/agent-news-mcp
|
||||||
|
|
||||||
|
RUN useradd -m -u 10001 agent
|
||||||
|
COPY --chown=agent:agent runner/claude-config /home/agent/claude-config
|
||||||
|
USER agent
|
||||||
|
WORKDIR /home/agent
|
||||||
|
ENV MCP_CONFIG_PATH=/home/agent/claude-config/mcp.json
|
||||||
|
|
||||||
|
# ~/.claude (OAuth-логин claude-code) монтируется сюда через PVC — см. deploy/agent-news.
|
||||||
|
CMD ["agent-news-bot"]
|
||||||
7
runner/claude-config/mcp.json
Normal file
7
runner/claude-config/mcp.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agent-news": {
|
||||||
|
"command": "/usr/local/bin/agent-news-mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user