Build a Telegram bot with Sexy.Bot in 5 minutes.
What is Sexy?
Sexy is a single-message UI framework for Telegram bots. Instead of flooding the chat with messages, your bot keeps one active message per chat. Every action deletes the old message and sends a new one — the result looks and feels like an interactive app inside Telegram.
The Session behaviour is the bridge between the framework and your code.
It has two responsibilities:
- Persistence — remember which message is currently on screen
- Dispatch — route incoming Telegram updates to your handlers
1. Get a bot token
Open @BotFather in Telegram, send /newbot,
follow the prompts, and copy the token (looks like 123456:ABC-DEF...).
2. Create a new project
mix new my_bot --sup
cd my_bot
--sup generates a supervision tree so the bot starts automatically with your app.
3. Add Sexy to dependencies
# mix.exs
defp deps do
[
{:sexy, "~> 0.9"}
]
endmix deps.get
4. Implement the Session
Create lib/my_bot/session.ex. We'll build it in three blocks: storage,
dispatch, and screens.
Block A — Storage (persistence)
The framework calls two callbacks to manage the active message:
get_message_id/1— before sending, to find and delete the old messageon_message_sent/4— after sending, to save the new message id
For this quickstart we use an Agent (a simple key-value process). In a real
project you would use a database (Ecto).
# lib/my_bot/session.ex
defmodule MyBot.Session do
@behaviour Sexy.Bot.Session
use Agent
def start_link(_), do: Agent.start_link(fn -> %{} end, name: __MODULE__)
# ── Persistence ──
@impl true
def get_message_id(chat_id) do
Agent.get(__MODULE__, &Map.get(&1, chat_id))
end
@impl true
def on_message_sent(chat_id, message_id, _type, _update_data) do
Agent.update(__MODULE__, &Map.put(&1, chat_id, message_id))
endThe two unused parameters:
type—"txt"for text messages,"media"for photos/videos/documentsupdate_data— a map you attach to the Object (e.g.%{screen: "products", page: 2}). Useful for saving app state alongside the message id.
Block B — Dispatch (routing updates)
Telegram sends your bot updates. Each update is a map with one key that tells you what happened:
| Update key | Meaning | Session callback |
|---|---|---|
message (text starts with /) | User sent a command | handle_command/1 |
message (otherwise) | User sent a regular message | handle_message/1 |
message (with successful_payment) | Payment completed | handle_successful_payment/1 * |
callback_query | User pressed an inline button | handle_query/1 |
pre_checkout_query | Payment pre-checkout | handle_pre_checkout/1 * |
my_chat_member | User blocked/unblocked the bot | handle_chat_member/1 |
* Optional — see Accepting Payments below.
Here is what a command update looks like (simplified):
%{
message: %{
chat: %{id: 123456},
text: "/start"
}
}And a callback query update (when a user presses an inline button):
%{
callback_query: %{
id: "abc123", # you must answer this (see below)
message: %{chat: %{id: 123456}},
data: "/about" # the callback_data string you set on the button
}
}Add the dispatch callbacks to the same module:
# ── Dispatch ──
@impl true
def handle_command(update) do
chat_id = update.message.chat.id
{cmd, _query} = Sexy.Utils.Bot.parse_comand_and_query(update.message.text)
case cmd do
"start" -> show_home(chat_id)
"help" -> show_help(chat_id)
_ -> :ok
end
end
@impl true
def handle_query(update) do
chat_id = update.callback_query.message.chat.id
{cmd, _query} = Sexy.Utils.Bot.parse_comand_and_query(update.callback_query.data)
case cmd do
"home" -> show_home(chat_id)
"about" -> show_about(chat_id)
"help" -> show_help(chat_id)
_ -> :ok
end
# Telegram waits for a response to every button press.
# answer_callback removes the "loading" spinner on the button.
# Arguments: callback_id, tooltip text (empty = none), show_alert flag.
Sexy.Bot.answer_callback(update.callback_query.id, "", false)
end
# Required callbacks — return :ok if you don't need them yet.
@impl true
def handle_message(_update), do: :ok
@impl true
def handle_chat_member(_update), do: :okBlock C — Screens
Each screen follows the same pattern: build a map → convert to Object → send.
# ── Screens ──
defp show_home(chat_id) do
%{
chat_id: chat_id,
text: "<b>Welcome to MyBot!</b>\n\nPick an option below:",
kb: %{inline_keyboard: [
[%{text: "About", callback_data: "/about"}],
[%{text: "Help", callback_data: "/help"}]
]}
}
|> Sexy.Bot.build()
|> Sexy.Bot.send()
end
defp show_about(chat_id) do
%{
chat_id: chat_id,
text: "This bot was built with <b>Sexy</b> framework.",
kb: %{inline_keyboard: [
[%{text: "Back", callback_data: "/home"}]
]}
}
|> Sexy.Bot.build()
|> Sexy.Bot.send()
end
defp show_help(chat_id) do
%{
chat_id: chat_id,
text: "Available commands:\n/start — Home screen\n/help — This message",
kb: %{inline_keyboard: [
[%{text: "Back", callback_data: "/home"}]
]}
}
|> Sexy.Bot.build()
|> Sexy.Bot.send()
end
endInline keyboard layout: kb expects a %{inline_keyboard: rows} map where
rows is a list of lists. Each inner list is one row of buttons. For example,
two buttons on the same row:
kb: %{inline_keyboard: [
[%{text: "Yes", callback_data: "/yes"}, %{text: "No", callback_data: "/no"}],
[%{text: "Cancel", callback_data: "/home"}]
]}
# Row 1: [Yes] [No]
# Row 2: [Cancel]callback_data is the string that arrives in update.callback_query.data
when the user presses that button. Convention: start with / and a command
name so you can route it in handle_query/1.
5. Start the bot in your supervision tree
# lib/my_bot/application.ex
defmodule MyBot.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyBot.Session, # start the Agent first
{Sexy.Bot, token: System.get_env("BOT_TOKEN"), session: MyBot.Session}
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyBot.Supervisor)
end
endOrder matters: MyBot.Session must start before Sexy.Bot because the bot
will call session callbacks as soon as updates arrive.
6. Run
BOT_TOKEN="123456:ABC-DEF..." mix run --no-halt
Open your bot in Telegram and send /start.
How it works
User sends /start
→ Poller receives update
→ handle_command/1
→ show_home/1: build map → build() → send()
→ Sexy calls Telegram API (sendMessage)
→ Sexy saves message_id via on_message_sent/4
→ User sees "Welcome to MyBot!"
User presses [About]
→ Poller receives callback_query (data: "/about")
→ handle_query/1
→ show_about/1: build map → build() → send()
→ Sexy calls get_message_id/1 → deletes old message
→ Sexy sends new message, saves new message_id
→ answer_callback removes button spinner
→ User sees "This bot was built with Sexy"Reference
Object fields
Every message sent through Sexy.Bot.send/1 is a Sexy.Utils.Object struct.
You build one from a plain map via Sexy.Bot.build/1:
Sexy.Bot.build(%{chat_id: 123, text: "Hello!"})| Field | Type | Default | Description |
|---|---|---|---|
chat_id | integer | nil | Telegram chat id (required) |
text | string | "" | Message text or caption. HTML is supported by default |
media | string | nil | nil | Media file_id or "file" for uploads. nil = text only |
kb | map | %{inline_keyboard: []} | Reply markup (inline keyboard) |
update_data | map | %{} | App data passed to Session.on_message_sent/4 |
file | binary | nil | nil | File content for document uploads |
filename | string | nil | nil | Filename for document uploads |
Sending different content types
The media field controls which Telegram API method is called:
# Text message (media: nil — default)
%{chat_id: id, text: "<b>Bold</b> text with HTML"}
# Photo by file_id (starts with "A")
%{chat_id: id, text: "Caption", media: "AgACAgIAAxk..."}
# Video by file_id (starts with "B")
%{chat_id: id, text: "Watch this", media: "BAACAgIAAxk..."}
# Animation / GIF (starts with "C")
%{chat_id: id, media: "CgACAgIAAxk..."}
# Document — upload binary with filename
%{chat_id: id, text: "Your export", upload_type: :document, file: File.read!("data.csv"), filename: "data.csv"}
# Photo / video / animation upload (multipart)
%{chat_id: id, text: "Look", upload_type: :photo, file: File.read!("p.jpg"), filename: "p.jpg"}
%{chat_id: id, text: "Clip", upload_type: :video, file: "/tmp/clip.mp4", filename: "clip.mp4"}Inline keyboard
%{
chat_id: id,
text: "Choose:",
kb: %{inline_keyboard: [
[%{text: "Option A", callback_data: "/pick val=a"}],
[%{text: "Option B", callback_data: "/pick val=b"}],
[%{text: "Cancel", callback_data: "/home"}]
]}
}Each inner list is a row. Buttons in the same list appear side by side. Buttons in separate lists stack vertically.
Passing state between screens
Use update_data to save app-specific context. It is passed as the fourth
argument to Session.on_message_sent/4:
%{
chat_id: id,
text: "Page 2 of products",
update_data: %{screen: "products", page: 2, category: "electronics"}
}
|> Sexy.Bot.build()
|> Sexy.Bot.send()Then in your Session:
def on_message_sent(chat_id, message_id, type, update_data) do
# update_data == %{screen: "products", page: 2, category: "electronics"}
MyApp.Users.update(chat_id, %{mid: message_id, state: update_data})
endsend/2 options
| Option | Default | Effect |
|---|---|---|
update_mid: true | yes | Deletes old message, saves new mid via Session |
update_mid: false | — | Sends without touching the current screen state |
# Normal — replaces current screen
Sexy.Bot.build(map) |> Sexy.Bot.send()
# Fire-and-forget — doesn't replace the screen
Sexy.Bot.build(map) |> Sexy.Bot.send(update_mid: false)Notifications
Sexy.Bot.notify/3 sends messages outside the main screen flow.
Overlay (default)
Sends a message with a dismiss button. The current screen stays intact:
Sexy.Bot.notify(chat_id, %{text: "Action completed!"})
Sexy.Bot.notify(chat_id, %{text: "Saved!"}, dismiss_text: "Got it")Replace
Replaces the current screen (mid is updated via Session):
Sexy.Bot.notify(chat_id, %{text: "Payment received!"}, replace: true)Navigate
Adds a button that deletes the notification and calls Session.handle_transit/3:
Sexy.Bot.notify(chat_id, %{text: "New order #42!"},
navigate: {"View Order", "/order id=42"}
)In your Session, implement the optional callback:
@impl true
def handle_transit(chat_id, "order", %{id: order_id}) do
show_order_screen(chat_id, order_id)
endExtra buttons
Append additional button rows after navigate/dismiss:
Sexy.Bot.notify(chat_id, %{text: "New message from Alice"},
navigate: {"Open Chat", "/chat user=alice"},
extra_buttons: [[%{text: "Mute", callback_data: "/mute user=alice"}]]
)Accepting Payments (Telegram Stars)
Sexy has built-in support for Telegram Stars payments. Here is the full flow:
1. Send an invoice
defp show_buy(chat_id) do
Sexy.Bot.send_invoice(
chat_id,
"Premium Access", # title
"Unlock all features", # description
"premium_#{chat_id}", # payload (your internal id)
"XTR", # currency (XTR = Stars)
[%{label: "Premium", amount: 100}] # price list
)
endThe user sees a payment card in the chat. When they tap "Pay", Telegram
sends a pre_checkout_query to your bot.
2. Approve the checkout (optional)
By default Sexy auto-approves every pre-checkout. If you need validation (check inventory, verify payload, etc.), add the optional callback to your Session:
@impl true
def handle_pre_checkout(update) do
query = update.pre_checkout_query
# Validate and approve:
Sexy.Bot.answer_pre_checkout(query.id)
endIf you do not implement this callback, Sexy calls answer_pre_checkout
automatically so the payment is not cancelled by Telegram's 10-second timeout.
3. Handle successful payment
After the charge goes through, Telegram sends a message with successful_payment.
Implement the optional callback:
@impl true
def handle_successful_payment(update) do
payment = update.message.successful_payment
chat_id = update.message.chat.id
# payment.telegram_payment_charge_id — save this for refunds
# payment.total_amount — amount in Stars
# payment.invoice_payload — your payload string
MyApp.Payments.activate(chat_id, payment)
show_home(chat_id)
endIf you do not implement this callback, Sexy just logs the event.
4. Refund (if needed)
Sexy.Bot.refund_star_payment(user_id, telegram_payment_charge_id)Payment update routing
| Update | Session callback | Default if not implemented |
|---|---|---|
pre_checkout_query | handle_pre_checkout/1 | Auto-approve (ok: true) |
message with successful_payment | handle_successful_payment/1 | Log and ignore |
Both callbacks are optional. The pre-checkout default ensures payments are never silently cancelled because your bot forgot to respond.
Next steps
- Use
Sexy.Utils.Bot.paginate/3for paginated lists - Use
Sexy.Utils.Bot.get_message_type/1to detect incoming media type