Elixir library for building Telegram bots. Provides a simple interface for handling messages, callbacks, and inline queries with automatic polling.
I decided to create this library because I couldn't find anything in the existing Elixir ecosystem that I liked. Maybe I just didn't search well enough, but still.
What makes this library different? I like the macro-based implementation, similar to how GenServer works. It feels like the right approach for this kind of library, and I think others might appreciate it too.
Add the bot to your application's supervision tree:
defmodule MyApp.Application do
def start(_type, _args) do
children = [MyBot]
Supervisor.start_link(children, strategy: :one_for_one)
end
endCreate a bot module:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(message, ctx) do
# Handle incoming messages
end
def handle_callback(callback, ctx) do
# Handle callback queries
:ok
end
endAdd your bot token to config/runtime.exs:
import Config
config :telegram_ex,
my_bot: System.fetch_env!("MY_BOT_TELEGRAM_TOKEN")These handlers receive the incoming update and a context map (ctx). The context carries the bot token, FSM state, and is used as a pipeline accumulator for builders.
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{text: "/start", chat: chat}, ctx) do
ctx
|> Message.text("Welcome")
|> Message.send(chat["id"])
end
def handle_callback(%{data: "ping", message: %{chat: chat}} = callback, ctx) do
ctx
|> Message.text("pong")
|> Message.answer_callback_query(callback)
|> Message.send(chat["id"])
end
endUse this style when the handler does not need to remember anything between updates.
defstate/2 is used when you want handlers to be selected by the current FSM state, with the state injected into pattern matching automatically. The second argument of the handler is ctx, which contains :state, :data, and :token.
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{text: "/start", chat: chat}, ctx) do
ctx
|> Message.text("Welcome")
|> Message.send(chat["id"])
{:transition, :started, %{step: 1}}
end
defstate :started do
def handle_message(%{text: text, chat: chat}, ctx) do
ctx
|> Message.text("You said: #{text}")
|> Message.send(chat["id"])
{:stay, Map.put(ctx.data, :last_message, text)}
end
end
endHandlers can return:
:ok- keep the current state and data unchanged{:stay, data}- keep the current state and replace stored data{:transition, state}- change the current state and keep existing data{:transition, state, data}- change the current state and replace stored data{:error, reason}- log a handler error
You can interact with the FSM directly to read or manipulate state programmatically:
alias TelegramEx.FSM
# Get current state and data for a user
{state, data} = FSM.get_state(:my_bot, chat_id)
# Set state only (keeps existing data)
FSM.set_state(:my_bot, chat_id, :waiting_input)
# Set state and replace data
FSM.set_state(:my_bot, chat_id, :waiting_input, %{retries: 0})
# Reset state (removes stored entry)
FSM.reset_state(:my_bot, chat_id)Messages are sent using the TelegramEx.Builder.Message module. All builders follow a pipeline pattern, accepting ctx as the first argument:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Message.text("Hello!")
|> Message.send(chat["id"])
end
endMessage.text(ctx, text)- Create a text messageMessage.text(ctx, text, parse_mode)- Create a text message with parse mode (e.g., "Markdown", "HTML")Message.inline_keyboard(ctx, keyboard)- Add inline keyboardMessage.reply_keyboard(ctx, keyboard, opts)- Add reply keyboard with optionsMessage.remove_keyboard(ctx)- Remove custom keyboardMessage.silent(ctx)- Send without notificationMessage.answer_callback_query(ctx, callback)- Answer callback queryMessage.send(ctx, chat_id)- Send the message
Use TelegramEx.Builder.Photo to send images:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Photo.path("/path/to/image.jpg")
|> Photo.caption("Here's a photo!")
|> Photo.send(chat["id"])
end
endPhoto.url(ctx, url)- Send photo from URLPhoto.path(ctx, path)- Send photo from local file pathPhoto.caption(ctx, caption)- Add caption to photoPhoto.caption(ctx, caption, parse_mode)- Add caption with parse modePhoto.silent(ctx)- Send without notificationPhoto.send(ctx, chat_id)- Send the photo
Use TelegramEx.Builder.Document to send files:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Document.path("/path/to/file.pdf")
|> Document.caption("Here's the document")
|> Document.send(chat["id"])
end
endDocument.url(ctx, url)- Send document from URLDocument.path(ctx, path)- Send document from local file pathDocument.caption(ctx, caption)- Add caption to documentDocument.caption(ctx, caption, parse_mode)- Add caption with parse modeDocument.silent(ctx)- Send without notificationDocument.send(ctx, chat_id)- Send the document
Use TelegramEx.Builder.Sticker to send stickers:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Sticker.id("CAACAgIAAxkBA...")
|> Sticker.send(chat["id"])
end
endSticker.id(ctx, file_id)- Send sticker by Telegram file IDSticker.url(ctx, url)- Send sticker from URLSticker.path(ctx, path)- Send sticker from local file pathSticker.silent(ctx)- Send without notificationSticker.send(ctx, chat_id)- Send the sticker
Use TelegramEx.Builder.Video to send videos:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Video.path("/path/to/video.mp4")
|> Video.duration(120)
|> Video.cover_path("/path/to/cover.jpg")
|> Video.send(chat["id"])
end
endVideo.id(ctx, file_id)- Send video by Telegram file IDVideo.url(ctx, url)- Send video from URLVideo.path(ctx, path)- Send video from local file pathVideo.duration(ctx, seconds)- Set video durationVideo.cover_path(ctx, path)- Set cover image from local fileVideo.cover_url(ctx, url)- Set cover image from URLVideo.silent(ctx)- Send without notificationVideo.send(ctx, chat_id)- Send the video
Use TelegramEx.Builder.Location to send geo coordinates:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Location.coordinates(55.7558, 37.6173)
|> Location.send(chat["id"])
end
endLocation.coordinates(ctx, latitude, longitude)- Set geo coordinatesLocation.send(ctx, chat_id)- Send the location
Use TelegramEx.Builder.Contact to send contacts:
defmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{chat: chat}, ctx) do
ctx
|> Contact.contact("John", "+1234567890")
|> Contact.send(chat["id"])
end
endContact.contact(ctx, name, phone)- Set first name and phone numberContact.contact(ctx, first_name, last_name, phone)- Set first name, last name, and phone numberContact.silent(ctx)- Send without notificationContact.send(ctx, chat_id)- Send the contact
Inline Keyboard:
def handle_message(%{chat: chat}, ctx) do
keyboard = [[
%{text: "Button 1", callback_data: "btn_1"},
%{text: "Button 2", callback_data: "btn_2"}
]]
ctx
|> Message.text("Choose an option:", "Markdown")
|> Message.inline_keyboard(keyboard)
|> Message.send(chat["id"])
endReply Keyboard:
def handle_message(%{chat: chat}, ctx) do
keyboard = [["/help", "/settings"], ["Contact"]]
ctx
|> Message.text("Use the buttons below:")
|> Message.reply_keyboard(keyboard, resize_keyboard: true, one_time_keyboard: true)
|> Message.send(chat["id"])
endReply Keyboard Options:
resize_keyboard: true- Request clients to resize the keyboardone_time_keyboard: true- Hide keyboard after first use
When a user presses an inline keyboard button, handle_callback/2 is called with a %TelegramEx.Types.CallbackQuery{} struct and ctx:
def handle_callback(%{data: "btn_1"} = callback, ctx) do
# Handle button 1 press
end
def handle_callback(%{data: "btn_2"} = callback, ctx) do
# Handle button 2 press
endTo show an alert or update the user after a callback:
def handle_callback(%{data: data, message: %{chat: chat}} = callback, ctx) do
ctx
|> Message.text("Processed: #{data}")
|> Message.answer_callback_query(callback)
|> Message.send(chat["id"])
endCallback Query Structure (%TelegramEx.Types.CallbackQuery{}):
:id- Unique identifier for the callback query:from- User who triggered the callback (map with string keys):message- The%TelegramEx.Types.Message{}the callback was attached to:inline_message_id- Identifier of the inline message (if applicable):chat_instance- Global identifier for the chat:data- Data associated with the callback button
The handle_message/2 callback receives a %TelegramEx.Types.Message{} struct and a ctx map with the following fields:
:message_id- Unique message identifier:from- Sender information (map with string keys):chat- Chat information (map with string keys):date- Message date as Unix timestamp:message_thread_id- Thread identifier in forum chats (if any):text- Message text content:photo- Photo attachments (if any):document- Document attachment (if any):sticker- Sticker (if any):video- Video (if any):voice- Voice message (if any):caption- Caption for media
defmodule EchoBot do
use TelegramEx, name: :echo_bot
def handle_message(%{text: text, chat: chat}, ctx) do
ctx
|> Message.text("Echo: #{text}")
|> Message.send(chat["id"])
end
enddefmodule MyBot do
use TelegramEx, name: :my_bot
def handle_message(%{text: "/start", chat: chat}, ctx) do
ctx
|> Message.text("Welcome! Send me any message.")
|> Message.send(chat["id"])
end
def handle_message(%{text: text, chat: chat}, ctx) do
ctx
|> Message.text("Echo: #{text}")
|> Message.send(chat["id"])
end
endUse TelegramEx.Router to group handlers by logic into separate modules. This keeps the main bot module clean and lets you organize handlers by domain.
defmodule MyApp.Routers.Admin do
use TelegramEx.Router
defstate :admin do
def handle_message(%{text: "/exit", chat: chat}, ctx) do
ctx
|> Message.text("Exiting admin mode")
|> Message.send(chat["id"])
FSM.reset_state(:my_bot, chat["id"])
end
def handle_message(%{text: text, chat: chat}, ctx) do
ctx
|> Message.text("Admin command: #{text}")
|> Message.send(chat["id"])
end
end
endRegister routers in the main bot module:
defmodule MyBot do
use TelegramEx, name: :my_bot, routers: [MyApp.Routers.Admin]
def handle_message(%{text: "/admin", chat: chat}, ctx) do
ctx
|> Message.text("Entering admin mode")
|> Message.send(chat["id"])
{:transition, :admin}
end
endRouters are checked in order before the main bot module. If a router's handler returns :pass, the next router (or the bot module) is tried.
When replying to messages from forum chats (topics/threads), message_thread_id is handled automatically. The library injects it from the incoming message into the outgoing payload, so replies are sent to the correct thread without any extra code.
- Text messages
- Photos (local & remote)
- Documents (local & remote)
- Stickers
- Video
- Location
- Polls
- Quizzes
- Contacts
- Inline keyboard
- Reply keyboard
- Edit message text
- Edit message caption
- Delete message
- Get chat members
- Ban user
- Kick user
- Restrict user
- Typing indicator
- Recording voice indicator
- FSM
- Forum topics
- Webhooks
- Middlewares
- Rate limiting
- Task scheduler
- Internationalization
- Backpex integration
- Routers