1 марта 2026 Telegram добавил в Bot API метод sendMessageDraft - возможность потокового вывода сообщений. Тот самый эффект, к которому все привыкли в ChatGPT и Claude. Текст появляется по частям, в конце бегают анимированные точки, и ты видишь, что ответ ещё генерируется.
Я написал простенький рабочий пример на чистом Python - без каких-либо фреймворков. Только asyncio и urllib.
Как это работает
Принцип простой:
Бот создаёт черновик сообщения через
sendMessageDraftПо мере получения чанков обновляет этот черновик
Пока идёт стрим - Telegram показывает анимированные точки в конце текста
Когда стрим завершён - бот отправляет финальное сообщение через обычный
sendMessage
Черновик закрепляется в чате и обновляется в реальном времени. Пользователь видит, как текст печатается.

sendMessageDraft
await api.arequest( "sendMessageDraft", {"chat_id": chat_id, "draft_id": draft_id, "text": accumulated_text}, )
Параметры:
chat_id- куда отправляемdraft_id- уникальный идентификатор черновика (int). Все обновления одного стрима должны идти с однимdraft_idtext- текущий накопленный текст
draft_id генерируется один раз на стрим. Я использал timestamp в миллисекундах с обрезкой до int32:
draft_id = int(time.time() * 1000) % 2147483647
Управление частотой обновлений
Отправлять sendMessageDraft на каждый чанк - плохая идея. Telegram зарейтлимитит. Поэтому обновляем черновик не чаще, чем раз в N миллисекунд:
now = time.monotonic() if now - last_draft_ts >= config.draft_interval: await api.arequest("sendMessageDraft", {...}) last_draft_ts = now
Значения DRAFT_INTERVAL_MS, CHUNK_SIZE, CHUNK_DELAY_MS в коде - подобраны на глаз. Подкрутите под свои условия.
Полный код
Один файл. Никаких зависимостей кроме стандартной библиотеки Python.
import asyncio import json import time from dataclasses import dataclass from typing import AsyncIterator, Callable from urllib import error, request DEFAULT_BASE_URL = "https://api.telegram.org" TOKEN = "" # ВАШ ТОКЕН БОТА DRAFT_INTERVAL_MS = 80 CHUNK_SIZE = 6 CHUNK_DELAY_MS = 30 @dataclass class AppConfig: token: str request_timeout: int = 35 poll_timeout: int = 30 poll_interval: float = 0.2 draft_interval: float = 0.08 stream_chunk_size: int = 6 stream_chunk_delay: float = 0.03 class TelegramApi: def __init__(self, config: AppConfig): self.config = config self.base_url = ( f"{DEFAULT_BASE_URL}/bot{config.token}" if config.token else "" ) def request(self, method: str, payload: dict): url = f"{self.base_url}/{method}" data = json.dumps(payload).encode("utf-8") req = request.Request( url=url, data=data, headers={"Content-Type": "application/json"}, method="POST", ) with request.urlopen(req, timeout=self.config.request_timeout) as response: raw_body = response.read().decode("utf-8") body = json.loads(raw_body) if not body.get("ok"): print(f"[telegram] api error in {method}: {body.get('description')}") return None return body.get("result") async def arequest(self, method: str, payload: dict): return await asyncio.to_thread(self.request, method, payload) StreamFactory = Callable[[str, AppConfig], AsyncIterator[str]] #---------ЗАГЛУШКА----------- async def fake_llm_stream( prompt: str, config: AppConfig ) -> AsyncIterator[str]: """Демо-стрим. Замените на реальный SDK вашей LLM.""" text = ( f"Запрос: {prompt}\n\n" "Это демонстрация стримингового ответа. " "Текст появляется частями, draft в Telegram " "обновляется на лету. После завершения " "отправляется финальное сообщение." ) for i in range(0, len(text), config.stream_chunk_size): yield text[i : i + config.stream_chunk_size] await asyncio.sleep(config.stream_chunk_delay) #---------ЗАГЛУШКА----------- async def stream_with_draft( api: TelegramApi, chat_id: int, prompt: str, config: AppConfig, stream_factory: StreamFactory = fake_llm_stream, ) -> str: draft_id = int(time.time() * 1000) % 2147483647 full_text = "" last_draft_ts = 0.0 async for chunk in stream_factory(prompt, config): if not chunk: continue full_text += chunk now = time.monotonic() if now - last_draft_ts >= config.draft_interval: await api.arequest( "sendMessageDraft", { "chat_id": chat_id, "draft_id": draft_id, "text": full_text, }, ) last_draft_ts = now if full_text: await api.arequest( "sendMessageDraft", {"chat_id": chat_id, "draft_id": draft_id, "text": full_text}, ) await api.arequest( "sendMessage", {"chat_id": chat_id, "text": full_text}, ) return full_text async def poll_updates(api: TelegramApi, config: AppConfig): print("Polling started. Send a message to your bot.") offset = 0 active_tasks: set[asyncio.Task] = set() while True: updates = await api.arequest( "getUpdates", { "offset": offset, "timeout": config.poll_timeout, "allowed_updates": ["message"], }, ) if updates and isinstance(updates, list): for update in updates: offset = update["update_id"] + 1 msg = update.get("message") or {} text = msg.get("text") chat_id = (msg.get("chat") or {}).get("id") if not chat_id or not text: continue await api.arequest( "sendChatAction", {"chat_id": chat_id, "action": "typing"}, ) task = asyncio.create_task( stream_with_draft(api, chat_id, text, config) ) active_tasks.add(task) task.add_done_callback(active_tasks.discard) await asyncio.sleep(config.poll_interval) async def main(): config = AppConfig( token=TOKEN, draft_interval=max(DRAFT_INTERVAL_MS, 0) / 1000.0, stream_chunk_size=max(CHUNK_SIZE, 1), stream_chunk_delay=max(CHUNK_DELAY_MS, 0) / 1000.0, ) api = TelegramApi(config) await poll_updates(api, config) if __name__ == "__main__": asyncio.run(main())
Нюансы
parse_modeпараметр используйте только в финальной отправке сообщения, чтобы телега не ругалась на незакрытые тэги.В коде на LLM стрим стоит заглушка, ее не используйте.
draft_idдолжен быть уникальным для каждого стрима, но одинаковым для всех обновлений внутри одного стримаФинальный
sendMessageобязателен. Черновик - это черновик. Без финального сообщения текст не сохранится в истории чата, а просто пропадетАнимированные точки добавляет сам телеграмм, пока черновик обновляется. Ничего дополнительного делать не нужно. Но можно и закостылить какой-нибудь мигающий курсор, но нафиг надо, если за нас уже все сделали.

sendMessageDraft - классное простенько обновление API. С которым теперь можно хорошие интеграции с LLM делать, а также и в игрушках, например, применять. Думаю скоро много ботов внедрят это.
Поддержка
Если материал оказался полезным или просто зацепил — приглашаю в канал "На Дерево"
Комментарии (10)

CrazyElf
08.04.2026 11:36full_text += chunk- не самый лучший способ работы со строками. Хотя, если текст не очень большой, то ладно.
Techdir_hub Автор
08.04.2026 11:36Это правда, поэтому это не повторяйте за мной!) Там конечно можно спорить, при каких объемах это плохо, но не будем

CrazyElf
08.04.2026 11:36Там больше зависит от того, какого размера будут добавляемые куски. В не старых версиях CPython есть оптимизации, благодаря которым место под строку выделяется с запасом и при небольших размерах добавляемой строки и отсутствии дополнительных ссылок на эту строку, добавляемое записывается прямо на свободное резервное место, новая строка не создаётся. Но это детали реализации, на которые не всегда можно полагаться. )

Techdir_hub Автор
08.04.2026 11:36Верно, в новых версиях вроде добавляли инплэйс обновление. Но нафиг его, помним, что стринга имутабельна, и делаем через io)

CrazyElf
08.04.2026 11:36StringIO, либо банальныйlistс последующимstring.join(примерно так же фактически устроен StringBuilder в C#).

Triton5
08.04.2026 11:36Отличная статья! Сам недавно реализовывал стриминг для Telegram-бота на Cloudflare Workers, и возникли мысли по поводу подхода.
В статье используется
sendMessageDraft→ финальныйsendMessage. Но есть альтернативный подход — редактирование существующего сообщения черезeditMessageText:Бот создаёт "думаю..." сообщение через
sendMessageПо мере получения чанков от LLM — редактирует это же сообщение через
editMessageTextВ конце — финальное редактирование с полным текстом
Плюсы такого подхода:
Сообщение в истории чата остаётся ОДНИМ (а не черновик + новое сообщение)
Не нужно два API-вызова в конце
Работает и для длинных ответов с разбивкой на несколько частей
Минусы:
Нужно вручную показывать "печатает" через
sendChatAction('typing')Требуется HTML-форматирование (проблемы с незакрытыми тегами при стриминге)
Лимит Telegram: ~1 editMessageText в секунду
Для коротких сообщений (без стриминга) можно использовать Markdown напрямую (без HTML), а для длинных — HTML + балансировка тегов.
Интересно мнение автора: какой подход лучше для ботов с длинными ответами, которые нужно разбивать на части?
Посмотреть live-реализацию можно в боте @Vika_talk_Bot — там используется подход с editMessageText, стриминг + разбивка на части, Markdown для коротких сообщений (без стрима) и HTML для длинных.
CrazyElf
08.04.2026 11:36Минус не мой, но старый метод через редактирование обсуждался выше в комментариях, плюс похоже вы просто рекламируете своего бота. За это видимо и минус.

Triton5
08.04.2026 11:36А, то есть, раз уже выше писали, то мне это обсуждать нельзя, да?:)
А как насчёт ответа на вопрос? Технической дискуссии, так сказать? Вы же не вникали в написанное, я так вижу.
У нас есть длинный текст, например 10 тысяч символов от нейросети, его нужно передать в телеграм, а там ограничение в 4096 символа. Решение: режем на заведомо более мелкие куски (я режу по 3500).
Проблема: при нарезке мы часто портим входную разметку Markdown! (что, кстати вы в своих комментариях выше вообще никто не отобразил), и Телеграм возвращает ошибку 400. Можно делать анализ тегов кусков текста, проходя каждую часть валидатором, можно просто отдавать в html, попутно просто вырезав все теги перед разрезанием на части. Я сделал так: пытаемся отправить маркдаун, а html как запасной путь.
Но это в случае отправки разрезанными кусками, где у нас есть власть над текстом. В случае же стриминга предложенная схема замены черновика на замену текста отлично подходит для того, что влезает в 1 сообщение. А в случае замены стрима на 5 кусках на финальные обработанные части мы должны обновлять все 5 частей. А если мы их по-другому нарежем, ну, так получится? Или нам надо тоже сохранять все 5 частей в память и их все заменять? :) Думаю над этим:)
Понимаете, надо сразу думать про то, что нам потом придётся резать текст.
Автор просто не рассматривал длинные ответы.Проблема остаётся:
Черновик →
sendMessage(НОВОЕ сообщение)Если текст > 4096 — снова резать на куски
Но это уже новые сообщения, не черновик!
Подход через editMessageText — более универсальный, хоть и сложнее. Особенно для длинных ответов с разбивкой.
А насчёт "рекламы" бота, который ничего не продаёт - вы, верно, шутите, мистер CrazyElf.
Особенно учитывая по самые уши залепленный рекламой сайт habr.com.
Если у вас есть другой бот, где можно в натуре посмотреть метод стриминга через редактирование, можете его написать :)
K0Jlya9
Результат работы примера выглядит хуже чем старый способ с банальным редактированием сообщения раз в пару секунд.
Techdir_hub Автор
Ну тут пример показывает, как реализуется механизм лишь. Всякие гэпы я на рандом воткнул. Новый метод на самом деле очень хорош, теперь нативнее все выглядит, нежели простое редактирование.