redb.Route — Telegram-коннектор: бот как маршрут. Long polling, клавиатуры, вебхуки и честный at-most-once
Про очереди в этой серии уже было — 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. Это продолжение серии, свежие статьи — сверху:
- ночной cron-джоб, партиции и сплиттер — коннектор Quartz (предыдущая в серии)
- коннектор Amazon SQS + SNS — очереди, fan-out и at-least-once
- коннектор RabbitMQ — один URI на endpoint, RPC, конкурирующие консьюмеры и dead-letter
- redb.Route — уходим от MassTransit, идём к Apache Camel: Kafka, Scatter‑Gather и транзакции
- Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router
- redb.Route — Apache Camel для .NET, который мы написали потому что выхода другого не было
- redb — типизированное хранилище для .NET поверх Postgres/MSSQL: без миграций, без Include, с полным LINQ
Полный список — в профиле. Исходники: 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.Http — Http.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.