1 марта 2026 Telegram добавил в Bot API метод sendMessageDraft - возможность потокового вывода сообщений. Тот самый эффект, к которому все привыкли в ChatGPT и Claude. Текст появляется по частям, в конце бегают анимированные точки, и ты видишь, что ответ ещё генерируется.

Я написал простенький рабочий пример на чистом Python - без каких-либо фреймворков. Только asyncio и urllib.


Как это работает

Принцип простой:

  1. Бот создаёт черновик сообщения через sendMessageDraft

  2. По мере получения чанков обновляет этот черновик

  3. Пока идёт стрим - Telegram показывает анимированные точки в конце текста

  4. Когда стрим завершён - бот отправляет финальное сообщение через обычный sendMessage

Черновик закрепляется в чате и обновляется в реальном времени. Пользователь видит, как текст печатается.

Просто представьте, что слова появляются, а точки прыгают)
Просто представьте, что слова появляются, а точки прыгают)

sendMessageDraft

await api.arequest(
    "sendMessageDraft",
    {"chat_id": chat_id, "draft_id": draft_id, "text": accumulated_text},
)

Параметры:

  • chat_id - куда отправляем

  • draft_id - уникальный идентификатор черновика (int). Все обновления одного стрима должны идти с одним draft_id

  • text - текущий накопленный текст

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_MSCHUNK_SIZECHUNK_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)


  1. K0Jlya9
    08.04.2026 11:36

    Результат работы примера выглядит хуже чем старый способ с банальным редактированием сообщения раз в пару секунд.


    1. Techdir_hub Автор
      08.04.2026 11:36

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


  1. CrazyElf
    08.04.2026 11:36

    full_text += chunk - не самый лучший способ работы со строками. Хотя, если текст не очень большой, то ладно.


    1. Techdir_hub Автор
      08.04.2026 11:36

      Это правда, поэтому это не повторяйте за мной!) Там конечно можно спорить, при каких объемах это плохо, но не будем


      1. CrazyElf
        08.04.2026 11:36

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


        1. Techdir_hub Автор
          08.04.2026 11:36

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


          1. CrazyElf
            08.04.2026 11:36

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


  1. Triton5
    08.04.2026 11:36

    Отличная статья! Сам недавно реализовывал стриминг для Telegram-бота на Cloudflare Workers, и возникли мысли по поводу подхода.

    В статье используется sendMessageDraft → финальный sendMessage. Но есть альтернативный подход — редактирование существующего сообщения через editMessageText:

    1. Бот создаёт "думаю..." сообщение через sendMessage

    2. По мере получения чанков от LLM — редактирует это же сообщение через editMessageText

    3. В конце — финальное редактирование с полным текстом

    Плюсы такого подхода:

    • Сообщение в истории чата остаётся ОДНИМ (а не черновик + новое сообщение)

    • Не нужно два API-вызова в конце

    • Работает и для длинных ответов с разбивкой на несколько частей

    Минусы:

    • Нужно вручную показывать "печатает" через sendChatAction('typing')

    • Требуется HTML-форматирование (проблемы с незакрытыми тегами при стриминге)

    • Лимит Telegram: ~1 editMessageText в секунду

    Для коротких сообщений (без стриминга) можно использовать Markdown напрямую (без HTML), а для длинных — HTML + балансировка тегов.

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

    Посмотреть live-реализацию можно в боте @Vika_talk_Bot — там используется подход с editMessageText, стриминг + разбивка на части, Markdown для коротких сообщений (без стрима) и HTML для длинных.


    1. CrazyElf
      08.04.2026 11:36

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


      1. Triton5
        08.04.2026 11:36

        А, то есть, раз уже выше писали, то мне это обсуждать нельзя, да?:)
        А как насчёт ответа на вопрос? Технической дискуссии, так сказать? Вы же не вникали в написанное, я так вижу.

        У нас есть длинный текст, например 10 тысяч символов от нейросети, его нужно передать в телеграм, а там ограничение в 4096 символа. Решение: режем на заведомо более мелкие куски (я режу по 3500).

        Проблема: при нарезке мы часто портим входную разметку Markdown! (что, кстати вы в своих комментариях выше вообще никто не отобразил), и Телеграм возвращает ошибку 400. Можно делать анализ тегов кусков текста, проходя каждую часть валидатором, можно просто отдавать в html, попутно просто вырезав все теги перед разрезанием на части. Я сделал так: пытаемся отправить маркдаун, а html как запасной путь.

        Но это в случае отправки разрезанными кусками, где у нас есть власть над текстом. В случае же стриминга предложенная схема замены черновика на замену текста отлично подходит для того, что влезает в 1 сообщение. А в случае замены стрима на 5 кусках на финальные обработанные части мы должны обновлять все 5 частей. А если мы их по-другому нарежем, ну, так получится? Или нам надо тоже сохранять все 5 частей в память и их все заменять? :) Думаю над этим:)

        Понимаете, надо сразу думать про то, что нам потом придётся резать текст.

        Автор просто не рассматривал длинные ответы.

        Проблема остаётся:

        • Черновик → sendMessage (НОВОЕ сообщение)

        • Если текст > 4096 — снова резать на куски

        • Но это уже новые сообщения, не черновик!

          Подход через editMessageText — более универсальный, хоть и сложнее. Особенно для длинных ответов с разбивкой.


        А насчёт "рекламы" бота, который ничего не продаёт - вы, верно, шутите, мистер CrazyElf.
        Особенно учитывая по самые уши залепленный рекламой сайт habr.com.
        Если у вас есть другой бот, где можно в натуре посмотреть метод стриминга через редактирование, можете его написать :)