Claude Code в Telegram голосом: свой мост, когда официальный канал выключен

от автора

Меня зовут Сол ГудКод. Обычно я помогаю людям выпутываться из ситуаций, в которые они сами себя загнали: кто-то подписал не тот договор, кто-то удалил не ту ветку. Но параллельно надо было успевать работать с Claude Code, а он, зараза, живёт в терминале на моём столе.

Моя личная ситуация была такая. Задачи приходят в голову не за столом, а в дороге, в очереди, в лифте. К компьютеру я сажусь через четыре часа, когда мысль уже выветрилась, а формулировка усохла до «надо там что-то поправить».

Мне нужен был бот, которому можно наговорить задачу голосом, а он бы её сделал. С моими файлами, в моих проектах, на моей машине.

Дальше вышло длиннее, чем я рассчитывал.

Хорошая новость: у Anthropic это есть официально

Первое, что я выяснил: изобретать ничего не надо. У Anthropic есть официальный Telegram-плагин в рамках фичи Channels. Сообщения из чата пушатся прямо в живую сессию Claude Code.

Более того, плагин умеет больше, чем написано в его README. Я полез читать server.ts и нашёл там обработчики message:voice и message:audio, объявленную возможность claude/channel/permission и инлайн-кнопки подтверждения прав. В README про это ни слова. Мораль: читайте исходники, а не описания. README устаревает первым.

Установка заняла минут пятнадцать. BotFather, токен, /plugin install telegram@claude-plugins-official, перезапуск с флагом --channels, паринг.

Написал боту.

Тишина.

Подсудимый выглядит виновным

Дальше начался самый неприятный сорт отладки: тот, где всё исправно, а результата нет.

Проверил процессы. Сессия Claude Code запущена, флаг --channels на месте. Поллера плагина нет. Ладно, думаю, значит MCP-сервер не поднялся. Полез разбираться, почему.

claude mcp listplugin:telegram:telegram: bun run --cwd ... start - √ Connected

Connected. Сервер подключается нормально.

Тогда я попросил Claude в headless-режиме позвать инструмент reply у этого сервера напрямую. Через несколько секунд мне в Telegram пришло сообщение «проверка связи из Claude Code». То есть исходящий путь полностью рабочий: бот может говорить.

Проверил, доходят ли до бота мои сообщения. Дёрнул getUpdates руками:

ok=True, накопленных апдейтов: 4  chat_id=1037166722  тип=private     text=/start  chat_id=1037166722  тип=private     text=asdasd  chat_id=-100...     тип=supergroup  text=Привет

Вот они, все мои сообщения, лежат в очереди. Бот их получил. Некому было забрать.

Сложите картину. MCP-сервер подключён, инструменты работают, порт слушается, сообщения до бота доходят, в логах чисто. И при этом события канала в сессию не попадают. Ни ошибки, ни предупреждения — ничего.

Я потратил на это часа полтора, прежде чем понял, что ищу не там.

Как проверить за минуту то, на что я убил вечер

Ключ оказался в документации Channels, в разделе про политики организации. Там прямым текстом: если каналы выключены, «MCP-сервер всё равно подключится и его инструменты будут работать, но сообщения канала не придут».

Это ровно мой отпечаток. Один в один.

Чтобы убедиться, я поставил официальный демо-канал fakechat. Он поднимает веб-интерфейс на localhost, никакого Telegram, никаких токенов. Запустил сессию с --channels plugin:fakechat@..., отправил на 127.0.0.1:8787 задачу «создай файл вот тут».

Порт слушался. Запрос принят, 204. Восемь процессов bun живы.

Файл не появился.

Если у вас похожая тишина, начните с этого теста. Он занимает минуту и сразу отвечает на вопрос «дело в моём боте или в чём-то системном».

Приговор: тумблер, которого у меня нет

Причина была на стартовом баннере, где я её сто раз видел и ни разу не прочитал:

Opus 5 with high effort · Claude Team · Digitalbasistax

Claude Team. А Channels — это research preview, и на планах Team и Enterprise они выключены по умолчанию, пока владелец организации не включит тумблер в админке. Локально это не обходится: channelsEnabled относится к managed-настройкам, пользователь их не переопределяет.

Админских прав у меня нет.

За двадцать лет практики я к такому привык: клиент невиновен, доказательства на его стороне, а дело всё равно не движется, потому что нужную бумагу подписывает человек, которого нет в городе. Технически всё в порядке. Практически ничего не работает.

Я честно рассмотрел законные пути. Попросить владельца организации: один тумблер, тридцать секунд, бесплатно. Личный аккаунт Pro или Max вне организации, где проверки политики просто не применяются. Ключ Anthropic Console, потому что при аутентификации по API-ключу каналы разрешены по умолчанию, только платить будешь по токенам вместо места в подписке.

И на этом я почти закрыл вопрос. А потом сообразил, что рассуждаю как человек, который перепутал транспорт с возможностью.

Апелляция: а зачем мне вообще их канал

Сторонние решения для Claude Code в Telegram появились задолго до Channels. Они никакой фичи Anthropic не используют. Они опрашивают Bot API сами и запускают Claude Code headless-вызовами.

Тумблер организации гасит конкретный механизм доставки. Способность запускать claude -p он не гасит.

Дальше выяснилось, что в CLI есть всё, что нужно для полноценного чата:

claude -p "задача" --session-id <uuid> --output-format stream-json --verboseclaude -p "уточнение" --resume <uuid>

Проверил --resume отдельным тестом, потому что не верил: попросил запомнить слово, следующим вызовом спросил, какое. Вспомнил. Контекст переносится между вызовами, а значит чат ведёт себя как чат, а не как набор изолированных запросов.

И тут я поймал приятную мысль: у моста получается свойство, которого у официального канала нет. Channels доставляют события только в уже запущенную сессию, то есть окно терминала должно висеть открытым. Мосту это не нужно. Он поднимает сессию под каждое сообщение и продолжает её по id.

Схема вышла такая:

Telegram Bot API  ──long poll──▶  мост                                    │  гейт по ID отправителя                                    │  голос → Whisper → текст                                    │  файлы → inbox, пути в промпт                                    ▼                     claude -p --resume <session-id>                       --output-format stream-json                                    │  события инструментов → прогресс                                    ▼                     Telegram sendMessage / sendDocument

Три файла на TypeScript под Bun: клиент Bot API, запуск и разбор потока событий, основной цикл. Плюс скрипт расшифровки на Python.

Улики, которые стоили мне вечера

Вот эта часть, по-моему, ценнее самого моста. Код вы напишете свой, а грабли тут общие.

Токен, который потерялся молча

Записал токен в .env командой Set-Content. Плагин ответил, что токена нет.

Файл на месте, токен в нём, глазами видно. Воспроизвёл логику парсинга построчно и увидел: регулярка ^(\w+)=(.*)$ применяется после split('\n'). Set-Content в Windows пишет CRLF, поэтому в конце строки остаётся \r. А в JavaScript точка не матчит \r, и $ перед ним не встаёт.

Строка не матчится. Токен теряется. И всё это внутри пустого catch {}, так что ошибки вы не увидите.

Пишите .env только с LF.

Кириллица в PowerShell 5.1

PowerShell 5.1 читает .ps1 в системной ANSI-кодировке, если в файле нет BOM. Кириллица в UTF-8 без BOM превращается в мусор, и скрипт падает не на логике, а на парсинге: «The string is missing the terminator» на строке с русским комментарием.

Отдельная подлость в том, что я наступил на это дважды. Второй раз внутри скрипта, у которого вывод был подавлен. Выглядело как «функция отмены просто не работает». Никаких ошибок, ничего в логе, отмена не отменяет.

Теперь у меня рефлекс: если у скрипта подавлен вывод и он «ничего не делает», первым делом смотрю первые три байта файла. Должно быть 239,187,191.

Промпт, обрезанный по первому пробелу

Мой любимый. Мост запускал Claude через spawn с shell: true, потому что claude в PATH это claude.ps1, а его без шелла не запустить.

С shell: true Node склеивает аргументы через пробел и отдаёт строку в cmd.exe без квотинга. Промпт разваливается по первому же пробелу.

Я отправил голосовое «Скажи, у тебя IP-адрес RoseVPN есть или нет?». Расшифровка сработала идеально. Ответ пришёл такой:

Слушаю — что сказать? Договори мысль, а то приходят обрывки.

Он получил слово «Скажи,» и вежливо попросил закончить фразу. Полторы минуты я тупо смотрел в этот ответ, прежде чем понял, что он абсолютно прав.

Лечится запуском claude.exe напрямую с shell: false, а путь к exe приходится резолвить самому. Та же беда, кстати, у Start-Process -ArgumentList с массивом: элементы не квотируются. На эти же грабли я успел наступить раньше, в PowerShell, и всё равно повторил в своём же коде.

Whisper, который не знает ваших продуктов

Расшифровка русского на локальном faster-whisper работает прилично. Голосовуха на 2,4 секунды обрабатывается за 4,3 секунды на RTX 4060, из которых большая часть уходит на загрузку модели с диска. Первый прогон занял 181 секунду, но там качалась сама модель на 1,6 ГБ.

Проблема в латинице внутри русской речи. «RoseVPN» превратился в «розовый пн». Не бессмыслица, а вполне правдоподобное словосочетание, которое ломает смысл фразы.

Лечится параметром initial_prompt: туда кладётся короткий список ваших продуктов и технологий, и декодирование смещается в их сторону. После этого получилось «IP-адрес RoseVPN». Список держите коротким, иначе модель начнёт вставлять эти слова там, где их не было.

Кодировка stdout у Python

Транскрипт приехал в чат вот таким:

услышал: ?? ??? ?????? ? ????? ?????? ??????

Python под Windows кодирует вывод в pipe локальной кодовой страницей, а не в UTF-8. Кириллица не пролезает.

Что меня тут подловило: мой собственный тест этого не показал. Я запускал скрипт из PowerShell, тот декодировал вывод кодовой страницей консоли, и всё выглядело нормально. А через pipe в Bun путь другой. Проверять надо ровно тем способом, которым это будет работать в бою, а не тем, которым удобно.

PYTHONUTF8=1 при запуске плюс reconfigure(encoding='utf-8') в скрипте.

CUDA под uv и ленивый генератор

Два бага, слипшихся в один симптом.

Первый: RuntimeError: Library cublas64_12.dll is not found. Мой скрипт искал CUDA-библиотеки через site.getsitepackages(), а тот под uv run возвращает эфемерный каталог сборки. Сами колёса nvidia-* лежат в контент-кеше uv и подключаются через sys.path. Сканировать надо sys.path и nvidia.__path__.

Второй интереснее. У меня был честный фолбэк на CPU, обёрнутый вокруг конструктора модели. Он не сработал ни разу. Причина в том, что model.transcribe() в faster-whisper возвращает ленивый генератор: вычисления начинаются при обходе сегментов, то есть уже после конструктора. Ошибка загрузки CUDA вылетала мимо всего моего try.

Фолбэк обязан оборачивать и обход генератора. Иначе это декорация.

Демон, умирающий вместе с ярлыком

Мост запускался через Start-Process -NoNewWindow, то есть в консоли родителя. Окно ярлыка закрывалось через шесть секунд, и мост получал сигнал закрытия вместе с ним. В логе три строки о успешном старте и тишина.

Для фонового процесса нужен -WindowStyle Hidden: своя консоль отвязывает его от родителя.

Отмена, которая оставляла сирот

Отмена задачи из чата сначала работала через taskkill /PID x /T /F. Замерил: из процессов дерева умирает один, двое остаются.

Причина в том, что к моменту отмены claude уже развернул рабочие процессы. Когда промежуточный родитель исчезает, они переподвешиваются к другому и выпадают из дерева. Обход по ParentProcessId их тоже не находит.

Пришлось убивать по маркеру: искать процессы, в чьей командной строке есть id сессии, и добирать их потомков. После этого стало killed 10, осталось ноль.

И маленькая история про измерения. Первая версия теста показывала двух выживших даже после починки. Оказалось, что считающий PowerShell получал маркер прямо в свою командную строку и попадал под собственный фильтр. Механизм был исправен; врал измеритель.

Что в итоге получилось

Голосовое из дороги превращается в задачу. Мост скачивает .oga, гоняет через Whisper, отбивает в чат «услышал: …» для проверки, и только потом работает. Эхо тут не украшение: один раз я убедился, что распознано не то, ещё до того как что-то поехало не туда.

Отмена сделана кнопкой под сообщением о прогрессе, и это оказалась самая нужная мелочь. Аналог Esc в CLI, но с одним важным свойством: контекст сохраняется. Сессия та же, следующее сообщение продолжает диалог. Проверял отдельным тестом: попросил запомнить кодовое слово, отменил задачу на десятом шаге, следующим сообщением спросил слово. Вспомнил.

Ради этого пришлось поменять момент сохранения сессии. Изначально id писался по событию done, и отмена первой задачи в чате обнуляла контекст: сессию просто не успевали записать. Теперь id забирается из первого же события.

Файлы приходят документами, а не текстом с путями. Разница принципиальная, когда результат работы надо переслать заказчику: путь C:\...\INSTRUCTION.md в мессенджере бесполезен, а документ форвардится в два тапа. Claude заканчивает ответ блоком с путями, мост его вырезает и отправляет файлы.

Конвенция намеренно явная, а не «выдерни пути из текста регуляркой». Пути в ответах упоминаются постоянно, и угадывание превратилось бы в рассылку случайных файлов.

Прогресс виден отдельным сообщением, которое правится по ходу: «работаю · шагов 7 · Bash: сборка». Финал приходит новым сообщением, потому что только новое даёт пуш на телефон. Правка старого не даёт.

Модель и глубину раздумий можно переключать из чата: /model, /effort, /new, /status. Мелочь, но именно её мне не хватало в первый же день.

Чего оно не умеет и где я схитрил

Только Windows. Логика моста кросс-платформенная, а вот отмена задач и лончеры написаны на PowerShell.

Одна задача на чат за раз, потому что --resume одной сессии двумя процессами недопустим.

Компьютер должен быть включён. Сообщения при этом не теряются: Telegram держит очередь и отдаёт накопленное после перезапуска, что меня один раз выручило.

И главное, о чём надо сказать прямо. Права по умолчанию стоят в bypassPermissions, то есть Claude работает автономно и ничего не спрашивает. Это не лень, а следствие конструкции: подтверждать запросы разрешений из Telegram негде, и задача просто встанет насмерть в ожидании ответа, которого никто не даст. Официальный плагин, кстати, это умеет: у него есть relay с инлайн-кнопками. Ещё один повод предпочесть его, если Channels вам доступны.

Отсюда честное предупреждение. Кто попал в allowlist, тот получает автономное выполнение кода на вашей машине с доступом к вашим файлам и ключам. Держите там только себя. В групповых чатах обязательно ограничивайте список участников, иначе задачи сможет ставить любой, кто в чате.

Меня до сих пор немного смущает эта развилка. Я выбрал автономность сознательно, потому что иначе вся затея не имеет смысла, но не могу сказать, что мне полностью нравится такой выбор.

Код

Всё выложено под MIT: github.com/by-sonic/claude-code-telegram-ru

Там мост, лончеры, транскрибер и шаблоны конфигов. Если Channels у вас работают, берите официальный плагин, он лучше. Этот мост нужен ровно тем, у кого тумблер выключен и включить его некому.

Отдельно скажу про способ работы, потому что вопрос всё равно возникнет. Проект я собирал вместе с Claude Code, и эту статью тоже писал с ним. Мне это кажется уместным: статья про инструмент, собранный этим же инструментом. Все цифры, ошибки и цитаты в тексте настоящие, из реальной отладки, включая тот вежливый ответ про обрывки мысли.

Если у вас похожая тишина от бота при живом MCP-сервере, начните с теста через fakechat. Сэкономите себе вечер, а мне будет приятно, что не зря его потратил.

ссылка на оригинал статьи https://habr.com/ru/articles/1063578/