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-safeParsed Telegram types with nested struct resolution
Long-pollingBuilt-in Hotline.Poller with offset tracking, 409/429 handling
WebhooksHotline.Webhook Plug with secret token verification
Bot behaviouruse Hotline.Bot for quick PubSub-driven bots
Bot DSLDeclarative command and on macros for routing updates
Conversation flowsDeclarative DSL for multi-step conversations with validation and branching
Access controlRestrict bots to specific user IDs via allow or allowed_ids
StreamingLazy Stream.resource for IEx exploration
BroadwayOptional Hotline.BroadwayProducer for pipeline processing
Code generatormix hotline.gen generates types and methods from the official API spec
TelemetryRequest and update events out of the box
Native JSONUses 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"}
]
end

Requires 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

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
end

Dispatch 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:

ContextVariables
command blocksupdate, state, chat_id, args
on blocksupdate, 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 count

Conversation 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
end

handle_input/3 return values control the flow:

ReturnEffect
{:next, store: %{k: v}}Merge data and advance to next step
:nextAdvance 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
:cancelCancel 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
end

Inline 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)}}
end

Webhooks

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:

ExampleDescription
dsl_bot.exsDeclarative bot using command and on macros
echo_bot.exsEchoes back whatever the user sends
greeter_bot.exsHandles /start, /help, /ping, /whoami commands
flow_bot.exsMulti-step flows: registration, feedback, and settings
stream_logger.exsLogs incoming updates to the console via streaming
broadway_pipeline.exsProcess updates through a Broadway pipeline

Run any example:

HOTLINE_TOKEN="your-bot-token" mix run examples/echo_bot.exs

License

MIT