commit be09de2ad7f5915e17812f4b9e1e727eb2a6e700 Author: matt2dev Date: Tue Jul 21 20:40:32 2026 +0300 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b57c9b5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +bot/.dart_tool +bot/build +mcp_server/.dart_tool +mcp_server/build diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..b6206d9 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8c7d1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd148fa --- /dev/null +++ b/README.md @@ -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 (см. выше). diff --git a/bot/bin/agent_news_bot.dart b/bot/bin/agent_news_bot.dart new file mode 100644 index 0000000..57ad60d --- /dev/null +++ b/bot/bin/agent_news_bot.dart @@ -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 _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; + } +} diff --git a/bot/lib/claude_runner.dart b/bot/lib/claude_runner.dart new file mode 100644 index 0000000..34a3261 --- /dev/null +++ b/bot/lib/claude_runner.dart @@ -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 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, с заголовком>. ' + 'Это единственный способ доставить материал — не выводи финальный ответ никак ' + 'иначе, только через вызов этого инструмента.'; + } +} diff --git a/bot/lib/config.dart b/bot/lib/config.dart new file mode 100644 index 0000000..66a7365 --- /dev/null +++ b/bot/lib/config.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +class Config { + final String telegramToken; + final Set 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', + ); + } +} diff --git a/bot/lib/telegram.dart b/bot/lib/telegram.dart new file mode 100644 index 0000000..1a8cada --- /dev/null +++ b/bot/lib/telegram.dart @@ -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>> 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; + if (data['ok'] != true) return []; + return (data['result'] as List).cast>(); + } catch (_) { + return []; + } + } + + Future sendMessage(int chatId, String text, {int? replyToMessageId}) async { + final chunks = _splitMessage(text); + for (final chunk in chunks) { + final payload = { + '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 = {'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 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 _splitMessage(String text) { + const maxLen = 4000; + if (text.length <= maxLen) return [text]; + final chunks = []; + 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(); +} diff --git a/bot/pubspec.lock b/bot/pubspec.lock new file mode 100644 index 0000000..b514136 --- /dev/null +++ b/bot/pubspec.lock @@ -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" diff --git a/bot/pubspec.yaml b/bot/pubspec.yaml new file mode 100644 index 0000000..6a72958 --- /dev/null +++ b/bot/pubspec.yaml @@ -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 diff --git a/mcp_server/bin/agent_news_mcp.dart b/mcp_server/bin/agent_news_mcp.dart new file mode 100644 index 0000000..50080db --- /dev/null +++ b/mcp_server/bin/agent_news_mcp.dart @@ -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 _splitMessage(String text) { + const maxLen = 4000; + if (text.length <= maxLen) return [text]; + final chunks = []; + 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 _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()); +} diff --git a/mcp_server/pubspec.lock b/mcp_server/pubspec.lock new file mode 100644 index 0000000..f5efb8e --- /dev/null +++ b/mcp_server/pubspec.lock @@ -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" diff --git a/mcp_server/pubspec.yaml b/mcp_server/pubspec.yaml new file mode 100644 index 0000000..ea873d1 --- /dev/null +++ b/mcp_server/pubspec.yaml @@ -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 diff --git a/runner/Dockerfile b/runner/Dockerfile new file mode 100644 index 0000000..5ed88a5 --- /dev/null +++ b/runner/Dockerfile @@ -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"] diff --git a/runner/claude-config/mcp.json b/runner/claude-config/mcp.json new file mode 100644 index 0000000..ef2d3d3 --- /dev/null +++ b/runner/claude-config/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "agent-news": { + "command": "/usr/local/bin/agent-news-mcp" + } + } +}