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"}
  ]
end
mix 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 message
  • on_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))
  end

The two unused parameters:

  • type"txt" for text messages, "media" for photos/videos/documents
  • update_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 keyMeaningSession callback
message (text starts with /)User sent a commandhandle_command/1
message (otherwise)User sent a regular messagehandle_message/1
message (with successful_payment)Payment completedhandle_successful_payment/1 *
callback_queryUser pressed an inline buttonhandle_query/1
pre_checkout_queryPayment pre-checkouthandle_pre_checkout/1 *
my_chat_memberUser blocked/unblocked the bothandle_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: :ok

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

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

Order 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!"})
FieldTypeDefaultDescription
chat_idintegernilTelegram chat id (required)
textstring""Message text or caption. HTML is supported by default
mediastring | nilnilMedia file_id or "file" for uploads. nil = text only
kbmap%{inline_keyboard: []}Reply markup (inline keyboard)
update_datamap%{}App data passed to Session.on_message_sent/4
filebinary | nilnilFile content for document uploads
filenamestring | nilnilFilename 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})
end

send/2 options

OptionDefaultEffect
update_mid: trueyesDeletes old message, saves new mid via Session
update_mid: falseSends 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)
end

Extra 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
  )
end

The 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)
end

If 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)
end

If 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

UpdateSession callbackDefault if not implemented
pre_checkout_queryhandle_pre_checkout/1Auto-approve (ok: true)
message with successful_paymenthandle_successful_payment/1Log and ignore

Both callbacks are optional. The pre-checkout default ensures payments are never silently cancelled because your bot forgot to respond.


Next steps