Hotline
Telegram Bot API client and framework for Elixir.
Hotline gives you everything you need to build Telegram bots in Elixir — from quick IEx exploration to production-ready supervised bots with long-polling, webhooks, and Broadway pipelines.
Features
| Type-safe | Parsed Telegram types with nested struct resolution |
| Long-polling |
Built-in Hotline.Poller with offset tracking, 409/429 handling |
| Webhooks | Hotline.Webhook Plug with secret token verification |
| Bot behaviour | use Hotline.Bot for quick PubSub-driven bots |
| Bot DSL |
Declarative command and on macros for routing updates |
| Conversation flows | Declarative DSL for multi-step conversations with validation and branching |
| Access control |
Restrict bots to specific user IDs via allow or allowed_ids |
| Streaming |
Lazy Stream.resource for IEx exploration |
| Broadway |
Optional Hotline.BroadwayProducer for pipeline processing |
| Code generator | mix hotline.gen generates types and methods from the official API spec |
| Telemetry | Request and update events out of the box |
| Native JSON |
Uses Elixir 1.18+ built-in JSON module — no Jason dependency |
Installation
Add hotline to your dependencies in mix.exs:
def deps do
[
{:hotline, "~> 0.1.0"}
]
endRequires Elixir ~> 1.18.
Configuration
Three ways to configure, in priority order:
# 1. Runtime options (highest priority)
Hotline.get_me(token: "your-bot-token")
# 2. Application environment
# config/config.exs
config :hotline,
token: "your-bot-token"
# 3. System environment variables
# export HOTLINE_TOKEN="your-bot-token"Quick Start
HOTLINE_TOKEN="your-bot-token" iex -S mix# Verify your bot
iex> {:ok, me} = Hotline.get_me()
{:ok, %Hotline.Types.User{first_name: "MyBot", ...}}
# Find your chat_id — send a message to your bot in Telegram, then:
iex> [update] = Hotline.stream() |> Enum.take(1)
iex> chat_id = update.message.chat.id
7644580464
# Send a message
iex> Hotline.send_message(%{chat_id: chat_id, text: "Hello from Hotline!"})
{:ok, %Hotline.Types.Message{...}}Building a Bot
Using the Bot DSL (Recommended)
The Bot DSL provides declarative macros for defining command handlers and update-type handlers:
defmodule MyBot do
use Hotline.Bot
command "/start" do
Hotline.send_message(%{chat_id: chat_id, text: "Welcome! Try /help"})
end
command "/ping" do
Hotline.send_message(%{chat_id: chat_id, text: "Pong!"})
end
command "/echo" do
text = if args == "", do: "Usage: /echo <text>", else: args
Hotline.send_message(%{chat_id: chat_id, text: text})
end
on :message do
Hotline.send_message(%{chat_id: chat_id, text: "Unknown command. Try /help"})
end
on :callback_query do
Hotline.answer_callback_query(%{callback_query_id: callback_query.id})
end
endDispatch priority:command handlers are checked first, then on type handlers, then any manual handle_update/2 fallback. Handlers that return {:noreply, new_state} propagate the new state; any other return defaults to {:noreply, state}.
Available bindings:
| Context | Variables |
|---|---|
command blocks | update, state, chat_id, args |
on blocks | update, state, chat_id, + type variable (e.g. message, callback_query) |
Commands automatically handle @botname suffixes (e.g. /start@mybot matches /start).
Manual Approach
For full control, implement handle_update/2 directly:
defmodule MyBot do
use Hotline.Bot
@impl Hotline.Bot
def handle_update(%{message: %{text: "/start", chat: %{id: chat_id}}}, state) do
Hotline.send_message(%{chat_id: chat_id, text: "Welcome! Try /help"})
{:noreply, state}
end
def handle_update(_update, state) do
{:noreply, state}
end
end
You can also combine both — DSL handlers run first, unmatched updates fall through to handle_update/2.
Starting a Bot
Add the poller and bot to your supervision tree:
children = [
{Hotline.Poller, []},
{MyBot, []}
]
Supervisor.start_link(children, strategy: :one_for_one)Restricting Access
Restrict at the module level with allow, at runtime with allowed_ids, or both:
# Declarative (compile-time)
defmodule MyBot do
use Hotline.Bot
allow [7644580464, 123456789]
# Or resolve from application config:
# allow {:config, :my_bot_allowed_ids}
command "/start" do
Hotline.send_message(%{chat_id: chat_id, text: "Hello!"})
end
end
# Runtime
{MyBot, allowed_ids: [7644580464]}
# Everyone (default — omit allow and allowed_ids)
{MyBot, []}Compile-time and runtime IDs are merged. Updates from non-allowed users are silently dropped.
Chat Registry
Hotline can automatically track all chats your bot interacts with, persisted across restarts via DETS:
children = [
{Hotline.Poller, []},
{Hotline.ChatRegistry, dets_path: "priv/chats.dets"},
{MyBot, []}
]Or configure globally:
config :hotline,
chat_registry_path: "priv/chats.dets"Then query known chats anytime:
Hotline.ChatRegistry.list() # all known chats
Hotline.ChatRegistry.get(7644580464) # lookup by chat_id
Hotline.ChatRegistry.count() # total countConversation Flows
Build multi-step conversations with the Flow DSL. Define steps declaratively, handle input with pattern matching, and let the Engine manage state per chat.
Defining a Flow
defmodule MyBot.Flows.Registration do
use Hotline.Flow
step :name, prompt: "What's your name?"
step :email, prompt: fn ctx -> "Thanks #{ctx.data.name}! What's your email?" end
step :confirm,
prompt: fn ctx -> "Confirm? Name: #{ctx.data.name}" end,
keyboard: [[%{text: "Yes", callback_data: "yes"}, %{text: "No", callback_data: "no"}]]
@impl true
def handle_input(:name, %{message: %{text: name}}, _ctx) when byte_size(name) >= 2 do
{:next, store: %{name: name}}
end
def handle_input(:name, _, _ctx), do: {:retry, "Name must be at least 2 characters."}
def handle_input(:email, %{message: %{text: email}}, _ctx) do
{:next, store: %{email: email}}
end
def handle_input(:confirm, %{callback_query: %{data: "yes"}}, _ctx), do: :done
def handle_input(:confirm, %{callback_query: %{data: "no"}}, _ctx), do: {:goto, :name, reset: true}
def handle_input(:confirm, _, _ctx), do: {:retry, "Use the buttons."}
@impl true
def on_done(ctx) do
Hotline.send_message(%{chat_id: ctx.chat_id, text: "Registered: #{ctx.data.name}"})
end
endhandle_input/3 return values control the flow:
| Return | Effect |
|---|---|
{:next, store: %{k: v}} | Merge data and advance to next step |
:next | Advance without storing data |
{:goto, :step} | Jump to a named step |
{:goto, :step, reset: true} | Jump and clear accumulated data |
{:retry, "message"} | Stay on current step, send error message |
:done / {:done, result} | Complete the flow |
:cancel | Cancel the flow |
Running Flows
Add Hotline.Flow.Engine to your supervision tree and trigger flows from your bot:
children = [
{Hotline.Poller, []},
{Hotline.Flow.Engine, []},
{MyBot, []}
]defmodule MyBot do
use Hotline.Bot
command "/register" do
Hotline.Flow.Engine.start_flow(chat_id, MyBot.Flows.Registration)
end
command "/cancel" do
Hotline.Flow.Engine.cancel_flow(chat_id)
end
on :message do
unless Hotline.Flow.Engine.handles_update?(update) do
Hotline.send_message(%{chat_id: chat_id, text: "Try /register or /help"})
end
end
endInline Keyboards in Flows
Pass keyboard: to any step to send inline buttons with the prompt:
step :rating,
prompt: "Rate your experience:",
keyboard: [
[%{text: "1", callback_data: "1"}, %{text: "2", callback_data: "2"},
%{text: "3", callback_data: "3"}, %{text: "4", callback_data: "4"},
%{text: "5", callback_data: "5"}]
]
def handle_input(:rating, %{callback_query: %{data: rating}}, _ctx) do
{:next, store: %{rating: String.to_integer(rating)}}
endWebhooks
Use Hotline.Webhook as a Plug, or deploy standalone with Bandit:
children = [
{Bandit, plug: Hotline.Webhook.Router, port: 4000},
{MyBot, []}
]With secret token verification:
config :hotline,
webhook_secret: "your-secret-token"Sending Files
# From a file path
Hotline.send_photo(%{chat_id: chat_id, photo: {:file, "/path/to/photo.jpg"}})
# From binary content
Hotline.send_document(%{chat_id: chat_id, document: {:file_content, pdf_binary, "report.pdf"}})Code Generator
Generate all Telegram API types and methods from the official spec:
mix hotline.gen
mix format
This creates full type modules in lib/hotline/types/ and a Hotline.GeneratedAPI module with every API method, complete with typespecs and docs.
Examples
See the examples/ directory:
| Example | Description |
|---|---|
dsl_bot.exs |
Declarative bot using command and on macros |
echo_bot.exs | Echoes back whatever the user sends |
greeter_bot.exs |
Handles /start, /help, /ping, /whoami commands |
flow_bot.exs | Multi-step flows: registration, feedback, and settings |
stream_logger.exs | Logs incoming updates to the console via streaming |
broadway_pipeline.exs | Process updates through a Broadway pipeline |
Run any example:
HOTLINE_TOKEN="your-bot-token" mix run examples/echo_bot.exs