From 7bfe0dfe0cf260a3953d7874aba9b53d205a3889 Mon Sep 17 00:00:00 2001 From: matt2dev Date: Tue, 21 Jul 2026 21:53:38 +0300 Subject: [PATCH] Fix HOME volume mount, genericize topic, add progress statuses, extract lexicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 28 +++++++++++++-------- bot/bin/agent_news_bot.dart | 26 ++++++++----------- bot/lib/claude_runner.dart | 18 ++++++-------- bot/lib/config.dart | 15 +++++------ bot/lib/lexicon.dart | 40 ++++++++++++++++++++++++++++++ docker-compose.yml | 2 +- mcp_server/bin/agent_news_mcp.dart | 7 +++--- runner/Dockerfile | 9 ++++--- 8 files changed, 93 insertions(+), 52 deletions(-) create mode 100644 bot/lib/lexicon.dart diff --git a/README.md b/README.md index 572bcc4..136a612 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,15 @@ Telegram-бот на Dart: по команде `/news` (или `/news <тема> Один контейнер, три процесса: -1. `agent-news-bot` (Dart, long polling) — слушает `/news`, спавнит `claude` как сабпроцесс. -2. `claude` (Claude Code CLI) — делает ресёрч, в конце вызывает MCP-тул `send_to_telegram`. +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. +Все тексты, которые бот показывает пользователю или подставляет в промпт Claude, лежат +в одном месте — `bot/lib/lexicon.dart`. + ## Запуск локально ```bash @@ -31,16 +35,20 @@ Claude Code CLI использует OAuth-логин через Claude.ai (по после первого запуска нужно зайти в контейнер и залогиниться: ```bash -docker compose exec agent-news claude +docker compose exec agent-news claude auth login ``` -CLI напечатает ссылку — открой её в браузере, авторизуйся. Токен сохранится в -`~/.claude` на volume, так что после `docker compose restart` логин не слетит. +CLI напечатает ссылку — открой её в браузере, авторизуйся, вставь код обратно в +терминал, если попросит. Учти: и `~/.claude/`, и `~/.claude.json` — оба нужны для +логина, поэтому в volume смонтирован **весь `/home/agent`** (см. `docker-compose.yml`), +не только `~/.claude/`. После `docker compose restart` логин не слетит. ### Проверка -Напиши боту `/news` (или `/news <тема>`) из чата с id из `ALLOWED_CHAT_IDS` — бот -ответит "ищу…", а через 1-3 минуты Claude сам пришлёт выжимку через `send_to_telegram`. +Напиши боту `/news <тема>` (или просто `/news` — возьмёт `DEFAULT_TOPIC`) из чата с id +из `ALLOWED_CHAT_IDS`. Бот сразу ответит "начинаю…", дальше Claude сам присылает статусы +по ходу ресёрча и в конце — готовую выжимку, всё через `send_to_telegram`. Ресёрч +занимает несколько минут — это нормально, статусы как раз для этого. ```bash docker compose logs -f agent-news @@ -59,6 +67,6 @@ cd ../mcp_server && dart pub get && dart analyze |---|---|---| | `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`) | +| `DEFAULT_TOPIC` | нет | тема для `/news` без аргумента (по умолчанию — «главные новости дня») | +| `CLAUDE_TIMEOUT_SECONDS` | нет | таймаут на ресёрч (по умолчанию 600) | +| `MCP_CONFIG_PATH` | нет | путь к `mcp.json` (по умолчанию `/app/claude-config/mcp.json`) | diff --git a/bot/bin/agent_news_bot.dart b/bot/bin/agent_news_bot.dart index 57ad60d..50b7aef 100644 --- a/bot/bin/agent_news_bot.dart +++ b/bot/bin/agent_news_bot.dart @@ -3,6 +3,7 @@ import 'dart:io'; import '../lib/config.dart'; import '../lib/telegram.dart'; import '../lib/claude_runner.dart'; +import '../lib/lexicon.dart'; void main() async { final config = Config.fromEnv(); @@ -41,12 +42,10 @@ void main() async { continue; } + stderr.writeln('[$chatId] $text'); + if (text == '/start') { - await telegram.sendMessage( - chatId, - 'Привет! Я agent-news. Команда /news — свежая выжимка по ИИ ' - '(ресёрч делает Claude Code, займёт 1-3 минуты).', - ); + await telegram.sendMessage(chatId, Lexicon.welcome); continue; } @@ -54,17 +53,13 @@ void main() async { final topic = text.startsWith('/news ') ? text.substring(6).trim() : null; if (claude.isBusy) { - await telegram.sendMessage(chatId, 'Уже выполняю другой запрос, подожди.', - replyToMessageId: messageId); + await telegram.sendMessage(chatId, Lexicon.alreadyBusy, replyToMessageId: messageId); continue; } - await telegram.sendMessage( - chatId, - '🔎 Ищу свежую информацию${topic != null ? ' по теме «$topic»' : ' по ИИ'}, ' - 'это займёт пару минут…', - replyToMessageId: messageId, - ); + final effectiveTopic = (topic == null || topic.isEmpty) ? config.defaultTopic : topic; + await telegram.sendMessage(chatId, Lexicon.starting(effectiveTopic), + replyToMessageId: messageId); await telegram.sendTyping(chatId); unawaited(_runNews(claude, telegram, chatId, topic)); @@ -88,11 +83,12 @@ Future _runNews( case ClaudeOutcome.busy: break; // уже сообщили выше case ClaudeOutcome.timeout: - await telegram.sendMessage(chatId, 'Не успел уложиться по времени, попробуй позже.'); + stderr.writeln('news for $chatId timed out'); + await telegram.sendMessage(chatId, Lexicon.timedOut); break; case ClaudeOutcome.failure: stderr.writeln('news for $chatId failed: ${result.detail}'); - await telegram.sendMessage(chatId, 'Не получилось выполнить ресёрч, попробуй позже.'); + await telegram.sendMessage(chatId, Lexicon.failed); break; } } diff --git a/bot/lib/claude_runner.dart b/bot/lib/claude_runner.dart index 4b841e7..d50ce26 100644 --- a/bot/lib/claude_runner.dart +++ b/bot/lib/claude_runner.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'config.dart'; +import 'lexicon.dart'; enum ClaudeOutcome { success, busy, timeout, failure } @@ -73,16 +74,11 @@ class ClaudeRunner { } 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, с заголовком>. ' - 'Это единственный способ доставить материал — не выводи финальный ответ никак ' - 'иначе, только через вызов этого инструмента.'; + final effectiveTopic = (topic == null || topic.isEmpty) ? config.defaultTopic : topic; + return [ + Lexicon.researchInstruction(effectiveTopic), + Lexicon.progressInstruction(chatId), + Lexicon.finalDeliveryInstruction(chatId), + ].join('\n\n'); } } diff --git a/bot/lib/config.dart b/bot/lib/config.dart index 66a7365..50e9ecb 100644 --- a/bot/lib/config.dart +++ b/bot/lib/config.dart @@ -1,16 +1,17 @@ import 'dart:io'; +import 'lexicon.dart'; class Config { final String telegramToken; final Set allowedChatIds; - final String newsPrompt; + final String defaultTopic; final int claudeTimeoutSeconds; final String mcpConfigPath; Config({ required this.telegramToken, required this.allowedChatIds, - required this.newsPrompt, + required this.defaultTopic, required this.claudeTimeoutSeconds, required this.mcpConfigPath, }); @@ -27,14 +28,10 @@ class Config { return Config( telegramToken: e['TELEGRAM_BOT_TOKEN'] ?? '', allowedChatIds: allowed, - newsPrompt: e['NEWS_PROMPT'] ?? - 'Проведи ресёрч последних новостей и трендов в области искусственного ' - 'интеллекта за последние 7 дней. Используй поиск в интернете. ' - 'Составь краткую выжимку: 5-8 пунктов, у каждого — суть в 1-2 ' - 'предложениях и ссылка на источник.', + defaultTopic: e['DEFAULT_TOPIC'] ?? Lexicon.defaultTopic, claudeTimeoutSeconds: - int.tryParse(e['CLAUDE_TIMEOUT_SECONDS'] ?? '360') ?? 360, - mcpConfigPath: e['MCP_CONFIG_PATH'] ?? '/home/agent/claude-config/mcp.json', + int.tryParse(e['CLAUDE_TIMEOUT_SECONDS'] ?? '600') ?? 600, + mcpConfigPath: e['MCP_CONFIG_PATH'] ?? '/app/claude-config/mcp.json', ); } } diff --git a/bot/lib/lexicon.dart b/bot/lib/lexicon.dart new file mode 100644 index 0000000..70e3c25 --- /dev/null +++ b/bot/lib/lexicon.dart @@ -0,0 +1,40 @@ +// Все тексты, которые бот показывает пользователю или подставляет в промпт +// Claude — в одном месте, чтобы менять формулировки не по всему коду. +class Lexicon { + static const welcome = + 'Привет! Я agent-news. Команда /news <тема> — сделаю ресёрч и пришлю ' + 'выжимку с источниками. Без темы — возьму главные новости дня. ' + 'Ресёрч ведёт Claude Code, обычно несколько минут — по ходу буду ' + 'присылать статусы, что происходит.'; + + static const alreadyBusy = 'Уже выполняю другой запрос, подожди.'; + + static String starting(String topic) => + '🔎 Начинаю ресёрч по теме «$topic». Пришлю статусы по ходу и выжимку в конце.'; + + static const timedOut = 'Не успел уложиться по времени, попробуй позже.'; + + static const failed = 'Не получилось выполнить ресёрч, попробуй позже.'; + + // Тема по умолчанию для /news без аргумента — просто «главные новости», + // без привязки к конкретной сфере: тема запроса задаётся пользователем. + static const defaultTopic = 'главные новости дня'; + + static String researchInstruction(String topic) => + 'Проведи ресёрч по теме "$topic" за последние 7 дней. Используй поиск ' + 'в интернете. Составь выжимку: 5-8 пунктов, у каждого — суть в 1-2 ' + 'предложениях и ссылка на источник.'; + + static String progressInstruction(int chatId) => + 'По ходу работы присылай короткие статусы через инструмент ' + 'send_to_telegram (chat_id=$chatId, text="⏳ <что делаешь сейчас>") — ' + 'например сразу после того как определишь план поиска, и ещё раз ' + 'после того как соберёшь основные источники. Это нужно, чтобы ' + 'пользователь видел, что процесс идёт, а не просто ждал молча.'; + + static String finalDeliveryInstruction(int chatId) => + 'В конце ОБЯЗАТЕЛЬНО вызови send_to_telegram ещё раз с параметрами ' + 'chat_id=$chatId и text=<готовая выжимка в Markdown, с заголовком>. Это ' + 'единственный способ доставить результат — не выводи финальный ответ ' + 'никак иначе, только через вызов этого инструмента.'; +} diff --git a/docker-compose.yml b/docker-compose.yml index 6ab679e..21481cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: runner/Dockerfile env_file: .env volumes: - - claude-home:/home/agent/.claude + - claude-home:/home/agent restart: unless-stopped volumes: diff --git a/mcp_server/bin/agent_news_mcp.dart b/mcp_server/bin/agent_news_mcp.dart index 50080db..b905a08 100644 --- a/mcp_server/bin/agent_news_mcp.dart +++ b/mcp_server/bin/agent_news_mcp.dart @@ -53,9 +53,10 @@ void main() { server.registerTool( 'send_to_telegram', description: - 'Отправляет готовый текст (Markdown) в указанный Telegram-чат. ' - 'Единственный способ доставить результат ресёрча пользователю — ' - 'вызывай этот тул с финальным оформленным текстом.', + 'Отправляет текст (Markdown) в указанный Telegram-чат. Единственный ' + 'способ доставить результат пользователю. Можно и нужно вызывать ' + 'несколько раз за сессию: сначала короткими статусами по ходу работы ' + '("⏳ ищу источники…"), в конце — с финальным оформленным текстом.', inputSchema: JsonSchema.object( properties: { 'chat_id': JsonSchema.integer(description: 'ID чата Telegram, куда отправить сообщение'), diff --git a/runner/Dockerfile b/runner/Dockerfile index 5ed88a5..39c53e9 100644 --- a/runner/Dockerfile +++ b/runner/Dockerfile @@ -33,10 +33,13 @@ 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 +# claude-config живёт ВНЕ /home/agent: логин claude-code пишет не только в +# ~/.claude/, но и в файл ~/.claude.json — весь $HOME монтируется как volume +# (см. docker-compose.yml), и если положить конфиг внутрь home, он потеряется +# под пустым volume при первом старте. +COPY --chown=agent:agent runner/claude-config /app/claude-config USER agent WORKDIR /home/agent -ENV MCP_CONFIG_PATH=/home/agent/claude-config/mcp.json +ENV MCP_CONFIG_PATH=/app/claude-config/mcp.json -# ~/.claude (OAuth-логин claude-code) монтируется сюда через PVC — см. deploy/agent-news. CMD ["agent-news-bot"]