Why This Library Exists
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.
Usage
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
end
Add your bot token to config/runtime.exs:
import Config
config :telegram_ex,
my_bot: System.fetch_env!("MY_BOT_TELEGRAM_TOKEN")Stateless Handlers
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.
Stateful Handlers
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
FSM API
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)Sending Messages
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 Builder Functions
Message.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
Sending Photos
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 Builder Functions
Photo.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
Sending Documents
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 Builder Functions
Document.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
Sending Stickers
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 Builder Functions
Sticker.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
Sending Videos
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 Builder Functions
Video.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
Sending Locations
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 Builder Functions
Location.coordinates(ctx, latitude, longitude)- Set geo coordinatesLocation.send(ctx, chat_id)- Send the location
Sending Contacts
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 Builder Functions
Contact.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
Keyboard Examples
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
Handling Callback Queries
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
endAnswering Callback Queries
To 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
Message Structure
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
Examples
Echo Bot
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
endCommand Handling
defmodule 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
endRouters
Use 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
end
Routers 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.
Forum Topics
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.
Roadmap
Sending Messages
- Text messages
- Photos (local & remote)
- Documents (local & remote)
- Stickers
- Video
- Location
- Polls
- Quizzes
- Contacts
Keyboards
- Inline keyboard
- Reply keyboard
Message Management
- Edit message text
- Edit message caption
- Delete message
Group Actions
- Get chat members
- Ban user
- Kick user
- Restrict user
Chat Effects
- Typing indicator
- Recording voice indicator
Integrations & Infrastructure
- FSM
- Forum topics
- Webhooks
- Middlewares
- Rate limiting
- Task scheduler
- Internationalization
- Backpex integration
- Routers