Перейти к содержанию

agent-news-bot — Telegram-бот, который дёргает Claude Code как агента

Пилотный проект: Telegram-бот на Dart, который по команде /news <тема> не сам ищет информацию, а поручает ресёрч Claude Code, а результат в чат присылает уже сам Claude — через собственный MCP-инструмент. Код: platformteam-projects/agent-news.

Представь секретаря на ресепшене (бот) и исследователя за соседней дверью (Claude Code). Ты говоришь секретарю: «принеси свежие новости про X». Секретарь сам ничего не ищет — он стучится к исследователю и передаёт задание. Исследователь роется в интернете и сам отчитывается тебе факсом (это и есть MCP-инструмент send_to_telegram) — сначала пару раз коротко «ищу…», «нашёл источники…», а в конце — сам отчёт. Секретарь только передал задание, дальше не вмешивается.

sequenceDiagram
    actor U as Пользователь
    participant T as Telegram
    participant B as Бот-секретарь
    participant C as Claude Code (исследователь)

    U->>T: пишет /news <тема>
    Note over B,T: бот сам стучится в Telegram и забирает<br/>сообщение (long polling) — не наоборот
    B->>T: забирает сообщение
    B->>T: "Принял, начинаю…"
    T->>U: приходит в чат
    B->>C: запускает как процесс, даёт задание
    C->>C: ищет в интернете
    C->>T: статус "⏳ ищу источники…" (MCP-тул)
    T->>U: статус приходит в чат
    C->>C: готовит выжимку
    C->>T: финальный отчёт (тот же MCP-тул)
    T->>U: выжимка приходит в чат

Важная деталь: пользователь пишет в Telegram, а не напрямую боту — у бота нет и не может быть прямого канала к пользователю. Бот сам, постоянно, ходит в Telegram Bot API и спрашивает «есть что-то новое для меня?» (это и называется long polling, getUpdates), а не Telegram сам «стучится» к боту при получении сообщения.

Почему статусы по ходу важны? Потому что реальный ресёрч занимает не секунды, а несколько минут — без промежуточных сообщений человек решает, что бот завис, хотя он честно работает. Это не теория: именно так и получилось на первом же тесте.

Один Docker-контейнер, три процесса:

  1. agent-news-bot (Dart, long polling Telegram API) — слушает /news <тема>, спавнит claude как локальный сабпроцесс. Отдельного контейнера/пода для Claude Code нет: на связке «бот дёргает docker exec в соседний контейнер» пришлось бы либо давать боту права на pods/exec в K8s, либо монтировать docker.sock — то и другое лишняя дыра ради удобства. Сабпроцесс даёт тот же эффект (переиспользуемый persistent процесс) без этой сложности.
  2. claude (Claude Code CLI, запускается с -p "<промпт>" --mcp-config mcp.json --strict-mcp-config --dangerously-skip-permissions) — делает ресёрч через встроенные тулы (веб-поиск), по ходу и в конце вызывает MCP-тул send_to_telegram.
  3. agent-news-mcp (Dart, package:mcp_dart, транспорт stdio) — спавнится самим claude по mcp.json, реализует единственный тул send_to_telegram (chat_id, text) и шлёт сообщение в Telegram Bot API напрямую. Тул рассчитан на несколько вызовов за сессию — не только на финальный ответ.
flowchart LR
    U["Пользователь"]
    TG["Telegram Bot API"]
    subgraph Container["один Docker-контейнер"]
        Bot["agent-news-bot<br/>(Dart)"]
        Claude["claude CLI<br/>(сабпроцесс, -p)"]
        MCP["agent-news-mcp<br/>(stdio MCP-сервер)"]
    end

    U -->|"/news <тема>"| TG
    Bot -->|"getUpdates, long polling —<br/>бот САМ опрашивает Telegram"| TG
    Bot -->|"Process.start('claude', ...)"| Claude
    Claude -->|"stdio, tools/call<br/>send_to_telegram (×N: статусы + финал)"| MCP
    MCP -->|"sendMessage"| TG
    TG -->|"статусы + выжимка"| U

На диаграмме два разных направления к Telegram специально нарисованы отдельно: Bot → TG (опрос за новыми сообщениями, pull) и MCP → TG (отправка результата, push). Прямой связи «пользователь → бот» в принципе не существует — весь трафик в обе стороны идёт через Telegram Bot API.

Ключевые детали и грабли, на которые реально наступили при сборке:

  • Авторизация Claude Code — OAuth-логин через Claude.ai (подписка, не API-ключ). Разово: docker compose exec agent-news claude auth login, перейти по ссылке в браузере, вставить код обратно в терминал.
  • Грабли №1 — не тот путь монтирования. Первая версия монтировала volume только на ~/.claude/. Логин проходил, claude auth status был честный loggedIn: true — а после пересборки контейнера снова требовал логина. Причина: claude-code пишет учётные данные не только в ~/.claude/, но и отдельным файлом ~/.claude.json рядом с этой папкой — он оставался на эфемерном слое контейнера. Фикс — монтировать volume на весь $HOME, а статичный mcp.json держать вне home (/app/claude-config), чтобы пустой volume не затирал его при первом старте.
  • Грабли №2 — тишина по 3+ минуты. Даже тривиальный принудительный вызов тула (без всякого веб-поиска) занял у Claude Code 3 минуты 43 секунды сквозного времени. Реальный ресёрч — дольше. Без промежуточных статусов это неотличимо от зависания. Фикс — в промпте отдельным пунктом просим Claude присылать короткие send_to_telegram-статусы по ходу работы, а не только в конце.
  • Один запрос за разClaudeRunner в боте держит простой busy-флаг: пока идёт ресёрч, новый /news получает «уже выполняю, подожди».
  • --strict-mcp-config — Claude использует только MCP-сервер из явно переданного mcp.json, не подхватывает ничего лишнего из окружения.
  • Тема — не хардкод. /news без аргумента берёт DEFAULT_TOPIC (по умолчанию просто «главные новости дня»), а /news <тема> — любую тему без ограничений по сфере. Все формулировки, которые бот показывает пользователю или подставляет в промпт Claude, вынесены в один файл — bot/lib/lexicon.dart.
  • Формат ответа — вся содержательная часть уходит от Claude напрямую в Telegram через MCP-тул; сам claude -p в stdout бот не парсит и не пересылает — это осознанный выбор: форматирование (Markdown, разбивка по сообщениям, статусы) полностью на стороне модели.

Сейчас проект гоняется только локально (docker compose up -d --build), без деплоя в K3s — это ещё пилот.

Домашнее задание

Собери свою версию этой же схемы (бот → агент как процесс → MCP-тул → доставка результата) под другую задачу — не новости. Например: /review <ссылка на PR> (агент смотрит диф и комментирует), /summarize <ссылка> (агент пересказывает документ/статью), /standup (агент собирает статусы задач и оформляет саммари). Тема на твой выбор — важна архитектура, не сюжет.

Обязательный чек-лист (это реальные грабли из сегодняшней сборки, наступи на них осознанно, а не случайно):

  1. Бот на Dart (или любом языке) спавнит claude -p как сабпроцесс — не через docker exec в соседний контейнер.
  2. Свой MCP-сервер с минимум одним тулом доставки результата (send_to_telegram, send_to_slack — что подходит под твой канал).
  3. Volume для $HOME целиком, не только для ~/.claude/ — иначе логин слетит при пересборке (см. «Грабли №1» выше).
  4. Промежуточные статусы через тот же MCP-тул, а не только финальный ответ — иначе на реальном тайминге (минуты, не секунды) это выглядит как зависание.
  5. Все тексты бота — в отдельном lexicon-файле, не разбросаны по коду.

Готово — открой PR и приложи короткое демо (скриншот переписки в чате).

Связанные страницы

  • mcp.md — что такое MCP и зачем он вообще нужен
  • agents.md — агенты и тулы в целом
  • agent-ssh-deploy.md — ещё один пример «агент сам выполняет действие», но по SSH