Fix HOME volume mount, genericize topic, add progress statuses, extract lexicon

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:53:38 +03:00
parent cb2041fc56
commit 7bfe0dfe0c
8 changed files with 93 additions and 52 deletions

View File

@@ -8,11 +8,15 @@ Telegram-бот на Dart: по команде `/news` (или `/news <тема>
Один контейнер, три процесса: Один контейнер, три процесса:
1. `agent-news-bot` (Dart, long polling) — слушает `/news`, спавнит `claude` как сабпроцесс. 1. `agent-news-bot` (Dart, long polling) — слушает `/news <тема>`, спавнит `claude` как сабпроцесс.
2. `claude` (Claude Code CLI) — делает ресёрч, в конце вызывает MCP-тул `send_to_telegram`. 2. `claude` (Claude Code CLI) — делает ресёрч; по ходу и в конце сам вызывает MCP-тул
`send_to_telegram` (промежуточные статусы + финальная выжимка).
3. `agent-news-mcp` (Dart, stdio MCP-сервер) — спавнится самим `claude`, шлёт готовый 3. `agent-news-mcp` (Dart, stdio MCP-сервер) — спавнится самим `claude`, шлёт готовый
текст в Telegram Bot API. текст в Telegram Bot API.
Все тексты, которые бот показывает пользователю или подставляет в промпт Claude, лежат
в одном месте — `bot/lib/lexicon.dart`.
## Запуск локально ## Запуск локально
```bash ```bash
@@ -31,16 +35,20 @@ Claude Code CLI использует OAuth-логин через Claude.ai (по
после первого запуска нужно зайти в контейнер и залогиниться: после первого запуска нужно зайти в контейнер и залогиниться:
```bash ```bash
docker compose exec agent-news claude docker compose exec agent-news claude auth login
``` ```
CLI напечатает ссылку — открой её в браузере, авторизуйся. Токен сохранится в CLI напечатает ссылку — открой её в браузере, авторизуйся, вставь код обратно в
`~/.claude` на volume, так что после `docker compose restart` логин не слетит. терминал, если попросит. Учти: и `~/.claude/`, и `~/.claude.json`оба нужны для
логина, поэтому в volume смонтирован **весь `/home/agent`** (см. `docker-compose.yml`),
не только `~/.claude/`. После `docker compose restart` логин не слетит.
### Проверка ### Проверка
Напиши боту `/news` (или `/news <тема>`) из чата с id из `ALLOWED_CHAT_IDS` — бот Напиши боту `/news <тема>` (или просто `/news` — возьмёт `DEFAULT_TOPIC`) из чата с id
ответит "ищу…", а через 1-3 минуты Claude сам пришлёт выжимку через `send_to_telegram`. из `ALLOWED_CHAT_IDS`. Бот сразу ответит "начинаю…", дальше Claude сам присылает статусы
по ходу ресёрча и в конце — готовую выжимку, всё через `send_to_telegram`. Ресёрч
занимает несколько минут — это нормально, статусы как раз для этого.
```bash ```bash
docker compose logs -f agent-news docker compose logs -f agent-news
@@ -59,6 +67,6 @@ cd ../mcp_server && dart pub get && dart analyze
|---|---|---| |---|---|---|
| `TELEGRAM_BOT_TOKEN` | да | токен бота, используется и ботом, и MCP-сервером | | `TELEGRAM_BOT_TOKEN` | да | токен бота, используется и ботом, и MCP-сервером |
| `ALLOWED_CHAT_IDS` | да | список chat_id через запятую — кому отвечает бот | | `ALLOWED_CHAT_IDS` | да | список chat_id через запятую — кому отвечает бот |
| `NEWS_PROMPT` | нет | промпт по умолчанию для `/news` без темы | | `DEFAULT_TOPIC` | нет | тема для `/news` без аргумента (по умолчанию — «главные новости дня») |
| `CLAUDE_TIMEOUT_SECONDS` | нет | таймаут на ресёрч (по умолчанию 360) | | `CLAUDE_TIMEOUT_SECONDS` | нет | таймаут на ресёрч (по умолчанию 600) |
| `MCP_CONFIG_PATH` | нет | путь к `mcp.json` (по умолчанию `/home/agent/claude-config/mcp.json`) | | `MCP_CONFIG_PATH` | нет | путь к `mcp.json` (по умолчанию `/app/claude-config/mcp.json`) |

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import '../lib/config.dart'; import '../lib/config.dart';
import '../lib/telegram.dart'; import '../lib/telegram.dart';
import '../lib/claude_runner.dart'; import '../lib/claude_runner.dart';
import '../lib/lexicon.dart';
void main() async { void main() async {
final config = Config.fromEnv(); final config = Config.fromEnv();
@@ -41,12 +42,10 @@ void main() async {
continue; continue;
} }
stderr.writeln('[$chatId] $text');
if (text == '/start') { if (text == '/start') {
await telegram.sendMessage( await telegram.sendMessage(chatId, Lexicon.welcome);
chatId,
'Привет! Я agent-news. Команда /news — свежая выжимка по ИИ '
'(ресёрч делает Claude Code, займёт 1-3 минуты).',
);
continue; continue;
} }
@@ -54,17 +53,13 @@ void main() async {
final topic = text.startsWith('/news ') ? text.substring(6).trim() : null; final topic = text.startsWith('/news ') ? text.substring(6).trim() : null;
if (claude.isBusy) { if (claude.isBusy) {
await telegram.sendMessage(chatId, 'Уже выполняю другой запрос, подожди.', await telegram.sendMessage(chatId, Lexicon.alreadyBusy, replyToMessageId: messageId);
replyToMessageId: messageId);
continue; continue;
} }
await telegram.sendMessage( final effectiveTopic = (topic == null || topic.isEmpty) ? config.defaultTopic : topic;
chatId, await telegram.sendMessage(chatId, Lexicon.starting(effectiveTopic),
'🔎 Ищу свежую информацию${topic != null ? ' по теме «$topic»' : ' по ИИ'}, ' replyToMessageId: messageId);
'это займёт пару минут…',
replyToMessageId: messageId,
);
await telegram.sendTyping(chatId); await telegram.sendTyping(chatId);
unawaited(_runNews(claude, telegram, chatId, topic)); unawaited(_runNews(claude, telegram, chatId, topic));
@@ -88,11 +83,12 @@ Future<void> _runNews(
case ClaudeOutcome.busy: case ClaudeOutcome.busy:
break; // уже сообщили выше break; // уже сообщили выше
case ClaudeOutcome.timeout: case ClaudeOutcome.timeout:
await telegram.sendMessage(chatId, 'Не успел уложиться по времени, попробуй позже.'); stderr.writeln('news for $chatId timed out');
await telegram.sendMessage(chatId, Lexicon.timedOut);
break; break;
case ClaudeOutcome.failure: case ClaudeOutcome.failure:
stderr.writeln('news for $chatId failed: ${result.detail}'); stderr.writeln('news for $chatId failed: ${result.detail}');
await telegram.sendMessage(chatId, 'Не получилось выполнить ресёрч, попробуй позже.'); await telegram.sendMessage(chatId, Lexicon.failed);
break; break;
} }
} }

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'config.dart'; import 'config.dart';
import 'lexicon.dart';
enum ClaudeOutcome { success, busy, timeout, failure } enum ClaudeOutcome { success, busy, timeout, failure }
@@ -73,16 +74,11 @@ class ClaudeRunner {
} }
String _buildPrompt({required int chatId, String? topic}) { String _buildPrompt({required int chatId, String? topic}) {
final base = topic == null || topic.isEmpty final effectiveTopic = (topic == null || topic.isEmpty) ? config.defaultTopic : topic;
? config.newsPrompt return [
: 'Проведи ресёрч последних новостей и трендов по теме "$topic" за последние ' Lexicon.researchInstruction(effectiveTopic),
'7 дней. Используй поиск в интернете. Составь краткую выжимку: 5-8 пунктов, ' Lexicon.progressInstruction(chatId),
'у каждого — суть в 1-2 предложениях и ссылка на источник.'; Lexicon.finalDeliveryInstruction(chatId),
].join('\n\n');
return '$base\n\n'
'Когда выжимка готова, ОБЯЗАТЕЛЬНО вызови инструмент send_to_telegram с '
'параметрами chat_id=$chatId и text=<готовый текст в Markdown, с заголовком>. '
'Это единственный способ доставить материал — не выводи финальный ответ никак '
'иначе, только через вызов этого инструмента.';
} }
} }

View File

@@ -1,16 +1,17 @@
import 'dart:io'; import 'dart:io';
import 'lexicon.dart';
class Config { class Config {
final String telegramToken; final String telegramToken;
final Set<int> allowedChatIds; final Set<int> allowedChatIds;
final String newsPrompt; final String defaultTopic;
final int claudeTimeoutSeconds; final int claudeTimeoutSeconds;
final String mcpConfigPath; final String mcpConfigPath;
Config({ Config({
required this.telegramToken, required this.telegramToken,
required this.allowedChatIds, required this.allowedChatIds,
required this.newsPrompt, required this.defaultTopic,
required this.claudeTimeoutSeconds, required this.claudeTimeoutSeconds,
required this.mcpConfigPath, required this.mcpConfigPath,
}); });
@@ -27,14 +28,10 @@ class Config {
return Config( return Config(
telegramToken: e['TELEGRAM_BOT_TOKEN'] ?? '', telegramToken: e['TELEGRAM_BOT_TOKEN'] ?? '',
allowedChatIds: allowed, allowedChatIds: allowed,
newsPrompt: e['NEWS_PROMPT'] ?? defaultTopic: e['DEFAULT_TOPIC'] ?? Lexicon.defaultTopic,
'Проведи ресёрч последних новостей и трендов в области искусственного '
'интеллекта за последние 7 дней. Используй поиск в интернете. '
'Составь краткую выжимку: 5-8 пунктов, у каждого — суть в 1-2 '
'предложениях и ссылка на источник.',
claudeTimeoutSeconds: claudeTimeoutSeconds:
int.tryParse(e['CLAUDE_TIMEOUT_SECONDS'] ?? '360') ?? 360, int.tryParse(e['CLAUDE_TIMEOUT_SECONDS'] ?? '600') ?? 600,
mcpConfigPath: e['MCP_CONFIG_PATH'] ?? '/home/agent/claude-config/mcp.json', mcpConfigPath: e['MCP_CONFIG_PATH'] ?? '/app/claude-config/mcp.json',
); );
} }
} }

40
bot/lib/lexicon.dart Normal file
View File

@@ -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, с заголовком>. Это '
'единственный способ доставить результат — не выводи финальный ответ '
'никак иначе, только через вызов этого инструмента.';
}

View File

@@ -5,7 +5,7 @@ services:
dockerfile: runner/Dockerfile dockerfile: runner/Dockerfile
env_file: .env env_file: .env
volumes: volumes:
- claude-home:/home/agent/.claude - claude-home:/home/agent
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -53,9 +53,10 @@ void main() {
server.registerTool( server.registerTool(
'send_to_telegram', 'send_to_telegram',
description: description:
'Отправляет готовый текст (Markdown) в указанный Telegram-чат. ' 'Отправляет текст (Markdown) в указанный Telegram-чат. Единственный '
'Единственный способ доставить результат ресёрча пользователю ' 'способ доставить результат пользователю. Можно и нужно вызывать '
'вызывай этот тул с финальным оформленным текстом.', 'несколько раз за сессию: сначала короткими статусами по ходу работы '
'("⏳ ищу источники…"), в конце — с финальным оформленным текстом.',
inputSchema: JsonSchema.object( inputSchema: JsonSchema.object(
properties: { properties: {
'chat_id': JsonSchema.integer(description: 'ID чата Telegram, куда отправить сообщение'), 'chat_id': JsonSchema.integer(description: 'ID чата Telegram, куда отправить сообщение'),

View File

@@ -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 chmod +x /usr/local/bin/agent-news-bot /usr/local/bin/agent-news-mcp
RUN useradd -m -u 10001 agent 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 USER agent
WORKDIR /home/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"] CMD ["agent-news-bot"]