redb.Route — Telegram-коннектор: бот как маршрут. Long polling, клавиатуры, вебхуки и честный at-most-once

redb.Route

Про очереди в этой серии уже было — Kafka, RabbitMQ, SQS. Теперь транспорт, который на первый взгляд к «интеграционным шинам» отношения не имеет: Telegram. Коннектор redb.Route.Telegram — поверх официальной библиотеки Telegram.Bot, но писать вам придётся не хендлеры, а маршруты, где весь бот задаётся одной строкой-URI:

From(Tg.Receive("${env:TELEGRAM_TOKEN}"))
    .Filter(Header(TelegramHeaders.MessageType).isEqualTo("Text"))
    .To(Tg.Send("${env:TELEGRAM_TOKEN}")
           .ChatId(Header(TelegramHeaders.ChatId)));

Прочитали — и уже знаете, что происходит: принимаем апдейты long-polling'ом, пропускаем только текстовые сообщения, отвечаем в тот же чат. Это работающий эхо-бот целиком — ни TelegramBotClient, ни OnMessage, ни ручного цикла getUpdates, ни обработки 429. Всё это — коннектор.

Зачем бот в ESB-фреймворке? Затем, что бот в реальном проекте — почти никогда не «просто бот». Это уведомления из очереди в канал дежурных, ChatOps-команды, которые дёргают внутренние API, файлы-отчёты по расписанию, диалоги, за которыми стоит база и LLM. То есть ровно интеграционные маршруты — и когда Telegram становится эндпоинтом среди прочих (From(Tg.Receive(...)) рядом с To("sqs://...") и To("sql:...")), вся обвязка бота — фильтры, роутинг по типу апдейта, ретраи, параллелизм, трейсинг — достаётся от фреймворка, а не пишется заново вокруг очередного OnMessage.

Подробная справка по коннектору — в README. Лицензия — Apache 2.0, без ключей и регистраций.

Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху:

Полный список — в профиле. Исходники: github.com/redbase-app. Про саму БД: redb.ru.


Установка и регистрация

dotnet add package redb.Route.Telegram

Пакет — redb.Route.Telegram на NuGet, поверх Telegram.Bot 22.x. Регистрация в DI:

builder.Services.AddRedbRoute(route =>
{
    route.Services.AddRedbRouteTelegram();
    route.AddRouteBuilder<MyBotRoutes>();
});

Токен берётся у @BotFather, кладётся в переменную окружения — и больше никакой инфраструктуры: long polling работает из любого места, где есть исходящий HTTPS. Ни белого IP, ни сертификата, ни туннеля для локальной разработки.


Анатомия URI

Одна схема telegram://, режим — в пути:

URI Направление Что делает
telegram://receive?token=TOKEN Consumer Long polling; принимает все типы апдейтов
telegram://send?token=TOKEN&chatId=ID Producer Отправляет текстовое сообщение
telegram://document?token=TOKEN&chatId=ID Producer Отправляет файл (тело: Stream или byte[])
telegram://photo?token=TOKEN&chatId=ID Producer Отправляет фото
telegram://answer?token=TOKEN Producer Отвечает на callback query (снимает спиннер с кнопки)
telegram://edit?token=TOKEN&chatId=ID Producer Редактирует текст существующего сообщения
telegram://delete?token=TOKEN&chatId=ID Producer Удаляет сообщение

Все query-параметры поддерживают ${...}-выражения, но с одной осознанной асимметрией:

  • Per-message параметры (chatId, caption, fileName, parseMode, …) понимают полную грамматику выражений — ${env:VAR}, ${header.name}, ${property.name}, ${body} — и резолвятся на каждом сообщении.
  • token резолвится один раз на старте эндпоинта, когда никакого сообщения ещё нет, поэтому поддерживает только ${env:VAR}. Отсутствующая переменная окружения — исключение на старте приложения, а не молчаливая отправка нерезолвленной строки в Telegram.
telegram://send?token=${env:TELEGRAM_TOKEN}&chatId=${header.telegram.chatId}&parseMode=HTML

Fluent-билдером — то же самое: Tg.Send(token).ChatId(...).ParseMode("HTML") собирает ровно эту строку с URL-энкодингом. У билдера есть implicit-преобразование в string, поэтому он подставляется прямо в From(...) / .To(...) без всяких .Build(). Фабрика Tg повторяет режимы один в один: Receive / Send / Document / Photo / Answer / Edit / Delete.


Все параметры

Главная причина сохранить статью в закладки. Имена — как в URI, дефолты — из коробки.

Producer (send / document / photo / answer / edit / delete)

Параметр Тип Зачем
token string Обязательный. Токен бота от @BotFather
chatId string/long Чат-получатель или @channelusername
parseMode string HTML | Markdown | MarkdownV2 (валидируется на старте)
caption string Подпись для document/photo
fileName string Имя файла, когда тело — Stream
disableNotification bool Отправить без звука (дефолт false)
replyToMessageId string Цель реплая: константный id или ${...}-выражение на каждое сообщение; .ReplyToIncoming() — сахар для ${header.telegram.messageId}
messageId string Цель edit/delete: id или выражение, например ${header.telegram.sentMessageId}
showAlert bool В режиме answer — модальный алерт вместо тоста (дефолт false)
bodyIsFileId bool Строковое тело в document/photo трактовать как готовый Telegram file_id (дефолт false)
sendTimeoutSeconds int Таймаут на отправку, 1–600 (дефолт 120). Слинкован с cancellation-токеном пайплайна

Consumer (receive)

Параметр Тип Зачем
token string Обязательный. Токен бота

Да, всё. У консьюмера сознательно нет ручки таймаута long polling: внутренний receiver Telegram.Bot управляет таймингом сам и наружу настройку не отдаёт, поэтому коннектор не предлагает фейковую опцию, которая ни на что не влияет. Пропускную способность масштабируют иначе — .Threads(N) на маршруте, об этом ниже.


Заголовки: всё, что коннектор кладёт и читает

Консьюмер разбирает апдейт и раскладывает его в заголовки telegram.*; тело exchange — текст сообщения (или callback data для нажатой кнопки).

После приёма апдейта

Заголовок Тип Что внутри
telegram.updateId long Уникальный ID апдейта
telegram.updateType string Message, EditedMessage, CallbackQuery, …
telegram.messageId int ID сообщения в чате
telegram.messageType string Text, Photo, Document, Sticker, …
telegram.text string? Текст сообщения
telegram.chatId long Чат — то, чем отвечать
telegram.chatType string Private, Group, Supergroup, Channel
telegram.userId long Отправитель
telegram.firstName / telegram.lastName / telegram.username string Имя / фамилия / @username без @
telegram.languageCode string? Язык отправителя (ru, en, …)
telegram.callbackQueryId string Для CallbackQuery — то, что нужно answer-режиму
telegram.callbackData string? data нажатой inline-кнопки
telegram.webAppData string Payload из mini app (WebApp.sendData), он же тело exchange
telegram.webAppButtonText string? Текст кнопки, которой открыли mini app

Перед отправкой (управление продюсером)

Заголовок Тип Что делает
telegram.chatId long/string Перебивает chatId из URI. Заголовок всегда выигрывает
telegram.parseMode string Per-message HTML / Markdown / MarkdownV2
telegram.disableNotification bool Per-message тихая отправка
telegram.replyToMessageId int Отправить как ответ на сообщение
telegram.replyMarkup markup Inline / reply-клавиатура (см. рецепт про кнопки)
telegram.messageId int Цель edit / delete; бьёт опцию messageId
telegram.caption string Per-message подпись для document / photo
telegram.showAlert bool Per-message алерт-режим для answer
telegram.fileId string Переиспользовать уже загруженный в Telegram файл
telegram.fileName string Имя файла для Stream / byte[]

После успешной отправки продюсер пишет обратно telegram.sentMessageId — ID созданного сообщения, ровно то, что нужно для последующего edit / delete (прогресс-бары, «обновляемые» статусы).

Правило приоритета простое и проверено тестом: URI-опция — это дефолт маршрута, заголовок — решение по конкретному сообщению. chatId=111 в URI и telegram.chatId=999 в заголовках → сообщение уйдёт в 999.


Честно про доставку: at-most-once

Место, где маркетинг любит промолчать. Открываем код.

Receiver Telegram.Bot сдвигает offset апдейтов в момент, когда апдейт выдан хендлеру — не после того, как ваш маршрут его обработал. Редоставки не существует: если процессор бросил исключение, апдейт уже подтверждён и назад не вернётся. Это свойство самого Bot API, и коннектор его не маскирует — семантика консьюмера честно называется at-most-once.

Отражено это и в поведении: упавший exchange логируется как дроп (message dropped (at-most-once, no redelivery)) и не перебрасывается — одно кривое сообщение не останавливает единственный поток апдейтов, бот продолжает жить. Ошибки поллинга тоже разобраны по-взрослому: 401 (кривой токен) и 409 (второй getUpdates на тот же токен) — фатальные, консьюмер отцепляется, чтобы не устроить ретрай-шторм; транзиентные сетевые — переживаются штатным ретраем Telegram.Bot.

Нужен at-least-once — рецепт стандартный: первым шагом маршрута кладите апдейт в надёжную очередь и обрабатывайте оттуда:

From(Tg.Receive(token))
    .To("sqs://tg-updates");          // durable buffer, at-least-once from here

From("sqs://tg-updates?concurrentConsumers=4")
    .Process(HandleUpdate);           // retries, visibility, redelivery — queue semantics

Как раз для этого рядом лежат коннекторы SQS и RabbitMQ из прошлых статей. Транспорт остаётся тонким, семантику надёжности вы выбираете сами — а не получаете «встроенную» с сюрпризами.


Rate limit: 429 обрабатывается за вас

Telegram ограничивает частоту отправки и на превышение отвечает HTTP 429 с подсказкой retry_after. Продюсер обрабатывает это сам: ждёт retry_after секунд (кламп 1–60), повторяет — до 3 попыток, уважая cancellation-токен пайплайна. В тесте это выглядит так: первый вызов — 429 c RetryAfter = 1, второй — успех, наружу ошибка не вылетает.

Это не опция, а обязательное поведение: немедленный ретрай на 429 эскалирует во временный бан токена — и вот тогда у вас действительно проблемы. Массовые рассылки поверх этого всё равно стоит троттлить на уровне маршрута (.Throttle(...) из EIP-набора), но от случайного всплеска коннектор защищает из коробки.


Конкурентность: один поток апдейтов, N воркеров

У Telegram-бота один поток getUpdates на токен — это ограничение протокола: два одновременных поллера растаскивают апдейты друг у друга и перемешивают порядок, а Bot API отвечает второму 409. Коннектор превращает это в fail-fast: попытка запустить второй консьюмер на тот же токен — исключение на старте, а не тихая деградация в проде.

Масштабируется поэтому не приём, а обработка — стандартным EIP-шагом:

From(Tg.Receive(token))
    .Threads(4)          // 4 concurrent processing workers, one receiver
    .Process(HandleMessage);

Один receiver раскладывает апдейты по пулу из четырёх воркеров. Интеграционный тест проверяет это буквально: на 20 апдейтов при пуле в 5 замеренный пик одновременности — ровно 5. Порядок обработки при N > 1 не гарантируется — это цена параллелизма, и она честно задокументирована. Полная модель конкурентности фреймворка — в CONCURRENCY.md.


Рецепт 1: эхо-бот целиком

Всё, что нужно для запуска — класс маршрутов, регистрация и переменная окружения:

using redb.Route;
using redb.Route.Telegram;
using redb.Route.Telegram.Fluent;

public class EchoBotRoutes : RouteBuilder
{
    protected override void Configure()
    {
        From(Tg.Receive("${env:TELEGRAM_TOKEN}"))
            .Filter(Header(TelegramHeaders.MessageType).isEqualTo("Text"))
            .To(Tg.Send("${env:TELEGRAM_TOKEN}")
                   .ChatId(Header(TelegramHeaders.ChatId)));
    }
}
builder.Services.AddRedbRoute(route =>
{
    route.Services.AddRedbRouteTelegram();
    route.AddRouteBuilder<EchoBotRoutes>();
});

Filter здесь не для красоты: receive принимает все типы апдейтов — редактирования, стикеры, вступления в группу. Эхо-бот отвечает только на текст, остальное молча пропускает. А ответить «как reply», а не просто в чат — одна опция продюсера:

.To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)).ReplyToIncoming())

Рецепт 2: кнопки — полный цикл inline-клавиатуры

Самая частая ботовая задача: показать кнопки, поймать нажатие, отреагировать. По шагам.

Шаг 1 — показать клавиатуру. WithInlineKeyboard — сахар над заголовком telegram.replyMarkup, никакого using Telegram.Bot.Types.ReplyMarkups в коде маршрута:

From(Tg.Receive(token))
    .SetBody("Choose an option:")
    .WithInlineKeyboard(k => k
        .Row(r => r.Callback("✅ Confirm", "action:confirm")
                   .Callback("❌ Cancel",  "action:cancel"))
        .Row(r => r.Callback("📊 Stats",   "action:stats")))
    .To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)).ParseMode("HTML"));

Типы inline-кнопок: Callback(text, data), Url(text, url), WebApp(text, url), SwitchInlineQuery(text, query), SwitchInlineQueryCurrentChat(text, query).

Шаг 2 — поймать нажатие и снять спиннер. Нажатие приходит апдейтом CallbackQuery: тело exchange — data кнопки, в заголовках — telegram.callbackQueryId и telegram.callbackData. И вот грабли, на которые наступают ровно один раз: на каждый tap обязан уйти answerCallbackQuery, иначе кнопка крутит спиннер до таймаута. В коннекторе это отдельный режим answer:

From(Tg.Receive(token))
    .Filter(Header(TelegramHeaders.UpdateType).isEqualTo("CallbackQuery"))
    .SetBody("✅ Done")                                         // optional toast text
    .To(Tg.Answer(token))                                       // clears the spinner
    .Process(e => e.In.SetBody(HandleAction(
        e.In.GetHeader<string>(TelegramHeaders.CallbackData)!)))
    .To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId))); // send follow-up

Tg.Answer сам читает telegram.callbackQueryId из заголовков — консьюмер его уже положил.

Шаг 3 — отредактировать сообщение вместо нового. Классика UX инлайн-меню: не плодить сообщения, а обновлять одно. Режим edit: тело — новый текст, telegram.messageId — какое сообщение менять; клавиатуру можно обновить тем же заголовком:

.SetBody("Updated text")
.Process(e =>
{
    e.In.Headers[TelegramHeaders.ReplyMarkup] = TgKeyboard.Inline(k =>
        k.Row(r => r.Callback("🔄 Refresh", "action:refresh")));
})
.To(Tg.Edit(token).ChatId(Header(TelegramHeaders.ChatId)))

Одна честная деталь: Bot API у editMessageText не принимает disable_notification (редактирование и так никого не уведомляет), поэтому в edit-режиме опция сознательно игнорируется — вместо того чтобы делать вид, что работает.

Динамическая клавиатура — когда ряды известны только в рантайме — строится тем же TgKeyboard внутри .Process(...):

.Process((e, ct) =>
{
    e.In.Headers[TelegramHeaders.ReplyMarkup] = TgKeyboard.Inline(k =>
    {
        foreach (var o in orders)
            k.Row(r => r.Callback(o.Title, $"order:{o.Id}"));
    });
    return Task.CompletedTask;
})
.To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)))

Reply-клавиатура (та, что вместо системной под полем ввода) и её снятие:

.WithReplyKeyboard(k => k
    .Row(r => r.Button("📋 Menu").Button("❓ Help"))
    .Resize()
    .OneTime())

// later:
.WithoutKeyboard()

Кнопки reply-рядов: Button(text), ContactButton(text), LocationButton(text), WebAppButton(text, url); опции клавиатуры: Resize(), OneTime(), Persistent(), Selective(), Placeholder(text). И всё это — сахар: билдер просто ставит заголовок telegram.replyMarkup, так что руками положить туда готовый InlineKeyboardMarkup из Telegram.Bot — тоже можно, обе двери открыты.

Mini app. WebApp(text, url) и WebAppButton(text, url) открывают ваше HTTPS-приложение внутри клиента Telegram. Разница между ними не косметическая: sendData из mini app работает только у кнопки reply-клавиатуры, у inline-кнопки его нет. Payload приходит обычным сообщением с messageType = "WebAppData" — в теле exchange и в заголовке telegram.webAppData:

From(Tg.Receive(token))
    .Filter(Header(TelegramHeaders.MessageType).isEqualTo("WebAppData"))
    .Unmarshal<OrderForm>()
    .Process(HandleOrder)
    .To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)));

Канал односторонний и ограничен 4096 байтами — ответа mini app не получает, это свойство Bot API, а не коннектора.


Рецепт 3: файлы и фото — транспорт не гадает

Режимы document и photo отправляют то, что лежит в теле exchange. Правила маппинга жёсткие и предсказуемые:

  • Stream или byte[] — загружается как новый файл (задайте fileName, чтобы у получателя не было безымянного вложения);
  • строка с http(s):// — передаётся Telegram, он скачает сам;
  • готовый file_id — через заголовок telegram.fileId или опцию bodyIsFileId=true (тогда строковое тело трактуется как file_id);
  • просто строка без этих признаков — отклоняется с внятной ошибкой. Транспорт не гадает, что вы имели в виду — путь на диске, file_id или содержимое файла.
// Nightly report: generate PDF, send as a document with a caption
From("cron://reports/daily?schedule=0 0 8 * * ?")
    .Process(BuildDailyReportPdf)                 // body = byte[]
    .To(Tg.Document(token)
           .ChatId(opsChatId)
           .FileName("report.pdf")
           .Caption("Daily report"));

// Re-send an already uploaded file — no second upload
.To(Tg.Document(token).ChatId(chatId).BodyIsFileId())

file_id — это способ Telegram не гонять байты повторно: раз загрузили, дальше шлёте идентификатор. Продюсер возвращает telegram.sentMessageId, а входящие документы несут свои метаданные в заголовках — цепочка «принял файл → переслал file_id» собирается без единого лишнего аплоада.


Рецепт 4: вебхук — те же заголовки, другой вход

Long polling прекрасен для старта, но на нагрузке или за строгим файрволом приходит время вебхука. В коннекторе это не «второй режим консьюмера», а честная композиция: HTTP-вход даёт коннектор redb.Route.Http, а Telegram-пакет добавляет один шаг — .UnpackTelegramUpdate(), который десериализует JSON апдейта и раскладывает те же самые заголовки telegram.*, что и long polling:

// Requires redb.Route.Http for Http.Listen(...)
From(Http.Listen("/tg/webhook")
         .Header("X-Telegram-Bot-Api-Secret-Token", webhookSecret)
         .InOut())
    .UnpackTelegramUpdate()          // deserialise JSON → Update + populate headers
    .To("direct://tg-dispatch");

// The same dispatcher handles both polling and webhook traffic
From("direct://tg-dispatch")
    .Choice()
        .When(Header(TelegramHeaders.UpdateType).isEqualTo("Message"))
            .To("direct://handle-message")
        .When(Header(TelegramHeaders.UpdateType).isEqualTo("CallbackQuery"))
            .To("direct://handle-callback")
    .EndChoice();

Вот ради этого заголовки и унифицированы: диспетчер и вся логика ниже не знают, пришёл апдейт поллингом или вебхуком. Локально разрабатываете на polling (ноль инфраструктуры), в прод выкатываете webhook — маршруты обработки не меняются ни на строчку. Проверка секретного заголовка X-Telegram-Bot-Api-Secret-Token — стандартная защита вебхука из документации Bot API, здесь это просто условие на HTTP-входе.


Рецепт 5: капстоун — RAG-бот на трёх маршрутах

И напоследок — зачем боту жить внутри интеграционного фреймворка, одной картинкой. Связка с коннектором redb.Route.Llm и knowledge-хранилищем превращает «бота-отвечалку» в RAG-систему буквально в три маршрута:

// 1. Ingest: a folder of markdown → embedded chunks in redb
From("file://docs?include=*.md")
    .To("knowledge://handbook?chunkChars=1000&overlap=100&embed=true");
// 2. A question from Telegram → RAG → answer back to the same chat
From(Tg.Receive(token))
    .Threads(4)                         // up to 4 dialogs in parallel
        .Knowledge("handbook", k: 5)    // top-5 chunks into the system prompt
        .To("llm://claude")             // grounded answer
    .EndThreads()
    .To(Tg.Send(token).ChatId(Header(TelegramHeaders.ChatId)));

Telegram здесь — просто транспорт на входе и выходе: та же строчка From(Tg.Receive(...)), что и в эхо-боте, а между ними — knowledge-поиск и LLM как обычные шаги маршрута. Подробный разбор этой связки (включая вариант, где модель сама дёргает knowledge_search как тул) — в статье про релиз экосистемы 3.3.0.


Что коннектор честно НЕ делает

Таблица прямо из README — граница транспорта проведена явно:

Чего нет Где взять
Вебхук-сервер redb.Route.HttpHttp.Listen(...) + .UnpackTelegramUpdate()
FSM / состояние диалога Слой приложения — своё хранилище состояний
At-least-once / редоставка Первым шагом маршрута — в надёжную очередь (SQS, RabbitMQ)
Батчинг Его нет в самом Bot API — каждое сообщение отдельный HTTP-вызов

Это не «недоделки в бэклоге», а позиция: транспорт остаётся тонким и предсказуемым, а диалоговое состояние, надёжность и HTTP-сервер — отдельные ответственности с отдельными, уже существующими инструментами.


Итог

Telegram в redb.Route — это маршруты, где весь бот задан строками-URI: From(Tg.Receive(...)) на входе, Tg.Send / Document / Photo / Answer / Edit / Delete на выходе, а вся возня с getUpdates, offset'ами, 429, спиннерами callback-кнопок и разметкой клавиатур спрятана за параметрами и заголовками. Фильтры, роутинг по типу апдейта, параллелизм .Threads(N), вебхук через HTTP-коннектор, файлы без лишних аплоадов через file_id — набор полный, а на выходе короткая и читаемая строка.

Честно про размены: доставка — at-most-once, потому что так устроен сам Bot API (нужна надёжность — очередь первым шагом); поток апдейтов один на токен, параллелится обработка, а не приём; FSM и состояние диалога — не задача транспорта. Всё это — свойства платформы, а не недостатки коннектора, и коннектор про них честен: fail-fast на старте вместо тихой деградации, внятные ошибки вместо угадывания.

Ловится что-то в вашем сценарии — пишите в комментариях.

Исходники и релизы: github.com/redbase-app. Про БД redb: redb.ru. Прошлые статьи — в профиле.


If this was useful — a ⭐ on GitHub helps others find it.