Telegram-бот с Telega 3: роутер, диалоги, flow, эксплуатация
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Telegram-бот с Telega 3: роутер, диалоги, flow, эксплуатация
Финальный проект серии. Поднимаем настоящего Telegram-бота на Telega 3: команды, дерево роутеров, фильтры, middleware, внедрение зависимостей, сессии и общее состояние, отложенные задачи, многошаговые Conversation-диалоги, Flow как конечные автоматы и декларативные Dialog-экраны. Внутри уже знакомые акторы и supervisor-дерево, а сверху то, без чего бот не живёт в проде: health, дедупликация, дренаж и мёртвые письма.
Урок написан под Telega 3 (третья мажорная версия, вышла 4 сентября 2026) и Gleam 1.18. Библиотека требует минимум Gleam 1.12. Если ты видел примеры под вторую версию, главное изменение вот: билдер бота переписан целиком, композиция роутеров стала отдельным типом, а таймауты у функций ожидания наконец меряются в миллисекундах, как и было написано в документации. Полная таблица переименований в конце урока, в разделе про миграцию.
Цели главы
В этой главе мы:
- Познакомимся с Telega, библиотекой для Telegram-ботов на Gleam
- Соберём бота новым билдером: один конструктор, режим доставки, один терминал
- Научимся строить роутер и дерево роутеров, разберём фильтры и типизированные callback-маршруты
- Освоим три слоя памяти бота: session, store и dependencies
- Научимся откладывать работу на потом через jobs, включая задачи, переживающие рестарт
- Изучим Conversation API, линейные многошаговые диалоги через
wait_*функции - Разберём Flow API, персистентные конечные автоматы с навигацией
- Соберём экран на Dialog API: одно живое сообщение, виджеты, под-диалоги
- Подключим pre-router middleware, роли, аннотации, защиту от дублей и rate limit
- Разберём классификацию ошибок Telegram и политику повторов у клиента
- Поймём архитектуру дерева супервизоров, health-проверки, дренаж и мёртвые письма
- Заглянем в расширенные возможности: inline-режим, платежи в Stars, реакции, медиагруппы, i18n и телеметрию
- Научимся тестировать бота через
telega/testing, вплоть до снимков экранов диалога
Как работают Telegram-боты
Telegram даёт два способа получать обновления: long polling и webhook. Выбор между ними, одно из первых архитектурных решений.
Long polling, активное получение
Бот сам спрашивает сервера Telegram: есть новые сообщения? Если сообщений нет, соединение висит открытым до 30 секунд, затем запрос повторяется. Это pull-модель: бот забирает обновления.
Плюсы:
- Простая настройка: не нужен публичный адрес и сертификат
- Работает на ноутбуке, что важно для разработки
- Предсказуемое поведение, проще отлаживать
- Встроенная защита от перегрузки: воркер не забирает новую пачку, пока предыдущая в работе
Минусы:
- Выше нагрузка на сеть: запросы идут, даже когда сообщений нет
- Бот должен работать постоянно, нельзя спать между сообщениями
- Не подходит для serverless-платформ
Webhook, реактивное получение
Telegram отправляет POST-запрос на твой сервер при каждом обновлении. Это push-модель: сервер узнаёт о событии, не опрашивая никого.
Плюсы:
- Меньше нагрузки: запросы только при реальных событиях
- Подходит для serverless: функция просыпается на сообщение
- Экономия ресурсов при низкой активности
Минусы:
- Нужен публичный адрес с валидным сертификатом
- Сложнее отлаживать локально
- Конкурентная обработка: несколько обновлений могут прийти одновременно
- Критично: Telegram ждёт ответа около десяти секунд. Не успел, обновление придёт повторно, и пользователь получит дубль
Отсюда два правила для webhook, к которым мы вернёмся в разделе про эксплуатацию: долгую работу выносим в очередь задач, а повторную доставку гасим идемпотентностью.
Что выбрать
| Сценарий | Рекомендация |
|---|---|
| Локальная разработка | Long polling |
| Простой бот на своём сервере | Long polling, он проще |
| Serverless | Webhook, единственный вариант |
| Высокая нагрузка, автомасштабирование | Webhook |
| Первый бот в жизни | Long polling |
Telega поддерживает оба режима. Ядро не привязано ни к HTTP-клиенту, ни к веб-серверу, ни к хранилищу: клиент подключается пакетом telega_httpc или telega_hackney, webhook-эндпоинт пакетом telega_wisp или telega_mist, хранилище пакетами telega_storage_*. Таблица экосистемы в конце урока.
Первый бот
Зависимости:
[dependencies]
telega = ">= 3.0.0 and < 4.0.0"
telega_httpc = ">= 3.0.0 and < 4.0.0"
gleam_erlang = ">= 1.2.0 and < 2.0.0"
envoy = ">= 1.0.0 and < 2.0.0"
Через CLI это одна команда:
gleam add telega telega_httpc gleam_erlang envoy
Эхо-бот целиком:
import envoy
import gleam/erlang/process
import telega
import telega/reply
import telega/router
import telega/update
import telega_httpc
fn handle_text(ctx, text) {
use ctx <- telega.log_context(ctx, "echo_text")
reply.text(ctx, text)
}
fn handle_command(ctx, command: update.Command) {
use ctx <- telega.log_context(ctx, "echo_command")
reply.text(ctx, "Команда: " <> command.text)
}
pub fn main() {
let bot_router =
router.new("echo_bot")
|> router.on_any_text(handle_text)
|> router.on_commands(["start", "help"], handle_command)
// Токен никогда не хардкодим: кто прочитает репозиторий, тот заберёт бота.
let assert Ok(token) = envoy.get("BOT_TOKEN")
let client = telega_httpc.new(token)
let assert Ok(_bot) =
telega.new(client)
|> telega.router(bot_router)
|> telega.start()
process.sleep_forever()
}
Запуск:
export BOT_TOKEN="123456:твой-токен"
gleam run
Разберём по шагам:
telega_httpc.new(token)создаёт HTTP-клиент к Bot APItelega.new(client)начинает сборку ботаtelega.router(bot_router)подключает роутерtelega.start()поднимает всё дерево процессов и, по умолчанию, long polling
Две мелочи, которые экономят строки в каждом обработчике. reply.text(ctx, text) отправляет сообщение и возвращает сам контекст, а не Result(Message, _), поэтому обработчик, который только отвечает, помещается в одну строку. А telega.log_context(ctx, "echo_text") вешает на процесс метаданные обновления (chat_id, from_id, update_id), и любая строка лога внутри этого обработчика, включая строки из чужих библиотек, будет с этими полями.
Полный пример: 00-echo-bot
Билдер: один конструктор, режим, терминал
В Telega 3 сборка бота устроена по одному правилу: бывают глаголы и бывают настройки. Глаголы это шаги конвейера, у них короткие имена. Настройки все начинаются с with_.
telega.new(api_client) // конструктор, всегда один
|> telega.dependencies(Dependencies(db:, catalog:)) // необязательно
|> telega.session(session_settings) // необязательно, иначе сессия это Nil
|> telega.router(bot_router) // или telega.router_tree(tree)
|> telega.polling(polling.default_settings()) // необязательно, это режим по умолчанию
|> telega.start() // терминал
Webhook, это тот же конвейер с заменой одного шага:
telega.new(api_client)
|> telega.webhook(
url: "https://bot.example.com", // публичный адрес
path: "webhook", // Telegram будет слать POST на url/path
secret_token: Some(secret), // None, и Telega сгенерирует секрет сама
)
|> telega.router(bot_router)
|> telega.start() // сама вызовет setWebhook
Терминалов два: telega.start() поднимает собственное дерево процессов, telega.supervised() возвращает описание ребёнка для твоего супервизора (об этом ниже, в разделе про архитектуру).
Порядок шагов проверяет компилятор
dependencies и session фиксируют типы, против которых потом типизирован роутер. Поэтому они идут до роутера. Раньше это была ловушка: вызов настройки зависимостей после роутера молча обнулял роутер, и бот стартовал без единого маршрута, без единой ошибки.
Теперь у билдера есть четвёртый тип-параметр, маркер состояния: telega.Fresh у нового билдера и telega.Configured, как только зарегистрировано хоть что-то, типизированное против сессии и зависимостей. dependencies и session принимают только Fresh, поэтому неправильный порядок, это ошибка компиляции:
Expected type:
telega.TelegaBuilder(Nil, e, Nil, telega.Fresh)
Found type:
telega.TelegaBuilder(Nil, e, Nil, telega.Configured)
Лечится всегда одинаково: подними dependencies и session выше router. Настройки на with_ можно ставить где угодно.
Если ты завернул сборку бота в собственную функцию-помощник, добавь ей этот же параметр:
// принимает билдер в любом состоянии
fn attach(builder: telega.TelegaBuilder(session, error, dependencies, state)) { ... }
// а помощник, который сам зовёт telega.session, работает только на свежем
fn attach_session(builder: telega.TelegaBuilder(old, error, dependencies, telega.Fresh)) { ... }
Настройки, которые стоит знать сразу
| Настройка | Что делает |
|---|---|
with_auto_commands() | публикует команды роутера в меню Telegram при старте |
with_auto_allowed_updates() | просит у Telegram только те типы обновлений, которые роутер умеет обрабатывать |
with_max_in_flight(n) | предел обновлений в работе, выше него бот честно говорит, что перегружен |
with_drain_timeout(ms) | сколько ждать дообработки при остановке |
with_signal_handlers() | SIGTERM запускает мягкую остановку, а не убивает VM |
with_dead_letters(...) | сохранять обновления, которые не пережил упавший процесс чата |
with_chat_idle_timeout(ms) | через сколько тишины выгружать процесс чата (по умолчанию 30 минут) |
with_media_group_timeout(ms) | собирать альбом в одно обновление |
with_session_key(...) | по какому ключу вести сессию |
use_pre_handler(...) | обработчик до маршрутизации, на весь бот |
Архитектура: дерево супервизоров
telega.start() поднимает не один процесс, а дерево на базе OTP (глава 10). Понимание этой картины экономит часы отладки.
Структура дерева
TelegaRootSupervisor (OneForOne)
├── ChatInstances (фабрика, дети Transient)
│ ├── ChatInstance {chat1:user1}
│ ├── ChatInstance {chat2:user2}
│ └── ... FSM: ROUTING и WAITING
├── Bot actor (Permanent)
└── Polling worker (Permanent, только в режиме polling)
TelegaRootSupervisor, корневой супервизор со стратегией OneForOne: падает один ребёнок, перезапускается только он.
ChatInstances, фабрика, которая заводит по процессу на каждую пару пользователь-чат. Ключ процесса это {chat_id}:{from_id}, а не просто chat_id: два человека в одной группе получают два разных процесса со своими сессиями. Дети фабрики перезапускаются по стратегии Transient, то есть только при аварийном выходе, и при рестарте сами перерегистрируются в ETS-реестре.
ChatInstance, процесс одной пары пользователь-чат. Внутри конечный автомат из двух состояний:
- ROUTING, обычный режим: обновление идёт через middleware и роутер
- WAITING, режим ожидания: обработчик вызвал
wait_textили другую функцию ожидания, и следующее сообщение уйдёт прямо в неё, минуя роутер
Bot actor, процесс с конфигурацией бота: роутер, настройки сессий, pre-router обработчики, счётчик обновлений в работе.
Polling worker, процесс long polling. Переживает сетевые сбои: останавливают его только 401 и 404, а 409, лимиты и пятисотые приводят к повторной попытке с нарастающей паузой.
Диспетчеризация и обратное давление
Bot actor раздаёт обновления процессам чатов не дожидаясь ответа, но с ограничением по числу обновлений в работе. Медленный обработчик в одном чате больше не задерживает ни другие чаты, ни следующий запрос за обновлениями. Предел задаётся with_max_in_flight(n); за ним health-проверка начинает отвечать Overloaded, а webhook-адаптеры отдают 503, и Telegram повторит доставку позже.
Обратная сторона той же медали: bot actor следит за каждым процессом, которому передал обновление. Если процесс упал, недоделанное обновление всё равно будет подтверждено, поэтому зависший обработчик не может заклинить поллер навсегда. А само обновление можно не потерять, см. раздел про мёртвые письма.
Изоляция и выгрузка чатов
Каждый чат живёт в своём процессе, отсюда:
- Крэш обработчика одного пользователя не трогает остальных
- Сессия и состояние ожидания принадлежат конкретной паре пользователь-чат
- Сообщения разных пользователей обрабатываются параллельно
- Упавший ChatInstance перезапускает супервизор
Процессы не копятся бесконечно. Экземпляр, в который полчаса ничего не приходило, останавливается (with_chat_idle_timeout, отключается через without_chat_idle_timeout), а после минуты тишины сжимает свою кучу (with_chat_hibernate_after). Практическое следствие: незавершённый Conversation-диалог не переживает выгрузку. Сессия переживает, её перечитают из хранилища, а вот приостановленный wait_*, это живой процесс, а не данные. Нужна устойчивость, бери Flow или Dialog.
Жизненный цикл обновления
- Poller получает пачку обновлений от Telegram
- Каждое проходит pre-router обработчики внутри bot actor
- Обновление уходит в процесс по ключу
{chat_id}:{from_id}, фабрика при необходимости заводит новый - ChatInstance смотрит на своё состояние:
- WAITING: обновление отдаётся ожидающей функции (кроме команд и платёжных запросов, они проваливаются в роутер)
- ROUTING: обновление идёт через middleware и роутер
- Обработчик выполняется и отвечает через
reply.* - Если обработчик вызвал
wait_*, процесс переходит в WAITING
Запуск под своим супервизором
telega.supervised() возвращает описание ребёнка, поэтому дерево бота можно подвесить в дерево приложения. Так выражается и порядок запуска: сначала пул к базе, потом бот.
import gleam/otp/static_supervisor as supervisor
let assert Ok(_) =
supervisor.new(supervisor.RestForOne)
|> supervisor.add(db_pool_child)
|> supervisor.add(
telega.new(api_client)
|> telega.router(bot_router)
|> telega.supervised(),
)
|> supervisor.start
Супервизор не отдаёт данные ребёнка наружу, поэтому сам объект бота (он нужен webhook-адаптеру и ручной остановке) забирают из хука with_on_start.
Мягкая остановка
telega.shutdown(bot)
Поллер перестаёт забирать обновления (а webhook начинает отвечать 503), бот ждёт до with_drain_timeout дообработки того, что уже в работе, выполняет with_on_shutdown и гасит дерево в обратном порядке запуска. telega.with_signal_handlers() вешает это на SIGTERM, то есть на сигнал, который присылает любой нормальный оркестратор при деплое.
Router, маршрутизация обновлений
telega/router, модуль маршрутизации. Роутер регистрирует обработчики, а telega.router подключает его к боту:
import telega/reply
import telega/router
let bot_router =
router.new("my_bot")
|> router.on_command("start", fn(ctx, _command) {
reply.text(ctx, "Привет! /help для помощи.")
})
router.new("name") создаёт именованный роутер, каждый |> router.on_* возвращает новый, чистая цепочка без мутаций.
Приоритет маршрутов
Для каждого обновления роутер пробует категории в этом порядке, побеждает первое совпадение:
- Команды, например
/start - Callback-запросы, нажатия inline-кнопок
- Кастомные маршруты с собственным матчером
- Медиа: фото, видео, голос, аудио, альбомы
- Текстовые маршруты по шаблону
- Специализированные: inline-запросы, опросы, платежи, реакции, события чата
- Fallback, всё, что не совпало
Внутри категории порядок регистрации.
Команды
router.new("my_bot")
|> router.on_command("start", handle_start)
|> router.on_commands(["help", "about"], show_info)
Обработчик получает разобранную update.Command с именем команды и полезной нагрузкой. Слэш в имени необязателен, "start" и "/start" одно и то же. Сопоставление регистронезависимое, поэтому /Start дойдёт до on_command("start", ...), а имя команды заканчивается на первом пробельном символе любого вида: /start\nref=42, это команда start с нагрузкой ref=42.
Текстовые сообщения
// Любой текст, сам текст приходит вторым аргументом
|> router.on_any_text(fn(ctx, text) { reply.text(ctx, "Ты написал: " <> text) })
// Только точное совпадение
|> router.on_text(router.Exact("ping"), fn(ctx, _text) { reply.text(ctx, "pong") })
Шаблоны: Exact, Prefix, Contains, Suffix.
Callback-запросы и типизированные маршруты
Классический способ, шаблон по строке callback data:
router.on_callback(router.Prefix("page:"), handle_pagination)
Но у фабрики callback-данных уже есть знание, как упаковать и разобрать свою нагрузку. on_callback_data заводит маршрут ровно под её payload-ы и отдаёт обработчику разобранное значение:
import telega/keyboard
let page = keyboard.int_callback_data("page")
router.on_callback_data(page, fn(ctx, _query, page_number) {
// page_number это Int, без unpack_callback и без разбора ошибок в обработчике
reply.text(ctx, "Страница " <> int.to_string(page_number))
})
Чужой payload или тот, который фабрика не смогла разобрать, до обработчика просто не доходит: контекст остаётся нетронутым, вместо того чтобы подсунуть тебе 0 вместо номера страницы.
Специализированные маршруты
На каждый оставшийся тип обновления есть свой on_*:
- Inline-режим:
on_inline_query,on_chosen_inline_result - Платежи:
on_shipping_query,on_pre_checkout_query - Опросы:
on_poll,on_poll_answer - Реакции:
on_reaction,on_reaction_emoji,on_reaction_emojis,on_paid_reaction,on_reaction_added,on_reaction_removed,on_reaction_count - События чата:
on_chat_member_updated(изменился статус участника),on_my_chat_member_updated(изменился статус самого бота: заблокировали, добавили в группу, выдали админку),on_chat_join_request,on_chat_boost,on_removed_chat_boost - Сообщения, которые не новые сообщения:
on_edited_message,on_channel_post,on_edited_channel_post,on_business_message - Mini Apps:
on_web_app_data - Платные медиа:
on_paid_media_purchase - Всё остальное:
on_unknown_update, туда попадает неизвестный этой версии вид обновления или тот, чью полезную нагрузку не удалось разобрать. Сырой payload читается черезupdate.raw
Путь разбора обновления в Telega 3 не паникует: незнакомый вид становится UnknownUpdate, а каждое обновление в пачке разбирается отдельно, поэтому одно битое не роняет всю пачку.
Медиа и альбомы
router
|> router.on_photo(handle_photo)
|> router.on_video(handle_video)
|> router.on_voice(handle_voice)
|> router.on_media_group(handle_album)
Альбом Telegram присылает отдельными сообщениями с общим media_group_id. on_media_group срабатывает, только если бот попросил их собрать:
telega.new(api_client)
|> telega.router(bot_router)
|> telega.with_media_group_timeout(1000) // подождать секунду после последнего
Без этой настройки каждое сообщение альбома придёт своим маршрутом on_photo или on_video. Сообщения, приходящие в момент активного wait_*, не собираются никогда: ожидающий обработчик ждёт их по одному.
Фильтры и кастомные маршруты
Для логики, не попадающей во встроенные категории, есть собственный матчер:
import gleam/string
import telega/update
router.on_custom(
matcher: fn(upd) {
case upd {
update.TextUpdate(text: t, ..) -> string.starts_with(t, "https://")
_ -> False
}
},
handler: handle_link,
)
А on_filtered принимает готовые предикаты, которые комбинируются через and, or и not:
router.new("admin_bot")
|> router.on_filtered(router.is_private_chat(), handle_private)
|> router.on_filtered(
router.and([
router.is_text(),
router.from_users([admin1, admin2]),
router.not(router.text_starts_with("/")),
]),
handle_admin_text,
)
Предикаты бывают по типу сообщения (is_text, is_command, has_photo, has_video, has_media, is_media_group, is_callback_query), по содержимому текста (text_equals, text_starts_with, text_contains, command_equals), по пользователю и чату (from_user, from_users, in_chat, from_chats, is_private_chat, is_group_chat, chat_type) и по содержимому сообщения (is_forwarded, is_reply, in_topic, has_entity, via_bot, is_automatic_forward, has_media_spoiler).
Две детали, которые в Telega 3 стали честнее. Фильтры по чату читают настоящий тип чата, а не знак chat_id, поэтому inline-запрос, который случился вообще вне чата, не совпадёт ни с одним из них. А has_entity смотрит и в entities, и в caption_entities, так что фото с ссылкой в подписи совпадает с has_entity("url") наравне с текстом.
from_users и from_chats, это белые списки. Чёрный делается оборачиванием в not:
// Реагировать только в чатах поддержки
router.on_filtered(router.from_chats([support_a, support_b]), handler)
// Реагировать везде, кроме забаненного чата
router.on_filtered(router.not(router.from_chats([banned_chat])), handler)
Роли: только администраторы
Фильтры, это чистые предикаты над обновлением, поэтому в сеть они ходить не могут. А проверка администратор ли пользователь требует вызова getChatMember. Для этого есть модуль telega/roles: он кеширует ответ в ETS (один круг к API слишком дорог, чтобы повторять его на каждое сообщение) и даёт булевы проверки (is_admin, is_owner), совместимые с use гарды (ensure_admin, ensure_owner) и middleware (require_admin, require_owner):
import telega/roles
import telega/router
import telega/reply
let cache = roles.new_cache(ttl_ms: 60_000)
router.new("admin")
|> router.on_command("ban", fn(ctx, _cmd) {
use ctx <- roles.ensure_admin(ctx, cache, on_denied: fn(ctx) {
reply.text(ctx, "Только для администраторов.")
})
// сюда попадают только администраторы и владелец чата
Ok(ctx)
})
Администратор, это админ или владелец; владелец, это создатель чата. При ошибке API проверка закрывается, то есть доступ запрещается. ttl_ms: 0 отключает кеш.
Композиция: лист и дерево
Здесь Telega 3 отличается от второй версии сильнее всего. Раньше композиция возвращала обычный роутер, и на неё можно было продолжать вешать маршруты, которые потом терялись. Теперь типов два, и они делают разную работу.
router.Router, это лист: маршруты, middleware, catch-обработчик, область видимости. Всё, что называется on_*, регистрируется на листе, и telega.router принимает именно лист.
router.RouterTree, это композиция: упорядоченный список листьев, часть из них под фильтром. Своих маршрутов у дерева нет, on_command на дереве просто не компилируется, а принимает дерево telega.router_tree.
Слияние складывает два листа в один плоский лист, при конфликте побеждает первый:
let main = router.merge(admin_router, user_router)
Дерево пробует листья по очереди, и каждый сохраняет свои middleware и свой catch-обработчик. append добавляет лист, который спрашивают всегда, branch добавляет лист под фильтром:
let app =
router.tree()
|> router.branch(router.is_private_chat(), private_router)
|> router.branch(router.is_group_chat(), group_router)
|> router.append(shared_router)
|> router.tree_fallback(handle_unknown)
telega.new(api_client)
|> telega.router_tree(app)
compose(a, b) и compose_many([a, b, c]), это сокращения для дерева из безусловных веток. Ветка, чей фильтр совпал, но у которой нет маршрута под это обновление, пропускается, и очередь доходит до следующей. Маршруты, которые раньше вешали прямо на композицию, теперь кладут в явный завершающий лист:
// /help обработается после того, как private_router и public_router откажутся
let app =
router.compose(private_router, public_router)
|> router.append(router.new("direct") |> router.on_command("help", handle_help))
Настройки, принадлежащие всем веткам сразу, имеют форму для дерева: use_middleware_on_tree и with_catch_handler_on_tree (второй не трогает ветку, у которой свой catch-обработчик уже есть).
Область видимости ограничивает лист предикатом. Лист вне своей области отказывается от обновления, поэтому в дереве очередь доходит до следующей ветки:
import telega/update
let admin =
router.new("admin")
|> router.on_command("ban", handle_ban)
|> router.scope(fn(upd) {
case upd {
update.CommandUpdate(from_id: id, ..) -> is_admin(id)
_ -> False
}
})
Авто-команды и allowed_updates
Роутер и так знает все свои команды и все типы обновлений, которые умеет обрабатывать. Значит, Telegram можно держать в синхроне с ним автоматически.
Регистрируем команды с описанием и включаем with_auto_commands, тогда при старте бот сам вызовет setMyCommands, и команды появятся в меню:
let bot_router =
router.new("my_bot")
|> router.on_command_with_description("start", "Запустить бота", handle_start)
|> router.on_command_with_description("help", "Показать справку", handle_help)
|> router.on_command("secret", handle_secret)
// ^ без описания: маршрутизируется, но в меню не публикуется
telega.new(client)
|> telega.router(bot_router)
|> telega.with_auto_commands()
|> telega.start()
with_auto_allowed_updates() сужает allowed_updates до тех типов, которые роутер реально обрабатывает, и Telegram перестаёт слать остальное. Два подвоха стоит знать заранее.
Первый: в непустой выведенный набор всегда входит callback_query. Обработчик, припаркованный на ожидании нажатия, для вывода невидим, и бот с одними командами в роутере раньше выводил ["message"] и ждал кнопку вечно. Разрешать callback-запросы ничего не стоит: Telegram присылает их только для клавиатур, которые бот сам и нарисовал.
Второй: остальные виды, которых ждёт conversation или flow, всё ещё невидимы, их добавляют руками:
|> telega.with_auto_allowed_updates()
|> telega.with_extra_allowed_updates(["message_reaction"])
Если в роутере есть fallback, кастомный, фильтрованный маршрут или on_unknown_update, сузить набор безопасно нельзя, и вывод честно возвращает пустой список, а Telegram шлёт свой набор по умолчанию. Ручной with_allowed_updates всегда побеждает и отключает вывод целиком.
Локализованные описания команд подключаются через telega_i18n, см. раздел про экосистему.
Reply: ярлыки, сущности, стриминг
Модуль telega/reply отправляет в тот чат, из которого пришло обновление: идентификатор чата и клиент лежат в ctx.
Ярлыки
Большая часть модуля, это тонкая обёртка над соответствующим вызовом API: with_photo, with_poll, with_invoice принимают полную запись параметров и возвращают Result(Message, TelegaError). А вот ярлыки возвращают сам контекст, то есть ровно то, что обработчику и нужно вернуть:
| Ярлык | Что делает |
|---|---|
reply.text(ctx, text) | отправить и вернуть контекст без изменений |
reply.quote(ctx, text) | отправить ответом на входящее сообщение |
reply.remove_keyboard(ctx, text) | отправить и убрать reply-клавиатуру с экрана |
reply.edit_callback_message(ctx, text) | заменить текст сообщения, на котором нажали кнопку |
reply.edit_callback_markup(ctx, text, markup) | то же плюс замена inline-клавиатуры |
reply.answer_toast(ctx, text) | ответить на callback-запрос всплывающей подсказкой |
reply.answer_alert(ctx, text) | ответить модальным окном, которое надо закрыть |
reply.answer_quietly(ctx) | ответить ничем, просто погасить спиннер на кнопке |
Четыре последних требуют callback-запроса в ctx.update и возвращают ошибку, если его нет: молча отправить вместо этого новое сообщение было бы хуже, чем отказаться. И правило, которое стоит записать на лбу: на callback-запрос надо ответить всегда, иначе клиент крутит спиннер на кнопке минуту.
Ярлыки падают с TelegaError, поэтому у роутера, собранного из них, тип ошибки будет TelegaError. Если у бота свой тип ошибки, бери with_text и отображай ошибку сам:
import telega/error
pub type BotError {
Api(error.TelegaError)
Db(pog.QueryError)
}
fn handler(ctx, _cmd) {
use ctx <- error.try(reply.with_text(ctx, "привет"), to: Api)
Ok(ctx)
}
Форматирование остаётся прежним: reply.with_html(ctx:, html:), reply.with_markdown(ctx:, markdown:) и with_markdown_v2.
Сущности вместо режима разметки
Режим разметки делает часть символов особыми, и всё, что напечатал пользователь, приходится экранировать: один пропущенный символ подчёркивания ломает сообщение целиком. Альтернатива, отправить текст как есть, а форматирование описать позиционно:
import telega/format as fmt
let document =
fmt.build()
|> fmt.text("Нашлось: ")
|> fmt.bold_text(whatever_the_user_typed)
|> fmt.to_formatted()
reply.with_entities(ctx, document)
Режим разметки не отправляется вовсе, значит ни один символ в тексте не особый. Тот же билдер умеет и в обычные строки: format.to_html, format.escape_markdown_v2. Смещения и длины считаются в кодовых единицах UTF-16, как требует Bot API, то есть эмодзи вне базовой плоскости считается за два.
Стриминг в одно сообщение
Языковая модель выдаёт токены быстрее, чем Telegram разрешает редактировать сообщение. reply.stream_text копит их и сбрасывает по таймеру:
import gleam/yielder
fn handle_prompt(ctx, prompt) {
use _ <- result.try(reply.stream_text(
ctx,
chunks: llm_tokens(prompt),
every_ms: 700,
))
Ok(ctx)
}
Первый кусок отправляется сразу, дальше сообщение редактируется не чаще раза в every_ms, с курсором в конце, а финальная правка курсор убирает. Четыреста токенов стоят горстку вызовов API, а не четыреста. reply.stream_into пишет в уже существующее сообщение, и это форма, которую хочет настоящий ассистент: сначала ответить, что услышал, потом писать в то же сообщение.
Ниже примерно 500 мс правки в приватном чате выстраиваются в очередь за ограничителем частоты, и анимация начинает дёргаться; 700 мс, хорошее значение по умолчанию.
Полный пример: 07-streaming-bot
Клавиатуры: inline и reply
В Telegram два типа клавиатур, и работают они принципиально по-разному.
Inline-клавиатуры
Inline-клавиатура показывается под конкретным сообщением. Нажатие отправляет callback-запрос, невидимое для пользователя событие: в чате не появляется нового сообщения.
Когда брать: навигация по меню, пагинация, действия без текстового ответа, подтверждения.
Простой путь, собрать список кнопок:
import telega/keyboard
import telega/reply
fn send_menu(ctx) {
let buy = keyboard.string_callback_data("buy")
let assert Ok(coffee) =
keyboard.inline_button(
text: "Кофе, 3 ⭐",
callback_data: keyboard.pack_callback(buy, "coffee"),
)
let assert Ok(tea) =
keyboard.inline_button(
text: "Чай, 2 ⭐",
callback_data: keyboard.pack_callback(buy, "tea"),
)
let markup =
[[coffee], [tea]]
|> keyboard.new_inline
|> keyboard.to_inline_markup
use _ <- try(reply.with_markup(ctx, "Выбирай:", markup))
Ok(ctx)
}
keyboard.inline_button возвращает Result, потому что проверяет ограничение Telegram: callback data не длиннее 64 байт. Слишком длинная нагрузка, это ошибка конфигурации бота, а не пользователя, и увидеть её лучше сразу.
Есть и билдер, если кнопки собираются в цикле:
let assert Ok(kb) =
keyboard.inline_builder()
|> keyboard.inline_text("🌍 Язык", keyboard.pack_callback(lang_cb, "open"))
let assert Ok(kb) =
kb |> keyboard.inline_text("🔔 Уведомления", keyboard.pack_callback(notify_cb, "open"))
let kb = kb |> keyboard.inline_next_row()
let assert Ok(kb) = kb |> keyboard.inline_text("❌ Закрыть", keyboard.pack_callback(close_cb, ""))
let markup = keyboard.inline_build(kb) |> keyboard.inline_to_markup
Функции построения:
keyboard.inline_builder(), создаёт билдерkeyboard.inline_text(builder, text, callback), добавляет кнопку, возвращаетResultkeyboard.string_callback_data(id)иkeyboard.int_callback_data(id), фабрики нагрузкиkeyboard.pack_callback(factory, data), упаковывает значениеkeyboard.inline_next_row(), новая строка кнопокkeyboard.inline_build(builder)иkeyboard.inline_to_markup(kb), завершение
Обрабатывается всё это типизированным маршрутом из раздела про роутер:
router.on_callback_data(buy, fn(ctx, _query, item_id: String) {
use _ <- try(reply.answer_quietly(ctx)) // гасим спиннер
reply.text(ctx, "Оформляю: " <> item_id)
})
Reply-клавиатуры
Reply-клавиатура заменяет системную клавиатуру пользователя. Нажатие отправляет обычное текстовое сообщение, видимое в чате: текст кнопки, это и есть текст сообщения.
Когда брать: анкеты, где ответы должны остаться в истории, быстрый ввод типичных команд, ситуации, где важна прозрачность.
fn ask_confirmation(ctx) {
let markup =
keyboard.builder()
|> keyboard.text("✅ Да")
|> keyboard.text("❌ Нет")
|> keyboard.next_row()
|> keyboard.text("❓ Не уверен")
|> keyboard.build()
|> keyboard.to_markup
use _ <- try(reply.with_markup(ctx, "Подтверди действие:", markup))
Ok(ctx)
}
Обрабатывается как обычный текст: router.on_text(router.Exact("✅ Да"), ...). Убрать такую клавиатуру с экрана можно ярлыком reply.remove_keyboard(ctx, "Готово").
Ключевые отличия
| Аспект | Inline | Reply |
|---|---|---|
| Расположение | под сообщением | вместо системной клавиатуры |
| Что отправляется | callback-запрос, невидимо | текстовое сообщение, видимо |
| Текст и данные | разделены: текст Да, данные confirm_yes | одно и то же |
| Обработка | on_callback_data, on_callback, wait_callback_query | on_text, wait_text |
| Надо ли отвечать | да, обязательно | нет |
Два типа взаимоисключающие: в одном сообщении может быть только один. И при редактировании сообщения тип клавиатуры поменять нельзя.
Полный пример: 04-keyboard-bot
Три слоя памяти бота
Прежде чем разбирать сессии, полезно увидеть всю картину сразу. У бота на Telega 3 три разных места, где живут данные, и путать их дорого.
| Слой | Что хранит | Область | Сохраняется |
|---|---|---|---|
session | состояние пользователя: корзина, шаг, настройки | пара {chat_id}:{from_id} | да, в хранилище |
store | общее состояние: счётчик чата, язык чата, глобальный флаг | чат, пользователь или весь бот | да, в хранилище |
dependencies | сервисы: пул к базе, HTTP-клиент, каталог переводов | весь бот, задаётся при старте | нет, никогда |
Правило простое. Описывает пользователя, значит сессия. Пишут несколько процессов сразу, значит store. Это сервис, который обработчик вызывает, значит dependencies.
Session, состояние пользователя
Каждое сообщение в Telegram, изолированное событие. Без дополнительного механизма бот забывает всё между обновлениями. Сессия, это персональная память под пару пользователь-чат.
Тип сессии
import gleam/option.{type Option, None}
pub type MusicBotSession {
MusicBotSession(
language: String,
favorite_genre: Option(String),
plays_count: Int,
)
}
pub fn default_session() -> MusicBotSession {
MusicBotSession(language: "ru", favorite_genre: None, plays_count: 0)
}
Каждый пользователь получает свою копию. Изменения у одного не задевают другого, это гарантирует изоляция процессов.
Подключение
Сессия подключается шагом telega.session, который идёт до роутера:
import telega
import telega/bot
import telega/router
import telega_httpc
pub fn build_bot(token: String) {
let bot_router =
router.new("music_bot")
|> router.on_command("start", handle_start)
|> router.on_command("lang", handle_change_language)
telega.new(telega_httpc.new(token))
|> telega.session(bot.SessionSettings(
default_session:,
get_session: fn(_key) { Ok(None) },
persist_session: fn(_key, session) { Ok(session) },
))
|> telega.router(bot_router)
|> telega.start()
}
SessionSettings, это три функции: default_session для новых пользователей, get_session для восстановления при создании процесса чата, persist_session для сохранения. В примере выше две последние заглушки, то есть сессия живёт только в памяти процесса.
Боту, которому сессия не нужна, делать не надо ничего: по умолчанию сессия это Nil, и обработчики видят Context(Nil, error, Nil).
Настоящее хранилище одной строкой
Заглушки писать не обязательно. Одно KeyValueStorage обслуживает всё, что бот помнит: сессии, инстансы flow и диалогов, значения store, отложенные задачи, мёртвые письма и окно дедупликации. Выбираешь бэкенд, подключаешь один раз:
import telega
import telega/storage
import telega_storage_sqlite
let assert Ok(Nil) = telega_storage_sqlite.migrate(conn)
let kv = telega_storage_sqlite.new(conn)
telega.new(client)
|> telega.session(storage.session_settings_from_storage(
storage: kv,
default: default_session,
encode: encode_session,
decode: session_decoder(),
))
|> telega.router(bot_router)
|> telega.start()
| Бэкенд | Когда брать |
|---|---|
| никакой, по умолчанию | бот без состояния, или состояние живёт только внутри диалога |
telega/storage/ets | одна нода, состояние можно потерять при рестарте: кеши, окна дедупликации, разработка |
telega_storage_sqlite | одна нода, состояние переживает рестарт. Один файл, никакого сервиса: выбор по умолчанию для малых и средних ботов |
telega_storage_postgres | несколько нод, или данные бота и так лежат в Postgres |
telega_storage_redis | несколько нод и высокая частота записи, потеря при сбросе Redis приемлема |
Чтение и обновление
Сессия доступна как ctx.session в любом обработчике:
import telega/bot.{type Context}
fn handle_stats(ctx: Context(MusicBotSession, Nil, Nil), _command) {
let genre = ctx.session.favorite_genre |> option.unwrap("не выбран")
reply.text(
ctx,
"Прослушано треков: " <> int.to_string(ctx.session.plays_count)
<> "\nЛюбимый жанр: " <> genre,
)
}
Обрати внимание на сигнатуру: Context(MusicBotSession, Nil, Nil). У контекста три типа-параметра: сессия, ошибка и зависимости.
Сессии иммутабельны, поэтому обновление, это возврат новой версии через bot.next_session:
fn handle_play_track(ctx: Context(MusicBotSession, Nil, Nil), _command) {
let updated =
MusicBotSession(..ctx.session, plays_count: ctx.session.plays_count + 1)
use _ <- try(reply.with_text(ctx, "Трек пошёл!"))
bot.next_session(ctx, updated)
}
Ключ, версии и запись только при изменении
Три вещи, которые в Telega 3 стоит настроить осознанно.
Ключ сессии. По умолчанию это пара {chat_id}:{from_id}, то есть у каждого участника группы своя сессия. Иногда нужно наоборот, одна сессия на весь чат:
|> telega.with_session_key(bot.chat_session_key) // одна на чат
|> telega.with_session_key(bot.user_session_key) // одна на пользователя во всех чатах
Побочный, но важный эффект: сессия на чат сериализует записи через один процесс. Это единственный способ получить атомарный счётчик там, где несколько участников пишут одно и то же значение.
Версии. В тот день, когда у типа сессии появится новое поле, старые записи в хранилище перестанут разбираться. Чтобы не обнулить всех пользователей, версионируем с самого начала:
storage.session_settings_from_storage_versioned(
storage: kv,
encode: encode_session,
decode: session_decoder(),
default: fn() { Session(sent: 0) },
version: 1,
migrate: fn(_from, raw) {
// Версия 0, это то, что писала сборка без версий: та же форма без конверта
decode.run(raw, session_decoder()) |> result.replace_error(Nil)
},
)
Что делать, если сессию не прочитать. База может быть недоступна секунду. Поведение выбирается явно: telega.with_session_load_error(bot.ReadOnly) продолжит обрабатывать обновление со значением по умолчанию, но не станет затирать сохранённое; bot.FailUpdate отклонит обновление; bot.UseDefault начнёт с чистого листа.
И одна оптимизация по умолчанию: сессия, которую обработчик не менял, не записывается обратно (bot.PersistOnChange). Старое поведение возвращается через telega.with_session_persistence(bot.PersistAlways).
Когда сессия, а когда нет
| Задача | Решение | Почему |
|---|---|---|
| Язык интерфейса | session | читается в каждом обработчике, меняется редко |
| Счётчик действий пользователя | session | быстрые обновления, потеря не смертельна |
| Счётчик сообщений всего чата | store | пишут все участники, ни в чьей сессии не живёт |
| Глобальный флаг возможности | store | один на бот |
| Пул соединений, каталог переводов | dependencies | сервис, а не данные |
| Форма регистрации | Conversation API | многошаговый диалог с валидацией |
| Незавершённое бронирование | Flow или Dialog | нужна персистентность и навигация назад |
| История заказов | своя база | долгосрочное хранение, свои запросы |
Store, общее состояние
Сессия принадлежит одному процессу и загружается один раз при его старте. Для состояния, которое пишут несколько процессов, это ровно неправильно: счётчик группы, который увеличивает каждый участник, протухнет в тот же миг, когда его изменит кто-то другой.
Поэтому telega/store не кеширует ничего. Каждое чтение идёт в бэкенд, каждая запись сразу возвращается туда же, и именно это делает конкурентных читателей корректными. Цена, круг к хранилищу на каждое обращение.
import gleam/dynamic/decode
import gleam/json
import telega/store
// Один счётчик на чат, общий для всех участников
let counters =
store.chat_data(
storage: kv,
encode: json.int,
decode: decode.int,
default: fn() { 0 },
)
fn handle_message(ctx, _text) {
use total <- result.try(store.update(ctx, counters, fn(n) { n + 1 }))
reply.text(ctx, "сообщений здесь: " <> int.to_string(total))
}
Четыре вида ключей: store.chat_data (по чату), store.user_data (по пользователю во всех чатах), store.global_data(name:, ...) (одно значение на бот), store.custom (свой ключ из обновления). Время жизни задаётся store.with_ttl. Операции: get, set, update, delete, плюс варианты с явным ключом get_at, set_at и так далее.
Store, это обычное значение: собери его при старте, положи в dependencies и раздай обработчикам. Регистрировать в билдере нечего.
Чтение-изменение-запись не атомарно.
store.updateчитает, применяет функцию и пишет обратно; два процесса, делающие это одновременно, могут потерять одно из увеличений. Там, где это важно, веди сессию по ключу чата, тогда все записи пойдут через один процесс. Store оставь для того, что пишут редко или в один поток.
Jobs, отложенная работа
Напоминание через час, ночной дайджест, повтор после того, как отпустит лимит частоты. Задачи бывают двух видов, и разница в том, что происходит при рестарте.
В памяти (run_after, run_every), это замыкание и таймер BEAM. Дёшево, отменяемо, исчезает вместе с VM. Годится для внутренних дел работающего бота: обновить кеш, подчистить таблицу.
Персистентные (persisted, persisted_every), это имя обработчика, чат, полезная нагрузка и время срабатывания, записанные в KeyValueStorage. Планировщик перечитывает их при старте, поэтому напоминание переживёт деплой. Всё, чего ждёт пользователь, должно быть здесь.
import gleam/json
import gleam/time/duration
import gleam/time/timestamp
import telega/jobs
// При старте, после telega.start()
let assert Ok(scheduler) =
jobs.new(bot)
|> jobs.with_name(scheduler_name)
|> jobs.with_storage(kv)
|> jobs.with_handler("reminder", deliver_reminder)
|> jobs.start()
// В обработчике: напомнить через час
jobs.persisted(
scheduler,
id: "reminder:" <> ctx.key,
handler: "reminder",
chat_id: ctx.update.chat_id,
user_id: ctx.update.from_id,
at: timestamp.add(timestamp.system_time(), duration.hours(1)),
payload: json.object([#("text", json.string("встать и размяться"))]),
)
Планировщик стартует после бота (ему нужен работающий экземпляр), а обработчикам он нужен раньше. Решение: зарегистрировать его под именем через jobs.with_name(name) и доставать в обработчике через jobs.from_name(name), положив само имя в dependencies.
Две вещи, которые надо понимать про идентификаторы и гарантии.
id персистентной задачи, это её личность в хранилище. Планирование с тем же id заменяет предыдущую задачу. Для напоминания в девять это ровно то, что нужно, а для очереди независимых напоминаний, нет: добавь в id то, что их различает. jobs.cancel принимает этот же id.
Гарантия, не более одного раза, а не ровно один раз. Задача, чей обработчик упал на середине, не повторяется: из хранилища её уже забрали. Задача, чей контекст не удалось собрать, повторяется несколько раз и потом отбрасывается с ошибкой в логе. А задача, чьё имя обработчика не зарегистрировано, спокойно лежит в хранилище дальше, и деплой, который добавит обработчик, подхватит её при старте.
Персистентная задача выполняется на фоновом контексте (telega.background_context): сессия загружена, зависимости внедрены, ответить и отредактировать сообщение можно. Нельзя одного, wait_*: приостанавливать нечего, процесса чата за этим контекстом нет.
Полный пример: 08-group-bot, сессия плюс два store плюс персистентные напоминания
Внедрение зависимостей
Обработчику почти всегда нужно что-то помимо состояния пользователя: пул соединений к базе, HTTP-клиент к внешнему API, каталог переводов, конфигурация. Это не состояние, это сервисы, общие на весь бот. Глобальные переменные ради них заводить плохо, а складывать в сессию ещё хуже: они утекут в хранилище и сломают сериализацию.
Слот dependencies в контексте типизированный, задаётся один раз при старте и никогда не сериализуется. Это и есть третий тип-параметр контекста:
import telega
pub type Dependencies {
Dependencies(db: Connection, catalog: Catalog)
}
pub fn start(client, db, catalog) {
telega.new(client)
|> telega.dependencies(Dependencies(db:, catalog:))
|> telega.router(bot_router)
|> telega.start()
}
Любой обработчик, шаг flow, продолжение wait_* или middleware читают сервисы прямо из контекста:
import telega/bot.{type Context}
fn my_bookings(ctx: Context(Nil, String, Dependencies), _cmd) {
let bookings = db.list_bookings(ctx.dependencies.db, ctx.update.from_id)
reply.text(ctx, format_bookings(bookings))
}
Есть и функция-аксессор telega.get_dependencies(ctx), если так читается лучше.
Поскольку dependencies, это просто поле, оно протягивается по системе типов: роутер становится Router(session, error, Dependencies), обработчики Context(session, error, Dependencies), и компилятор гарантирует, что все они подключены к одному и тому же набору сервисов.
Историческая ловушка. В Telega 2 была отдельная функция, которая задавала зависимости уже готовому билдеру, и вызов её после роутера молча обнулял роутер: бот запускался без маршрутов и без единой ошибки. В третьей версии этой функции нет, а неправильный порядок ловит компилятор через маркер
Fresh. Если ты переносишь код со второй версии, просто поднимиdependenciesнаверх.
Conversation API, линейные диалоги
Conversation API позволяет писать многошаговый диалог как последовательность операций. Обработчик приостанавливается на каждой функции wait_* и продолжается, когда придёт нужное сообщение.
Базовая идея
import telega
import telega/bot.{type Context}
import telega/reply
fn handle_name_conversation(ctx: Context(Nil, error, Nil), _command) {
use _ <- try(reply.with_text(ctx, "Как тебя зовут?"))
use ctx, name <- telega.wait_text(ctx:, or: None, timeout: None)
use _ <- try(reply.with_text(ctx, "Сколько тебе лет?"))
use ctx, age_str <- telega.wait_text(ctx:, or: None, timeout: None)
reply.text(ctx, "Привет, " <> name <> "! Тебе " <> age_str <> ".")
}
Каждый use ctx, value <- telega.wait_* приостанавливает выполнение: процесс чата переходит в состояние WAITING, а обработчик продолжается со следующим сообщением от этого пользователя. Приостанавливается только этот процесс, остальные чаты обрабатываются как обычно. Думай об этом как об await: он уступает текущую сопрограмму, а не блокирует всё.
Заметь разницу в возвращаемых значениях: reply.with_text отдаёт Result(Message, _), поэтому его связывают через use _ <- try(...), а обновлённый контекст возвращают только функции wait_*.
Функции ожидания
Базовые: wait_any, wait_message, wait_text, wait_hears, wait_command, wait_commands, wait_callback_query, wait_photos, wait_video, wait_voice, wait_audio, wait_for (свой фильтр), wait_filtered.
С валидацией:
// Число с проверкой диапазона
use ctx, age <- telega.wait_number(
ctx:,
min: Some(13),
max: Some(120),
or: Some(bot.HandleText(fn(ctx, _) { reply.text(ctx, "Введи число от 13 до 120") })),
timeout: None,
)
// Почта с проверкой формата
use ctx, email <- telega.wait_email(ctx:, or: None, timeout: None)
// Типобезопасный выбор: сам рисует inline-клавиатуру
use ctx, plan <- telega.wait_choice(
ctx:,
text: "Выбери тариф:",
options: [#("Бесплатный", Free), #("Премиум", Premium)],
or: None,
timeout: Some(60_000),
)
Изменение в Telega 3, которое стоит проверить в своём коде. Параметр
timeoutмеряется в миллисекундах, как и было написано в документации. Во второй версии он ошибочно трактовался как секунды. Написанный тогдаtimeout: Some(60)теперь означает 60 миллисекунд, а не минуту: умножь все свои таймауты ожидания на 1000.
Второе изменение поменьше: wait_choice теперь сам отправляет текст вопроса, для этого у него появился параметр text:. Отдельный reply перед ним больше не нужен.
У wait_callback_query перед or: идёт параметр filter: Option(CallbackQueryFilter). Передай None, чтобы ловить любое нажатие, или фильтр под конкретную клавиатуру:
let assert Ok(filter) = keyboard.filter_inline_keyboard_query(kb)
use ctx, payload, query_id <- telega.wait_callback_query(
ctx:,
filter: Some(filter),
or: None,
timeout: Some(30_000),
)
Обработка неожиданного ввода
Параметр or: покрывает оба вида несовпадения: обновление не того типа (фото вместо текста) и обновление правильного типа, не прошедшее фильтр самого ожидания. После or разговор остаётся в ожидании, чтобы пользователь мог попробовать снова:
import telega/update
use ctx, age <- telega.wait_number(
ctx:,
min: Some(18),
max: Some(100),
or: Some(bot.HandleAll(fn(ctx, upd) {
case upd {
update.TextUpdate(..) -> reply.text(ctx, "Это не число, попробуй ещё раз.")
_ -> reply.text(ctx, "Отправь число или /cancel")
}
})),
timeout: Some(60_000),
)
Кто получает обновление
Три механизма умеют претендовать на одно и то же обновление, поэтому порядок задан жёстко.
- Ожидающее продолжение conversation. Процесс чата смотрит свой
wait_*раньше, чем что-либо маршрутизирует. - Роутер, по приоритету маршрутов.
- Автовозобновление flow и dialog, это обычные маршруты роутера:
apply_to_routerрегистрирует их после твоих, поэтому команда или точный текстовый маршрут выигрывают у ждущего flow.
Из первого пункта есть два исключения, и оба спасают пользователя.
Команда, которую ожидание не просило, проваливается в роутер, поэтому /cancel работает посреди диалога, а само ожидание остаётся взведённым. Обработчик команды, который собирается разговор прекратить, обязан сказать об этом явно:
router.on_command("cancel", fn(ctx, _cmd) {
bot.cancel_conversation_in(ctx)
reply.text(ctx, "Отменено.")
})
Pre-checkout и shipping запросы проваливаются в роутер по той же причине, но с более жёстким сроком: Telegram срывает платёж, если бот не ответил на pre-checkout запрос за десять секунд, а в приватном чате этот запрос приходит в тот же процесс, что и сообщения. Поэтому обработчик может отправить счёт и встать на ожидание успешной оплаты, пока router.on_pre_checkout_query отвечает на запрос, без которого оплаты не будет.
И правило, которое нельзя нарушить: два ожидающих механизма не могут поделить одно обновление. Вызов wait_* изнутри шага flow проглотил бы именно то обновление, которого flow ждёт, поэтому библиотека пишет предупреждение и эмитит событие телеметрии. Внутри flow паркуйся через action.wait, внутри диалога через on_text и on_message.
Когда Conversation API
Бери, когда: линейный диалог на два-пять шагов, валидация с повтором, навигация назад не нужна, потеря при рестарте не страшна.
Не бери, когда: сложное ветвление, нужна кнопка назад, состояние должно пережить перезапуск, кусок диалога переиспользуется. Для этого есть Flow и Dialog.
Полный пример: 03-conversation-bot
Конечные автоматы в ботах
У Conversation API есть потолок: нет навигации назад, состояние теряется при рестарте, логика переходов неявная, куски диалога плохо переиспользуются.
Конечные автоматы решают это. Состояние в момент t зависит от входа и предыдущего состояния:
Зачем выделять состояния явно
Классическая ситуация в коде без автомата:
type UserSession {
UserSession(
is_waiting_name: Bool,
is_waiting_phone: Bool,
is_waiting_email: Bool,
has_confirmed: Bool,
is_cancelled: Bool,
)
}
Пять булевых полей, это тридцать две комбинации, из которых осмысленны пять. Мозг не удержит, компилятор не поможет. А теперь один тип, в котором видны все состояния:
type RegistrationState {
Idle
AwaitingName
AwaitingPhone
AwaitingEmail
Confirming
Completed
Cancelled
}
Сразу понятно, где может находиться регистрация, и компилятор проверит, что все варианты разобраны.
Взрыв состояний и statecharts
Главная боль классических автоматов, взрыв состояний. Добавляешь новый независимый аспект, и число состояний умножается: Valid/Invalid, это два, плюс Enabled/Disabled уже четыре, плюс Dirty/Pristine уже восемь.
В 1987 году Дэвид Харел предложил statecharts: параллельные регионы для независимых аспектов, иерархия вложенных состояний, условия на переходах. Flow API в Telega реализует эти идеи для ботов.
Flow API, персистентные автоматы
telega/flow, это набор модулей для сложных диалогов как автоматов. В отличие от Conversation API, Flow даёт:
- Персистентность: состояние ложится в хранилище после каждого перехода и переживает рестарт
- Навигацию: назад или к произвольному шагу
- Типизированные шаги через свой тип, с конвертерами в строку и обратно
- Композицию: подпотоки, последовательное, условное и параллельное соединение
Создание flow
import telega/flow/builder
import telega/flow/storage
pub type RegistrationStep {
AskName
AskEmail
Done
}
fn step_to_string(step: RegistrationStep) -> String {
case step {
AskName -> "ask_name"
AskEmail -> "ask_email"
Done -> "done"
}
}
fn string_to_step(s: String) -> Result(RegistrationStep, Nil) {
case s {
"ask_name" -> Ok(AskName)
"ask_email" -> Ok(AskEmail)
"done" -> Ok(Done)
_ -> Error(Nil)
}
}
pub fn create_registration_flow(store) {
builder.new("registration", store, step_to_string, string_to_step)
|> builder.add_step(AskName, ask_name_step)
|> builder.add_step(AskEmail, ask_email_step)
|> builder.add_step(Done, done_step)
|> builder.on_complete(fn(ctx, _inst) { reply.text(ctx, "Готово!") })
|> builder.build(initial: AskName)
}
Конвертеры нужны для сериализации состояния. Компилятор проверит, что они покрывают все варианты типа, но не проверит, что для каждого варианта вызван add_step: пропущенный шаг всплывёт в рантайме.
Обработчик шага вызывается дважды
Ключевая идея: шаг, это функция fn(ctx, instance) -> StepResult, и она должна быть переисполняемой, потому что вызывается дважды.
- Первый раз, без ввода: бот задаёт вопрос и говорит
action.wait - Второй раз, когда пользователь ответил: ввод лежит в step data под ключом
"user_input"
import gleam/option.{None, Some}
import telega/flow/action
import telega/flow/instance
import telega/reply
fn ask_name_step(ctx, inst) {
case instance.get_step_data(inst, "user_input") {
Some(name) -> {
let inst = instance.store_data(inst, "name", name)
action.next(ctx, inst, AskEmail)
}
None -> {
let _ = reply.with_text(ctx, "Как тебя зовут?")
action.wait(ctx, inst)
}
}
}
Для кнопок вместо "user_input" читают результат ожидания через instance.get_wait_result(inst). Он возвращает WaitResult: TextInput(value:), BoolCallback(value:), DataCallback(value:), PhotoInput(..), Pending и другие варианты, так шаг подтверждения различает да и нет.
Действия
action.next(ctx, inst, AskEmail) // к следующему шагу
action.back(ctx, inst) // на шаг назад
action.goto(ctx, inst, AskName) // к произвольному шагу
action.wait(ctx, inst) // ждать текстовый ввод
action.wait_callback(ctx, inst) // ждать нажатие кнопки
action.wait_with_timeout(ctx, inst, timeout_ms: 120_000)
action.complete(ctx, inst) // завершить, вызовет on_complete
action.cancel(ctx, inst) // отменить
Шаг, запросивший wait_callback, возобновляется только callback-запросом: текстовое сообщение уйдёт тому flow, который ждёт текст. А если обновление принимают несколько ждущих flow, побеждает тот, чей инстанс обновлялся последним, то есть тот, в котором пользователь сейчас на самом деле находится.
Flow data и step data
У инстанса два хранилища, и их легко перепутать:
- Flow data (
store_data,get_data) переживает все переходы. Сюда собранные данные: имя, почта - Step data (
store_step_data,get_step_data,clear_step_data) очищается на каждом переходе. Сюда временное: счётчик попыток, флаги валидации. Сам ввод пользователя приходит именно сюда, под ключом"user_input"
let inst = instance.store_data(inst, "email", "user@example.com")
let inst = instance.store_step_data(inst, "attempts", "2")
Валидацию делают прямо в шаге: плохой ввод, отправили ошибку и снова action.wait, оставаясь на том же шаге. Со счётчиком попыток в step data после трёх неудач можно вызвать action.cancel.
Хранилище
import telega/flow/storage
// Для разработки: в памяти на ETS, не переживает рестарт VM
let assert Ok(store) = storage.create_ets_storage()
// Для тестов: всё выбрасывает
let store = storage.create_noop_storage()
Для прода flow-хранилище получают из того же общего KeyValueStorage, что и сессии: storage.flow_storage_from_storage(kv), а если брошенные инстансы надо подчищать, storage.flow_storage_from_storage_with_retention(kv, retention_ms: ...).
Свою реализацию пишут через FlowStorage с четырьмя операциями (save, load, delete, list_by_user), а сериализуют инстанс целиком: instance.to_json_string и instance.from_json_string. В JSON лежит schema_version, поэтому запись, сделанная более новой сборкой Telega, честно не разберётся, вместо того чтобы прочитаться наполовину.
Плоская строка из Telega 2 (
instance_to_rowиinstance_from_row) в третьей версии удалена: она теряла историю, стек подпотоков и параллельное состояние. Если твой адаптер хранилища раскладывал инстанс по колонкам, переходи на одну JSON-колонку.
Регистрация в роутере
import telega/flow/registry
import telega/flow/types
let flow_reg =
registry.new_registry()
|> registry.register(types.OnCommand("/register"), registration_flow)
|> registry.register_cancel_command("/cancel")
let bot_router =
router.new("my_bot")
|> router.on_command("help", handle_help)
|> registry.apply_to_router(flow_reg)
apply_to_router сам разводит возобновление для всех типов ввода: текст, callback, фото, видео, голос. Триггеры кроме OnCommand: OnText, OnCallback, OnPhoto, OnAnyText, OnFiltered. Flow можно зарегистрировать и без триггера через register_callable, чтобы запускать программно.
Ветвления, подпотоки, тайм-ауты
- Условные переходы:
builder.add_conditional(from:, condition:, true:, false:)иadd_multi_conditional - Подпотоки: переиспользуемый кусок выносят в отдельный flow и подключают через
builder.add_subflow - Композиция: модуль
flow/composeсоединяет flows последовательно, условно или параллельно - Готовые шаги:
flow/handlerдаётtext_step("Вопрос?", "data_key", NextStep)для самого частого паттерна, аtext_step_withвычисляет текст по контексту, что удобно для локализации
Инстанс в ожидании по умолчанию живёт вечно, для прода задают срок:
builder.new("booking", store, step_to_string, string_to_step)
|> builder.with_ttl(ms: 600_000)
|> builder.on_timeout(fn(ctx, _inst) {
reply.text(ctx, "Сессия истекла, начни заново через /book")
})
|> builder.add_step(AskName, ask_name_step)
|> builder.build(initial: AskName)
Просроченность проверяется лениво, на следующем сообщении пользователя. Для админских задач flows отменяют программно: registry.cancel_user_flows(reg, user_id:, chat_id:) или registry.cancel_flow_instance(reg, flow_id:).
Dialog API, один живой экран
Flow, это автомат, который сам шлёт сообщения. Диалог, это уровень выше: одно живое сообщение, которое перерисовывается на каждое нажатие.
┌─────────────────────────────┐
│ Настройки │ одно сообщение Telegram,
│ язык: ru, имя: Алиса │ редактируется на месте
├─────────────────────────────┤ при каждом нажатии
│ [ RU ] [ EN ] │
│ [ Имя ] │
│ [ Готово ] │
└─────────────────────────────┘
Диалог, это набор окон; окно, это чистая функция отрисовки плюс обработчики событий. Движок сам держит одно сообщение и правит его, сам строит и разбирает callback data кнопок, сам даёт навигацию назад, а состояние сохраняет через то же flow-хранилище. Диалоги компилируются в flow, поэтому наследуют персистентность, срок жизни и команду отмены.
Какой слой брать
| Conversation | Flow | Dialog | |
|---|---|---|---|
| Модель интерфейса | бот шлёт сообщения | ты сам шлёшь и правишь | одно живое сообщение, правка на месте |
| Переживает рестарт | нет | да | да |
| Навигация назад | нет | вручную, действием back | встроена |
| Callback data | вручную | вручную | генерируется и проверяется, 64 байта |
| Списки и пагинация | нет | нет | виджеты: пейджер, radio, multiselect, календарь |
| Композиция | вложенные вызовы | подпотоки | под-диалоги с типизированным результатом |
| Брать, когда | быстрый вопрос-ответ | ветвистый процесс со своими сообщениями | экран: настройки, мастер, каталог |
Отдельно: модуль telega/menu_builder из второй версии устарел. Окно диалога с виджетом select или paged_select рисует то же меню, но помнит своё состояние, историю и следит за бюджетом callback data. Признак, что тебе пора в диалоги, ровно один: ты пишешь editMessageText руками и разбираешь payload кнопок внутри flow.
Первый диалог
import gleam/option.{None, Some}
import telega/dialog
import telega/dialog/types.{ActionButton, RenderedWindow}
import telega/flow/registry as flow_registry
import telega/format
pub type Settings {
Settings(lang: String, name: String)
}
fn render_menu(settings: Settings, _ctx) -> RenderedWindow {
RenderedWindow(
text: format.build()
|> format.bold_text("Настройки")
|> format.line_break()
|> format.text("язык: " <> settings.lang <> ", имя: " <> settings.name)
|> format.to_formatted(),
buttons: [
[ActionButton("RU", "ru"), ActionButton("EN", "en")],
[ActionButton("Имя", "name")],
[ActionButton("Готово", "done")],
],
media: None,
)
}
fn handle_menu(settings: Settings, event: types.ActionEvent, _ctx) {
case event.action_id {
"ru" -> Ok(types.Stay(Settings(..settings, lang: "ru")))
"en" -> Ok(types.Stay(Settings(..settings, lang: "en")))
"name" -> Ok(types.Goto("name", settings))
"done" -> Ok(types.Done(settings))
_ -> Ok(types.Stay(settings))
}
}
fn render_name(_settings, _ctx) -> RenderedWindow {
RenderedWindow(
text: format.build() |> format.text("Как тебя зовут?") |> format.to_formatted(),
buttons: [[ActionButton("‹ Назад", "back")]],
media: None,
)
}
pub fn create_settings_dialog(store) {
let assert Ok(settings) =
dialog.new(
id: "settings",
storage: store,
initial_state: fn() { Settings(lang: "ru", name: "") },
encode_state: encode_settings,
decode_state: decode_settings,
)
|> dialog.window(id: "menu", render: render_menu, on_action: handle_menu)
|> dialog.window_with_input(
id: "name",
render: render_name,
on_action: fn(settings, event: types.ActionEvent, _ctx) {
case event.action_id {
"back" -> Ok(types.Back(settings))
_ -> Ok(types.Stay(settings))
}
},
on_text: fn(settings, text, _ctx) {
Ok(types.Goto("menu", Settings(..settings, name: text)))
},
)
|> dialog.initial("menu")
|> dialog.on_done(fn(settings, ctx) { save_settings(settings, ctx) })
|> dialog.build()
settings
}
Подключается через тот же реестр flow:
let reg =
flow_registry.new_registry()
|> dialog.attach_on_command("settings", settings_dialog)
|> flow_registry.register_cancel_command("cancel")
let bot_router = flow_registry.apply_to_router(bot_router, reg)
Вот и вся петля: /settings присылает меню, каждое нажатие правит то же самое сообщение, Done выполняет on_done и убирает клавиатуру. build() возвращает Result, потому что проверяет идентификаторы окон заранее: дубли, несуществующее начальное окно, зарезервированные символы, бюджет callback data.
Действия окна
Обработчик, это чистая функция из старого состояния в действие с новым состоянием, никакой скрытой мутации:
| Действие | Что делает |
|---|---|
Stay(state) | сохранить состояние и перерисовать текущее окно |
Goto(window_id, state) | сохранить и перейти к окну, с записью в историю |
Back(state) | сохранить и вернуться на шаг назад по истории |
Done(state) | сохранить, выполнить on_done, убрать клавиатуру, удалить инстанс |
StartSub(sub_id, args, state) | запустить под-диалог |
Shown(mode, action) | выполнить действие, показав итоговое окно заданным способом |
Кнопки бывают четырёх видов: ActionButton(text, action_id), ActionArgButton(text, action_id, arg) для элементов списка, UrlButton и WebAppButton как есть, и NoopButton(text) для некликабельных заголовков и счётчиков. Обработчик получает уже разобранное событие ActionEvent(action_id, arg), а не сырую строку.
Данные, которых нет в состоянии
Окно иногда показывает то, что в его состояние не входит: открытые заказы пользователя, сегодняшнюю цену, строку, которую мог поменять другой обработчик. window_with_data делит это надвое: load читает мир, render остаётся чистой функцией от пары состояние-данные.
|> dialog.window_with_data(
id: "orders",
load: fn(_state, ctx) { db.open_orders(ctx.dependencies.db, ctx.update.from_id) },
render: fn(_state, orders, _ctx) { order_list(orders) },
on_action:,
)
load выполняется на каждую отрисовку окна, и именно это не даёт экрану протухнуть. Значит, держи его в одно дешёвое чтение, а дорогое клади в состояние или в зависимости.
Ввод текста
Окно, добавленное через window_with_input, принимает ещё и текст. Окна без on_text вежливо глотают текст: сообщение поглощается, окно перерисовывается, и пользователь всегда видит текущий экран. Диалог модален к тексту, но не к командам: команды по-прежнему доходят до роутера, поэтому команду отмены надо зарегистрировать обязательно.
Ошибки валидации живут в состоянии, а не в отдельных сообщениях:
fn handle_date_text(state, text, _ctx) {
case validate_date(text) {
Ok(Nil) -> Ok(types.Goto("time", State(..state, date: text, error: None)))
Error(key) -> Ok(types.Stay(State(..state, error: Some(key))))
}
}
Виджеты
Виджет, это управляемый кусок клавиатуры из telega/dialog/widget. Движок рисует его ряды после собственных кнопок окна, сам обрабатывает его нажатия и сохраняет его состояние вместе с инстансом диалога, так что выбор и позиция страницы переживают рестарт.
import telega/dialog/widget.{SelectItem}
|> dialog.window_with_widgets(
id: "prefs",
render: render_prefs,
on_action: handle_prefs,
widgets: [
widget.radio(id: "zone", items: zone_items, default: Some("hall")),
widget.multiselect(id: "extras", items: extra_items, min: 0, max: 3, done: "confirm"),
],
)
| Виджет | Поведение | Как прочитать значение |
|---|---|---|
pager(id:, page_size:, total:) | ряд со стрелками и номером страницы | widget.current_page |
select(id:, items:, columns:, on_selected:) | одноразовая сетка выбора | ничего не хранит, вызывает on_selected |
radio(id:, items:, default:) | один выбор, с отметками | widget.radio_value |
multiselect(id:, items:, min:, max:, done:) | галочки, кнопка готово внутри границ | widget.multiselect_values |
paged_select(...) | выбор плюс пейджер | widget.current_page |
counter(id:, min:, max:, step:, initial:) | ряд минус, число, плюс | widget.counter_value(default:) |
calendar(id:, from:, to:, on_picked:) | сетка месяца с листанием | on_picked получает дату |
list_group(id:, items:, actions:, on_action:) | ряд кнопок на каждый элемент списка | on_action получает пару идентификаторов |
Идентификаторы элементов едут в callback data, поэтому держи их короткими: лимит в 64 байта проверяется на каждой отрисовке. Состояние виджета читается из любой отрисовки или обработчика:
let zone =
dialog.widget_store(ctx, window_id: "prefs", widget_id: "zone")
|> widget.radio_value
|> option.unwrap("hall")
Обрати внимание: default у radio, это только визуальная преднастройка. radio_value остаётся None, пока пользователь не нажал, поэтому то же значение по умолчанию применяй и при чтении.
Под-диалоги
Под-диалог, это переиспользуемый диалог, прикреплённый к родителю, со своим типом состояния. Он забирает то же живое сообщение, а когда заканчивается, окно родителя получает его результат.
// Переиспользуемый диалог адреса со своим состоянием
pub type AddressState {
AddressState(city: String, street: String)
}
|> dialog.subdialog(sub: address_dialog, init: fn(_parent_state, _args) {
AddressState(city: "", street: "")
})
|> dialog.on_sub_result(window: "confirm", sub: address_dialog, handler:
fn(state, address: AddressState, _ctx) {
Ok(types.Stay(BookingState(
..state,
address: Some(address.city <> ", " <> address.street),
)))
})
Запускается из любого обработчика окна: "address" -> Ok(types.StartSub("delivery_address", dict.new(), state)).
Правила, которые стоит знать:
- Обработчик получает финальное состояние под-диалога в его собственном типе: передача самого под-диалога в
on_sub_result, это то, что позволяет декодировать его его же кодеком. Ни одной стороне не надо писать кодек руками и договариваться о ключах словаря - Под-диалог правит то же живое сообщение; идентификатор сообщения, ожидания, срок жизни и персистентность общие с родителем
Backна первом окне под-диалога отменяет его: родительское окно просто перерисовывается,on_sub_resultне вызывается- Под-диалог не может уйти
Gotoв окна родителя, его навигация в своём пространстве имён - Вложенность транзитивная: диалог со своими под-диалогами можно прикрепить как под-диалог,
build()разворачивает всё дерево в одно пространство имён. Ограничивает глубину не рекурсия, а те же 64 байта callback data, иbuild()меряет каждое имя
Режимы показа, медиа и обновление снаружи
dialog.with_show_mode(builder, mode) решает, когда диалог заменяет живое сообщение вместо правки. EditLive, по умолчанию, всегда правит, и это правильно для нажатий. Диалогу с вводом текста нужен ResendOnUserMessage: после того как пользователь напечатал, отредактированное окно оказывается выше его сообщения, и его легко не заметить, поэтому окно удаляют и присылают заново, ниже. Режим сужается дважды: with_window_show_mode на одно окно и types.Shown(mode, action) на одно действие.
Окно может нести медиа: положи в RenderedWindow.media file_id или ссылку, и текст окна станет подписью. Bot API не умеет превращать текстовое сообщение в медийное и обратно, поэтому движок отслеживает вид живого сообщения и сам выбирает стратегию: правку, либо удаление и новую отправку.
А задача, которая закончилась где-то в стороне (экспорт, вебхук платежа, ночной тик), может перерисовать то, на что пользователь сейчас смотрит:
let assert Ok(ctx) = telega.background_context(bot, chat_id:, user_id:)
let assert Ok(#(_ctx, refreshed)) = dialog.refresh(ctx, reg, dialog_id: "export")
refresh заново выполняет render текущего окна с текущим состоянием, поэтому всё, что он читает, перечитывается. Открыть диалог он не может: пользователю без открытого диалога вернётся False и ни одного вызова API.
Устаревшие кнопки и несколько диалогов сразу
Нажатия на устаревшие сообщения ловятся дважды: по идентификатору окна в payload и по сравнению идентификатора сообщения с отслеживаемым живым. И то, и другое отвечает на callback-запрос текстом про устаревшее меню и ничего не делает. Нажатия в уже завершённом диалоге ловит fallback, который attach регистрирует сам, поэтому вручную писать его в роутере не нужно.
Два открытых диалога у одного пользователя, это два инстанса, два живых сообщения и два префикса callback data, и каждый возобновляется на своих нажатиях. Общее у них одно, дорога назад: dialog.start, вызванный изнутри обработчика другого диалога, запоминает, кто кого открыл, и завершившийся диалог может вернуть экран через dialog.return_to_caller.
Под-диалог берут, когда второй экран, это шаг первого (адрес внутри бронирования). Два диалога берут, когда это две вещи, которые пользователь может держать открытыми одновременно (каталог и его настройки).
Полный пример: 06-restaurant-booking, все возможности диалогов сразу
Middleware
Middleware оборачивает обработчики, то есть функции fn(Context, Data) -> Result(Context, Error). Это обычный декоратор:
import telega/router
let bot_router =
router.new("my_bot")
|> router.use_middleware(fn(handler) {
fn(ctx, data) {
io.println("Обработка началась")
let result = handler(ctx, data)
io.println("Обработка закончилась")
result
}
})
|> router.on_command("start", handle_start)
Первый добавленный middleware самый внешний: он выполняется первым и видит результат обработчика последним.
let bot_router =
router.new("my_bot")
|> router.use_middleware(router.with_logging) // внешний, выполняется первым
|> router.use_middleware(auth_middleware)
|> router.use_middleware(rate_limit) // внутренний, ближе всех к обработчику
|> router.on_command("ban", handle_ban)
Часть обёрток есть в коробке: with_logging, with_filter, with_recovery и with_rate_limit. Флуд-контроль на пользователя подключается одной строкой:
router.new("my_bot")
|> router.use_middleware(router.with_rate_limit(
limit: 5,
window_ms: 10_000,
on_limit: fn(ctx) { reply.text(ctx, "Слишком часто, подожди немного.") },
))
|> router.on_any_text(handle_text)
Pre-router middleware
Middleware роутера срабатывает после того, как обновление доставлено в процесс чата и загружена сессия. Для сквозных вещей вроде антиспама, аналитики и дедупликации это поздно и дорого: процесс уже создан.
Pre-router обработчик регистрируется через telega.use_pre_handler и выполняется один раз на обновление внутри bot actor, до маршрутизации и до создания процесса чата. Он может отбросить обновление целиком:
import telega
import telega/bot
telega.new(client)
|> telega.use_pre_handler(fn(pre: bot.PreContext(deps)) {
case is_banned(pre.update.chat_id) {
True -> bot.Stop // отбросить до маршрутизации
False -> bot.proceed() // пропустить дальше
}
})
|> telega.router(bot_router)
Pre-обработчики выполняются по порядку регистрации, первый bot.Stop обрывает остальные и роутер. PreContext несёт update, config, dependencies, bot_info и аннотации, оставленные предыдущими, но не сессию: её ещё не загружали. И поскольку все они выполняются последовательно в одном процессе, логика прочитал, потом записал не гоняется на конкурентных обновлениях.
Аннотации
Pre-обработчик может передать вниз факты об этом обновлении, вернув bot.Continue(annotations:) вместо bot.proceed(). Аннотации от нескольких обработчиков сливаются, а в обработчике читаются через bot.annotation:
import gleam/dict
import gleam/dynamic
import gleam/dynamic/decode
telega.new(client)
|> telega.use_pre_handler(fn(pre: bot.PreContext(deps)) {
bot.Continue(annotations: dict.from_list([
#("locale", dynamic.string(resolve_locale(pre.update))),
]))
})
// дальше в любом обработчике
let locale =
bot.annotation(ctx, "locale", decode.string) |> result.unwrap("ru")
Аннотации живут одно обновление и никогда не сохраняются. Долгоживущие сервисы, это dependencies, состояние пользователя, это сессия.
Защита от дублей
Telegram повторно доставляет обновление с тем же update_id, если не получил 200 вовремя: при медленном ответе, редеплое или сетевом сбое. Для неидемпотентных команд (выставить счёт, списать звёзды) это двойное выполнение. Модуль telega/idempotency даёт готовый pre-router middleware, который запоминает каждый update_id в хранилище на заданное окно и отбрасывает дубли:
import telega/idempotency
import telega/storage/ets
let assert Ok(store) = ets.new(name: "telega_dedup")
telega.new(client)
|> telega.webhook(url:, path:, secret_token:)
|> telega.use_pre_handler(idempotency.deduplicate(storage: store, ttl_ms: 3_600_000))
|> telega.router(bot_router)
Для нескольких нод или переживания рестарта берут персистентное хранилище. При ошибке хранилища обновление пропускается: обработать дважды можно пережить, потерять настоящее обновление, нет.
Обработка ошибок
В Gleam нет исключений, всё идёт через Result.
Уровни
Ошибки отправки. Каждая функция reply.* возвращает Result(_, TelegaError). В примерах мы пишем let assert Ok(_) для краткости, в проде обрабатывай явно или ставь middleware восстановления.
Ошибки обработчиков. Вернул Error, ошибка уходит в catch-обработчик роутера (router.with_catch_handler), если он задан, иначе логируется. Процесс не падает.
Крэши процессов. Обработчик паникует, процесс чата перезапускает супервизор. Сессия перечитывается из хранилища, состояние ожидания теряется, бот продолжает работать. Само обновление можно сохранить, см. мёртвые письма.
Классификация ошибок Telegram
Голый текст ошибки от Telegram, это не то, на чём стоит строить логику. error.classify превращает его в понятный тип:
import telega/error
case reply.with_text(ctx, text) {
Ok(_) -> Ok(ctx)
Error(err) -> case error.classify(err) {
// Пользователь заблокировал бота: чат остаётся валидным, он может разблокировать
error.BotBlocked -> mark_inactive(ctx)
// Группа стала супергруппой, у неё новый идентификатор
error.ChatMigrated(new_chat_id:) -> resend_to(new_chat_id)
// Флуд-контроль: Telegram сам сказал, сколько ждать
error.TooManyRequests(retry_after:) -> schedule_retry(retry_after)
// Правка ничего не изменила, это успех
error.MessageNotModified -> Ok(ctx)
_ -> Error(err)
}
}
Виды: BotBlocked, BotKicked, UserDeactivated, ChatNotFound, ChatWriteForbidden, MessageNotModified, MessageNotFound, MessageCantBeEdited, MessageTooLong, TooManyRequests(retry_after:), ChatMigrated(new_chat_id:), Unauthorized и Other. Параметры самого Telegram доступны и напрямую: error.retry_after, error.migrate_to_chat_id.
Стратегии
// 1. assert, для прототипа
fn handle_start(ctx, _cmd) {
let assert Ok(_) = reply.with_text(ctx, "Привет!")
Ok(ctx)
}
// 2. ярлык, когда тип ошибки бота это TelegaError
fn handle_start(ctx, _cmd) {
reply.text(ctx, "Привет!")
}
// 3. свой тип ошибки, через error.try
fn handle_start(ctx, _cmd) {
use ctx <- error.try(reply.with_text(ctx, "Привет!"), to: Api)
Ok(ctx)
}
// 4. middleware восстановления, глобально
router.new("my_bot") |> router.use_middleware(router.with_recovery)
Повторы на стороне клиента
Клиент сам решает, что повторять. Политика настраивается явно, потому что повторять всё опасно: повторённая отправка сообщения, это второе сообщение.
import telega/client
let api =
telega_httpc.new(token)
|> client.set_retry_policy(client.RetryPolicy(
..client.default_retry_policy(),
// Повторять пятисотые и обрывы связи только у методов, которые ничего
// не создают. Это и есть значение по умолчанию.
retry_on_server_errors: client.OnlyIdempotent,
retry_on_transport_errors: client.OnlyIdempotent,
))
|> client.set_max_retry_attempts(3)
|> client.set_max_retry_delay(60_000)
Варианты повтора: Never, OnlyIdempotent, Always. По умолчанию политика такая: четыре попытки, первая пауза в секунду и дальше с удвоением, разброс паузы включён, чтобы флот ботов не вернулся в один и тот же момент, а сон после 429 не длиннее минуты. Дольше этого ответ с ошибкой просто возвращается вызывающему, потому что сон блокирует процесс, который сделал вызов.
Отдельно настраивается темп запросов: telega_httpc.new_with_default_limits(token:) создаёт клиента с документированными лимитами Telegram, то есть 30 запросов в секунду в целом, один в секунду в приватный чат, двадцать в минуту в группу. Запросы за обновлениями очередь обходят.
Наблюдаемость и эксплуатация
Всё, что выше, это логика бота. Ниже то, без чего бот не живёт на сервере.
Health и перегрузка
telega.health(bot) спрашивает состояние у самого процесса бота, поэтому упавший или заклинивший бот честно ответит Unavailable, а не отдаст протухший снимок. В этом весь смысл проверки здоровья.
pub type Health {
Healthy(in_flight: Int, chat_instances: Int)
Draining(in_flight: Int, chat_instances: Int)
Overloaded(in_flight: Int, chat_instances: Int, max_in_flight: Int)
Unavailable
}
Webhook-адаптеры приносят готовый эндпоинт:
fn handle_request(bot, req) {
use <- telega_wisp.handle_health(telega: bot, req:, path: telega_wisp.default_health_path)
use <- telega_wisp.handle_bot(telega: bot, req:)
wisp.not_found()
}
GET /healthz отвечает 200 и телом с числом обновлений в работе, когда бот обслуживает, и 503 в остальных случаях. Используй это как проверку готовности (вывести из ротации), а не живости: Draining и Overloaded, это здоровые состояния работающего бота, и перезапускать его на них ровно неправильно.
telega.with_max_in_flight(n) задаёт предел, за которым бот признаёт перегрузку. Значения по умолчанию нет: без этого вызова перегрузка не сообщается никогда, а очередь растёт без границ. Поллингу такой предел не нужен, воркер и так не забирает новое, пока не разберёт старое.
Структурные логи
telega.log_context вешает идентификаторы обновления на logger как метаданные процесса, поэтому каждая строка, написанная внутри, включая строки из библиотек, которые про Telega ничего не знают, несёт chat_id, from_id, update_id и ключ сессии:
fn handler(ctx, _cmd) {
use ctx <- telega.log_context(ctx, "checkout")
telega.log_info(ctx, "начинаем")
reply.text(ctx, "Секунду...")
}
Дальше эти поля забирает шаблон форматтера Erlang или JSON-форматтер, и логи становятся пригодными для поиска.
Телеметрия
Каждая стадия обработки эмитит событие telemetry. Два, которые стоит вывести на график первыми:
import telega/telemetry
telemetry.attach_many(
id: "prom",
events: [
["telega", "update", "stop"], // длительность обработки обновления
["telega", "api_call", "stop"], // длительность и статус вызова Bot API
],
handler: handle_event,
)
В Telega 3 у события update.stop появилось поле route, то есть какой маршрут забрал обновление, и router, то есть какая ветка дерева. Это превращает плоское время обработки в честное время по каждой команде, и стоит оно ноль: роутер и так это знает. Внутри обработчика то же значение доступно как router.matched_route(ctx).
Обработчики телеметрии выполняются синхронно в том же процессе, поэтому держи их в одно обновление счётчика, а тяжёлое пересылай своему процессу.
Мёртвые письма
Обработчик, который паникует, уносит с собой процесс чата. Бот подтвердит поллеру обновление, которое процесс не доделал, поэтому ничего не зависнет, но само обновление пропадёт, а вместе с ним улики.
Дай боту очередь, и оно сохранится:
import telega/storage
import telega/storage/ets
let assert Ok(kv) = ets.new(name: "bot")
telega.new(client)
|> telega.router(bot_router)
|> telega.with_dead_letters(storage.dead_letters_from_storage(
storage: kv,
retention_ms: Some(7 * 24 * 60 * 60 * 1000),
))
|> telega.start()
Записи лежат под ключом с идентификатором обновления и содержат сырой JSON и причину падения. Читаются через telega.dead_letters, прогоняются обратно через бота через telega.replay_dead_letters после починки бага, забываются через telega.drop_dead_letter. Хранилище тут должно быть персистентным: ETS умирает вместе с нодой, ровно в тот момент, когда улики и нужны.
Расширенные возможности
Ядра выше хватит для большинства ботов. Дальше короткий обзор, чтобы знать, куда смотреть.
Inline-режим
Пользователь набирает @твой_бот запрос в любом чате и получает выпадающий список. Маршрут router.on_inline_query, ответ собирается билдером telega/inline_mode с пагинацией:
import telega/inline_mode
fn handle_inline(ctx, query: types.InlineQuery) {
let #(page, next_offset) =
catalog.search(query.query)
|> inline_mode.paginate(offset: query.offset, page_size: 10)
let answer =
page
|> list.fold(inline_mode.new(), fn(builder, item) {
inline_mode.article_described(
builder,
id: item.id,
title: item.title,
text: item.description,
description: Some(item.description),
)
})
|> inline_mode.with_cache_time(300)
|> inline_mode.maybe_next_offset(next_offset)
use _ <- try(inline_mode.answer(answer, ctx, query.id))
Ok(ctx)
}
Telegram разрешает максимум 50 результатов на ответ. Какой результат пользователь выбрал, приходит отдельным маршрутом on_chosen_inline_result.
Платежи и Telegram Stars
Цифровые товары продаются за звёзды, и для них не нужен платёжный провайдер вообще:
import telega/payments
let invoice =
payments.stars_invoice(
title: "Премиум-подписка",
description: "Месяц без рекламы",
payload: "premium_month", // вернётся в pre-checkout и в успешной оплате
amount: 100,
)
use _ <- try(payments.send(invoice, ctx))
Дальше Telegram присылает pre-checkout запрос, и вот тут внимательно: платёж срывается, если бот не ответил за десять секунд. Здесь настоящий магазин проверяет остаток, цену, которую сам назвал, и существование заказа:
fn pre_checkout_handler(ctx, query: types.PreCheckoutQuery) {
case catalog.find(query.invoice_payload) {
Ok(item) if item.stars == query.total_amount ->
payments.answer_pre_checkout_ok(ctx, query)
Ok(_) ->
payments.answer_pre_checkout_error(ctx, query, "Цена изменилась, попробуй заново.")
Error(Nil) ->
payments.answer_pre_checkout_error(ctx, query, "Этого товара больше нет.")
}
}
Успешная оплата приходит служебным сообщением, а её telegram_payment_charge_id, это единственный способ потом сделать возврат. Сохрани его.
Полный пример: 10-inline-and-payments
Реакции
import telega/reactions
let _ = reactions.react_emoji(ctx, "🔥")
let changes = reactions.get_changes(update)
Есть готовые константы (reactions.fire, reactions.heart, reactions.thumbs_up), react_many для нескольких сразу и счётчики через get_counts и get_top_reactions. Маршруты: on_reaction, on_reaction_emojis([...]), on_reaction_count.
Медиагруппы
import telega/media_group
let album =
media_group.new()
|> media_group.add_photo_url_with_caption("https://example.com/1.jpg", "Первое")
|> media_group.add_photo_url_with_caption("https://example.com/2.jpg", "Второе")
|> media_group.build()
let _ = reply.with_media_group(ctx:, media: album)
Входящие альбомы собираются в одно обновление, если включён telega.with_media_group_timeout(ms), и ловятся маршрутом on_media_group.
Интернационализация
Локализация выносится в пакет telega_i18n: каталоги в TOML или JSON, middleware определения языка, интерполяция и плюрализация по правилам CLDR. Описания команд для меню Telegram тоже локализуются: telega_i18n.with_command_translations(catalog, prefix: "commands.") публикует своё setMyCommands на каждый язык каталога. Диалоги переводятся тем же способом: render получает контекст, а служебные надписи движка (кнопка готово, стрелки пейджера, отметки) задаются через dialog.with_labels.
Тестирование
Telega несёт свой набор инструментов в модулях telega/testing/*, никакого настоящего соединения с Telegram не нужно.
Conversation DSL
import telega/testing/conversation
pub fn start_command_test() {
conversation.conversation_test()
|> conversation.send("/start")
|> conversation.expect_reply("Привет!")
|> conversation.run(build_router(), fn() { Nil })
}
conversation_test()создаёт пустую цепочкуsend(text)симулирует сообщение от пользователяexpect_reply(text)ждёт точный ответ,expect_reply_containing(part)подстроку,expect_keyboard(buttons:)inline-клавиатуру с нужными кнопкамиrun(router, session_factory)прогоняет всё это
Многошаговые диалоги проверяются той же цепочкой, потому что под капотом run поднимает окружение, симулирующее процесс чата с его автоматом:
pub fn register_flow_test() {
conversation.conversation_test()
|> conversation.send("/register")
|> conversation.expect_reply_containing("зовут")
|> conversation.send("Алиса")
|> conversation.expect_reply_containing("Алиса")
|> conversation.run(register_router(), fn() { Nil })
}
Когда обработчик ходит во внешний API, его подменяют mock-клиентом из telega/testing/mock и запускают через run_with_mock. А бот с внедрёнными зависимостями тестируется через run_with_dependencies(router, session, deps), куда прокидывают mock-базу и тестовый каталог переводов. Отдельный обработчик изолируют через telega/testing/context: context.context_with_dependencies(session:, dependencies:) собирает контекст руками.
Снимки диалогов
Диалоги задуманы под снимочное тестирование, и уровней тут три.
Первый, чистые кадры окон. render(state, ctx) не имеет эффектов, поэтому любое состояние снимается без сети:
render_confirm(filled_state(), ctx)
|> testing_render.window_frame
|> birdie.snap(title: "booking:confirm:frame_ru")
Второй, транскрипт движка. Драйвер telega/testing/dialog прогоняет скомпилированный flow с mock-клиентом, а снимается вся последовательность видимых вызовов API:
import telega/testing/dialog as testing_dialog
let #(client, calls) = testing_dialog.text_client()
let driver =
testing_dialog.driver(flow:, client:, dialog_id: "settings")
|> testing_dialog.with_chat(chat_id: 42)
testing_dialog.start(driver, command: "/settings")
testing_dialog.press(driver, data: "dlg:settings:menu:lang")
testing_dialog.send_text(driver, text: "Алиса")
mock.get_calls(calls) |> testing_render.calls_transcript |> birdie.snap(...)
Драйвер подменяет автовозобновление реестра, то есть проходит ровно тот же код, что и настоящее обновление.
Третий, пути ошибок. Ответы Telegram скриптуются через mock.stateful_client, и снимается восстановление после сообщение не изменилось или сообщение для правки не найдено.
А нулевым уровнем идёт карта целиком: telega/testing/graph.of_dialog(dialog:, ctx:) обходит диалог и выгружает все окна и переходы в Graphviz DOT или Mermaid, включая кнопки, навигацию виджетов и вход-выход под-диалогов. Он читает те же чистые функции, что и тесты, поэтому сети не требует, а его вывод детерминирован настолько, что сам годится в снимок как регрессионный тест навигации.
Чистая логика
Не забывай про самое дешёвое: чистые функции (разбор команд, форматирование, переходы автомата) тестируются обычным gleeunit без всякого набора Telega.
import gleeunit/should
pub fn format_task_list_test() {
format_task_list([
Task(text: "Купить молоко", done: False),
Task(text: "Написать тесты", done: True),
])
|> should.equal("Ваши задачи:\n1. ☐ Купить молоко\n2. ✅ Написать тесты")
}
Главный приём тот же, что и везде: выноси логику из обработчиков. Разбор, валидация и форматирование в чистых функциях, обработчик тонкий, только вызовы reply и wait.
Деплой
Релиз
Gleam компилируется в Erlang, поэтому бот едет либо как проект, который запускают gleam run, либо как релиз OTP, которому на машине не нужен вообще никакой инструментарий. В прод хочется второе:
gleam export erlang-shipment
./build/erlang-shipment/entrypoint.sh run
Релизу нужна одна и та же мажорная версия OTP при сборке и запуске, поэтому собирай его внутри того образа, который деплоишь, а не на ноутбуке.
systemd
Long polling не требует входящего порта, и это самый простой вариант под systemd:
[Service]
Type=exec
User=mybot
WorkingDirectory=/opt/mybot
Environment=BOT_TOKEN=...
ExecStart=/opt/mybot/entrypoint.sh run
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=30
TimeoutStopSec должен быть заметно больше, чем таймаут дренажа у бота, а сам бот собран с telega.with_signal_handlers(), иначе SIGTERM просто убьёт VM.
Docker
Собираем в образе с Gleam, запускаем на тонком образе с Erlang: компилятор в рантайме не нужен.
FROM ghcr.io/gleam-lang/gleam:v1.18.1-erlang-alpine AS build
WORKDIR /app
COPY gleam.toml manifest.toml ./
RUN gleam deps download
COPY src ./src
RUN gleam export erlang-shipment
FROM erlang:27-alpine
RUN adduser -D bot
WORKDIR /app
COPY --from=build /app/build/erlang-shipment ./
USER bot
ENTRYPOINT ["./entrypoint.sh"]
CMD ["run"]
Две детали, на которых спотыкаются. STOPSIGNAL по умолчанию SIGTERM, и это то, что нужно, только docker stop --timeout должен быть больше таймаута дренажа. И ENTRYPOINT пиши в exec-форме, как выше: в shell-форме между Docker и VM встаёт sh, а он сигналы не пробрасывает.
Мягкий деплой без потерь
Порядок, которого хочет деплой:
- Приходит SIGTERM
- Бот перестаёт принимать: поллер не забирает новое, webhook отвечает
503, и Telegram повторит доставку после деплоя - Обновления в работе доделываются, но не дольше таймаута дренажа
with_on_shutdownотпускает то, чем бот владел: пулы, файлы, регистрации- VM выходит
Все таймауты в цепочке должны быть упорядочены: таймаут дренажа меньше, чем TimeoutStopSec у systemd, kill_timeout у облака или terminationGracePeriodSeconds у Kubernetes. Иначе оркестратор убьёт VM посреди дренажа.
И честно про то, что переживает рестарт: сессии ровно настолько, насколько их переживает хранилище (ETS умирает вместе с нодой). Conversation не переживает никогда, приостановленное продолжение, это живой процесс, а не данные. Flow и Dialog переживают, потому что их состояние, это данные.
Мониторинг
Бот работает на BEAM, значит доступны обычные инструменты: observer для процессов и памяти, logger для логов, телеметрия для метрик. Плюс здоровье через /healthz и мёртвые письма для разбора падений, оба разобраны выше.
Полный пример: 09-webhook-wisp, здоровье, дедупликация, предел нагрузки и дренаж в одном боте
Миграция с Telega 2 на Telega 3
Если у тебя уже есть бот на второй версии, менять придётся только код, который собирает бота. Обработчики, роутеры, сессии, flows и диалоги не тронуты.
| Telega 2 | Telega 3 |
|---|---|
telega.new_for_polling(api_client:) | telega.new(client) |
telega.new(api_client:, url:, webhook_path:, secret_token:) | telega.new(client) плюс telega.webhook(url:, path:, secret_token:) |
telega.new_for_polling_with_dependencies(...) | telega.new(client) плюс telega.dependencies(deps) |
telega.with_dependencies(deps) | telega.dependencies(deps) |
telega.with_router(r) | telega.router(r) |
telega.with_router_tree(t) | telega.router_tree(t) |
telega.with_session_settings(s) | telega.session(s) |
telega.with_nil_session() | удалить вызов, Nil, это поведение по умолчанию |
telega.init(), init_for_polling(), init_for_polling_nil_session() | telega.start() |
telega.supervised_for_polling() | telega.supervised() |
telega.with_polling_config(...) | telega.polling(polling.PollingSettings(..polling.default_settings(), timeout:, limit:)) |
telega.with_chat_config(...) | with_chat_restart_tolerance(intensity:, period:) плюс with_chat_init_timeout(ms) |
любой telega.set_* | одноимённый telega.with_* |
Чек-лист на один проход по коду:
- Заменить конструктор, добавить
telega.webhook(...), если бот на вебхуке with_router,with_session_settings,with_dependencies, это теперьrouter,session,dependencies- Поднять
dependenciesиsessionвышеrouter - Все
init*заменить наstart - Все
set_*заменить наwith_* - Добавить четвёртый тип-параметр всем своим помощникам, которые упоминают
TelegaBuilder - Умножить на 1000 все таймауты в
wait_*: раньше их трактовали как секунды, теперь это миллисекунды, как и было в документации - Добавить
text:в вызовыwait_choice - Если композиция роутеров возвращалась в
Routerи на неё вешались маршруты, вынести эти маршруты в отдельный лист и добавить его черезrouter.append - Если адаптер flow-хранилища раскладывал инстанс по колонкам через
instance_to_row, перейти наinstance.to_json_string - Убрать ветку
error.NoSessionSettingsErrorиз разборов ошибок, её больше нет
Упражнения
Первые пять упражнений, это чистые функции: их можно написать и прогнать без бота и без токена. Последние пять требуют роутера и проверяются через telega/testing/conversation.
Упражнение A.1 (Лёгкое): Парсинг команд бота
pub type BotCommand {
CmdStart
CmdHelp
CmdList
CmdAdd(title: String)
CmdDone(index: Int)
CmdUnknown(text: String)
}
pub fn parse_command(text: String) -> BotCommand {
todo
}
Разбери текст сообщения:
"/start", этоCmdStart"/help", этоCmdHelp"/list", этоCmdList"/add Buy milk", этоCmdAdd("Buy milk")"/done 3", этоCmdDone(3)"/done abc", этоCmdUnknown("/done abc"), потому что число не разобралось- всё остальное, это
CmdUnknown(text)
Подсказка: case string.trim(text) { "/add " <> title -> CmdAdd(title) ... }, int.parse(n) для проверки числа.
Упражнение A.2 (Лёгкое): Форматирование одной задачи
pub type Task {
Task(text: String, done: Bool)
}
pub fn format_task(t: Task) -> String {
todo
}
Task("Buy milk", False), это"☐ Buy milk"Task("Write tests", True), это"✅ Write tests"
Упражнение A.3 (Лёгкое): Форматирование списка задач
pub fn format_task_list(tasks: List(Task)) -> String {
todo
}
[], это"Список задач пуст. Добавьте: /add <задача>"- непустой список, это
"Ваши задачи:\n1. ☐ Buy milk\n2. ✅ Write tests"
Подсказка: list.index_map, string.join.
Упражнение A.4 (Среднее): Автомат многошагового диалога
pub type ConvState {
Idle
AwaitingTitle
}
pub fn conversation_step(state: ConvState, input: String) -> #(ConvState, String) {
todo
}
Переходы:
Idleплюс"/add", это#(AwaitingTitle, "Введите название задачи:")Idleплюс"/help", это#(Idle, "Доступные команды:\n/list, список задач\n/add, добавить задачу\n/help, помощь")Idleплюс другое, это#(Idle, "Не понимаю. /help, список команд.")AwaitingTitleплюс название, это#(Idle, "✅ Задача ... добавлена!")
Подсказка: case state, string.trim(input) { Idle, "/add" -> ... }.
Упражнение A.5 (Среднее): Полная диспетчеризация команд
pub fn dispatch(cmd: BotCommand, tasks: List(Task)) -> #(List(Task), String) {
todo
}
Обработай все команды:
CmdStart, это приветствие и подсказка про/helpCmdHelp, это список командCmdList, это форматированный списокCmdAdd(title), это список с новой задачей в конце и подтверждениеCmdDone(n), это задача с номеромnпомечена выполненной; если такой нет, сообщение об этомCmdUnknown(t), это ответ, что команда непонятна
Подсказка: используй format_task_list из A.3.
Упражнение A.6 (Среднее): Роутер приветствий
import telega/error.{type TelegaError}
import telega/router.{type Router}
pub fn build_greeting_router() -> Router(Nil, TelegaError, Nil) {
todo
}
Роутер с двумя командами: /start отвечает так, что в ответе есть слово Привет, а /help так, что в ответе есть /start.
Подсказка: router.new("greeting") |> router.on_command("start", fn(ctx, _cmd) { reply.text(ctx, "...") }). Обрати внимание на три типа-параметра у Router: сессия, ошибка, зависимости.
Упражнение A.7 (Среднее): Эхо-роутер
pub fn build_echo_router() -> Router(Nil, TelegaError, Nil) {
todo
}
Повторяет любой текст обратно с префиксом. "hello", это "Эхо: hello".
Подсказка: router.on_any_text(fn(ctx, text) { reply.text(ctx, "Эхо: " <> text) }).
Упражнение A.8 (Среднее): Роутер с многошаговым диалогом
pub fn build_register_router() -> Router(Nil, TelegaError, Nil) {
todo
}
/register, бот спрашивает, как тебя зовут- пользователь отвечает, бот приветствует по имени
Подсказка: use ctx, name <- telega.wait_text(ctx:, or: None, timeout: None). Помни, что timeout в миллисекундах.
Упражнение A.9 (Сложное): Роутер с inline-клавиатурой
pub fn build_menu_router() -> Router(Nil, TelegaError, Nil) {
todo
}
/menu отправляет сообщение с inline-кнопками Список и Добавить, а нажатие на первую отвечает содержимым списка. Тест проверяет клавиатуру через conversation.expect_keyboard(buttons: ["Список", "Добавить"]).
Подсказка: собери фабрику через keyboard.string_callback_data("menu"), кнопки через keyboard.inline_button(text:, callback_data:), а обработчик повесь типизированным маршрутом router.on_callback_data. Не забудь погасить спиннер через reply.answer_quietly.
Упражнение A.10 (Среднее): Дерево роутеров
pub fn build_app_tree() -> router.RouterTree(Nil, TelegaError, Nil) {
todo
}
Собери два листа и соедини их деревом:
admin_router:/banотвечает, что пользователь заблокированuser_router:/startотвечает приветствием
Проверь, что маршрут, не найденный в первой ветке, доходит до второй, и добавь дереву tree_fallback.
Подсказка: router.tree() |> router.append(admin) |> router.append(user) |> router.tree_fallback(...). Подключается такое дерево через telega.router_tree, а не telega.router.
Итоги
Мы построили Telegram-бота на Telega 3, который собирает воедино концепции всей серии:
- Типобезопасность: Gleam ловит ошибки на этапе компиляции, включая неправильный порядок сборки бота
- Билдер: один конструктор, режим доставки шагом, один терминал, маркер состояния вместо тихих граблей
- Router и RouterTree: лист с маршрутами и отдельный тип для композиции, фильтры, типизированные callback-маршруты
- Три слоя памяти: сессия для пользователя, store для общего, dependencies для сервисов
- Jobs: отложенная и повторяющаяся работа, переживающая рестарт
- Conversation API: линейные диалоги через
wait_*, с провалом команд в роутер - Flow API: персистентные автоматы с навигацией, ветвлениями и подпотоками
- Dialog API: одно живое сообщение, виджеты, под-диалоги с типизированным результатом
- Middleware: сквозная логика, pre-router обработчики, аннотации, защита от дублей и rate limit
- Ошибки: классификация ответов Telegram и явная политика повторов у клиента
- Эксплуатация: здоровье, предел нагрузки, структурные логи, телеметрия с маршрутом, мёртвые письма, мягкий дренаж
- Дерево супервизоров: изоляция по паре пользователь-чат, выгрузка простаивающих чатов, запуск под своим супервизором (глава 10)
- Result и use: обработка ошибок без исключений (глава 5)
Бот на Gleam работает на BEAM и наследует всю его отказоустойчивость: крэш обработчика одного сообщения не убивает сервер. Каждая пара пользователь-чат живёт в изолированном процессе, Conversation делает код диалогов линейным, Flow добавляет персистентность, а Dialog превращает набор сообщений в один экран, который пользователь листает.
Экосистема Telega
Ядро telega не привязано ни к HTTP-клиенту, ни к хранилищу: нужное подключается отдельными пакетами. Все они выпускаются одной версией с ядром, то есть на момент написания урока это 3.0.0.
| Пакет | Зачем |
|---|---|
telega_httpc | HTTP-клиент поверх Erlang httpc |
telega_hackney | HTTP-клиент поверх hackney |
telega_wisp | webhook-адаптер для Wisp: эндпоинт, проверка секрета, health |
telega_mist | минимальный webhook-адаптер прямо поверх mist |
telega_storage_postgres | хранилище в PostgreSQL |
telega_storage_sqlite | хранилище в SQLite, один файл без сервиса |
telega_storage_redis | хранилище в Redis или Valkey |
telega_webapp | Telegram Mini Apps: проверка initData |
telega_i18n | интернационализация: каталоги, middleware языка, плюрализация |
Ресурсы
- HexDocs, telega
- Telega, GitHub
- Telega, примеры от эхо-бота до вебхука в проде
- Telega, гайд по роутеру
- Telega, гайд по диалогам
- Telega, гайд по Flow
- Telega, эксплуатация и деплой
- Telega, миграция на третью версию
- Telegram Bot API
домашка