Sexy
Telegram framework for Elixir — bots and userbots from one dependency
Sexy.Bot for Bot API. Sexy.TDL for TDLib. Use one or both.
What is Sexy?
Sexy is a Telegram framework with two engines:
- Sexy.Bot — Bot API with a single-message UI pattern. Every screen replaces the previous one, creating an app-like experience inside Telegram.
- Sexy.TDL — TDLib integration for userbot sessions. Manages port to
tdlib_json_cli, deserializes JSON into Elixir structs, routes events to your app.
Both can run in the same application simultaneously.
Quick Start: Bot API
1. Add dependency
# mix.exs
defp deps do
[{:sexy, git: "git@github.com:Puremag1c/Sexy.git"}]
end2. Start in your supervision tree
children = [
{Sexy.Bot, token: "123456:ABC-DEF...", session: MyApp.Session},
]3. Implement Session
defmodule MyApp.Session do
@behaviour Sexy.Bot.Session
# Persistence — Sexy manages one active message per chat
@impl true
def get_message_id(chat_id), do: MyApp.Users.get_mid(chat_id)
@impl true
def on_message_sent(chat_id, message_id, type, update_data) do
MyApp.Users.save_mid(chat_id, message_id, type, update_data)
end
# Dispatch — Sexy routes updates to these callbacks
@impl true
def handle_command(update), do: MyApp.Bot.command(update)
@impl true
def handle_query(update), do: MyApp.Bot.query(update)
@impl true
def handle_message(update), do: MyApp.Bot.message(update)
@impl true
def handle_chat_member(update), do: :ok
end4. Build and send screens
%{
chat_id: chat_id,
text: "Welcome!",
kb: %{inline_keyboard: [[%{text: "Start", callback_data: "/start"}]]}
}
|> Sexy.Bot.build()
|> Sexy.Bot.send()That's it. Sexy deletes the old message, sends the new one, and saves state via your Session.
Quick Start: TDLib (Userbots)
1. Configure
# config/config.exs
config :sexy,
tdlib_binary: "/path/to/tdlib_json_cli",
tdlib_data_root: "/path/to/tdlib_data"
Or run the interactive setup: mix sexy.tdl.setup
2. Add to supervision tree
children = [
Sexy.TDL,
# optionally alongside Sexy.Bot:
{Sexy.Bot, token: "...", session: MyApp.Session},
]3. Open a session
config = %{Sexy.TDL.default_config() |
api_id: "12345",
api_hash: "abc123",
database_directory: "/tmp/tdlib_data/my_account"
}
Sexy.TDL.open("my_account", config, app_pid: self())4. Handle events
def handle_info({:recv, struct}, state) do
# TDLib object as Elixir struct (e.g. %Sexy.TDL.Object.UpdateNewMessage{})
end
def handle_info({:proxy_event, text}, state) do
# proxychains output
end
def handle_info({:system_event, type, details}, state) do
# :port_failed, :port_exited, :proxy_conf_missing
end5. Send commands
Sexy.TDL.transmit("my_account", %Sexy.TDL.Method.GetMe{})
Sexy.TDL.transmit("my_account", %Sexy.TDL.Method.SendMessage{
chat_id: 123456,
input_message_content: %Sexy.TDL.Object.InputMessageText{
text: %Sexy.TDL.Object.FormattedText{text: "Hello from userbot!"}
}
})Concepts
Single-message pattern (Bot)
User clicks button -> old message deleted -> new message sent -> state saved
Every chat has one active screen. Sexy.Bot.send/1 handles the full cycle: detect content type, call Telegram API, delete previous message via Session.get_message_id/1, save new mid via Session.on_message_sent/4.
Object struct
Every message goes through Sexy.Utils.Object — the universal message container. Build one with Sexy.Bot.build/1:
Sexy.Bot.build(%{chat_id: 123, text: "Hello!"})
#=> %Sexy.Utils.Object{chat_id: 123, text: "Hello!", ...}Fields:
| Field | Type | Default | Description |
|---|---|---|---|
chat_id | integer | nil | Telegram chat id (required) |
text | string | "" | Message text or caption (HTML supported) |
media | string/nil | nil | Content type selector (see table below) |
kb | map | %{inline_keyboard: []} | Telegram reply markup |
entity | list | [] |
Telegram entities (bold, links, etc.). When non-empty, parse_mode is omitted |
update_data | map | %{} |
App-specific 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 |
Media type detection — the media field determines how the message is sent:
media value | Sent as | API method |
|---|---|---|
nil | text message | sendMessage |
"file" | document (multipart upload) | sendDocument |
starts with "A" | photo | sendPhoto |
starts with "B" | video | sendVideo |
starts with "C" | animation (GIF) | sendAnimation |
Telegram file_ids have predictable prefixes by type — Sexy uses this for auto-detection.
Examples:
# Text message with buttons
%{chat_id: id, text: "Pick:", kb: %{inline_keyboard: [[%{text: "Go", callback_data: "/go"}]]}}
# Photo by file_id
%{chat_id: id, text: "Nice photo", media: "AgACAgIAAxk..."}
# Document upload from binary
%{chat_id: id, text: "Your report", media: "file", file: csv_binary, filename: "report.csv"}
# Pass state to on_message_sent
%{chat_id: id, text: "Cart", update_data: %{screen: "cart", page: 1}}send/2 options
Sexy.Bot.send(object, opts) sends an Object and manages the message lifecycle.
| Option | Default | Description |
|---|---|---|
update_mid: true | true | Delete previous message, save new mid via Session |
update_mid: false | — | Send without touching the current screen state |
# Normal send — replaces current screen (default)
Sexy.Bot.build(%{chat_id: id, text: "Home"}) |> Sexy.Bot.send()
# Send without replacing — useful for secondary messages
Sexy.Bot.build(%{chat_id: id, text: "Tip of the day"}) |> Sexy.Bot.send(update_mid: false)Notifications
Sexy.Bot.notify(chat_id, message, opts) sends notification messages separate from the main screen flow.
Options:
| Option | Default | Description |
|---|---|---|
replace: false | false | Overlay — sends without replacing current screen, adds dismiss button |
replace: true | — | Replace — becomes new active screen (mid updated via Session) |
navigate: {text, path} | nil |
Adds a button that deletes the notification and calls Session.handle_transit/3 |
navigate: {text, fn} | nil |
Same, but with a function fn mid -> callback_data end for custom routing |
dismiss_text: "text" | "OK" | Custom dismiss button text |
extra_buttons: [[...]] | [] | Additional button rows appended after navigate/dismiss |
# Overlay — dismiss button, current screen untouched
Sexy.Bot.notify(chat_id, %{text: "Done!"})
# Custom dismiss text
Sexy.Bot.notify(chat_id, %{text: "Saved!"}, dismiss_text: "Got it")
# Replace — becomes new active screen
Sexy.Bot.notify(chat_id, %{text: "Payment received!"}, replace: true)
# Navigate — click deletes notification, calls Session.handle_transit/3
Sexy.Bot.notify(chat_id, %{text: "New order!"},
navigate: {"View Order", "/order id=123"}
)
# Navigate with custom callback + extra buttons
Sexy.Bot.notify(chat_id, %{text: "Alert!"},
navigate: {"Details", fn mid -> "/show mid=#{mid}" end},
extra_buttons: [[%{text: "Mute", callback_data: "/mute"}]]
)Payments (Telegram Stars)
Sexy supports Telegram Stars payments out of the box.
1. Send an invoice:
Sexy.Bot.send_invoice(chat_id, "Premium Access", "Unlock all features", "premium_123", "XTR", [
%{label: "Premium Access", amount: 100}
])currencymust be"XTR"for Starspricesis a list of%{label, amount}maps
2. Handle the payment flow:
When a user confirms payment, Telegram sends a pre_checkout_query. By default Sexy auto-approves it. To add validation, implement the optional callback:
@impl true
def handle_pre_checkout(update) do
query = update.pre_checkout_query
# validate payload, check inventory, etc.
Sexy.Bot.answer_pre_checkout(query.id)
end
After payment succeeds, Telegram sends a message with successful_payment. Implement the optional callback to process it:
@impl true
def handle_successful_payment(update) do
payment = update.message.successful_payment
chat_id = update.message.chat.id
# payment.telegram_payment_charge_id — for refunds
# payment.total_amount — amount in Stars
# payment.invoice_payload — your payload string
MyApp.Payments.activate(chat_id, payment)
end3. Refund (if needed):
Sexy.Bot.refund_star_payment(user_id, telegram_payment_charge_id)TDL supervision tree
Sexy.TDL (Supervisor)
|-- Sexy.TDL.Registry (ETS session storage)
|-- AccountVisor (DynamicSupervisor)
|-- Riser per session (one_for_all)
|-- Backend (port to tdlib_json_cli)
|-- Handler (JSON -> structs -> events)
|-- ...extra children from your app
Open a session with Sexy.TDL.open/3, close with Sexy.TDL.close/1. Each session gets its own supervision subtree. Pass children: [MyWorker] in opts to inject app-specific processes.
Auto-generated types
Sexy ships 2558 structs generated from TDLib API documentation:
Sexy.TDL.Method.*— 786 API methods (GetMe, SendMessage, etc.)Sexy.TDL.Object.*— 1772 response types (UpdateNewMessage, User, Chat, etc.)
Regenerate from a different TDLib version: mix sexy.tdl.generate_types /path/to/types.json
API Reference
Sexy.Bot
| Function | Description |
|---|---|
build(map) | Map -> Object struct |
send(object, opts) | Send to Telegram, manage mid lifecycle |
notify(chat_id, msg, opts) | Notification with dismiss/navigate |
send_message(chat_id, text) | Send text message |
send_photo(body) | Send photo |
send_video(body) | Send video |
send_animation(body) | Send animation |
send_document(chat_id, file, name, text, kb) | Send file |
edit_text(body) | Edit message text |
edit_reply_markup(body) | Edit buttons |
delete_message(chat_id, mid) | Delete message |
answer_callback(id, text, alert) | Answer callback query |
send_invoice(chat_id, title, desc, payload, cur, prices) | Telegram Stars payment |
answer_pre_checkout(pre_checkout_query_id) | Approve pre-checkout query |
refund_star_payment(user_id, charge_id) | Refund a Stars payment |
request(body, method) | Any Telegram Bot API method |
Sexy.TDL
| Function | Description |
|---|---|
open(session, config, opts) | Start TDLib session |
close(session) | Stop session and cleanup |
transmit(session, msg) | Send command to TDLib |
default_config() | Base TDLib config template |
Sexy.Bot.Session callbacks
| Callback | Required | Description |
|---|---|---|
get_message_id(chat_id) | yes | Return current active mid |
on_message_sent(chat_id, mid, type, update_data) | yes | Save new active mid |
handle_command(update) | yes | /command messages |
handle_query(update) | yes | Button callbacks |
handle_message(update) | yes | Text messages |
handle_chat_member(update) | yes | Join/leave events |
handle_poll(update) | no | Poll responses |
handle_transit(chat_id, cmd, query) | no | Transit button clicks |
handle_pre_checkout(update) | no | Payment pre-checkout (auto-approved if missing) |
handle_successful_payment(update) | no | Successful payment notification |
Module Map
Sexy Namespace module
Sexy.Bot Bot API supervisor + public API
Sexy.Bot.Api Telegram HTTP client
Sexy.Bot.Sender Object -> Telegram + mid lifecycle
Sexy.Bot.Screen Map -> Object struct
Sexy.Bot.Session Behaviour: persistence + dispatch
Sexy.Bot.Notification Overlay/replace notifications
Sexy.Bot.Poller GenServer polling + routing
Sexy.TDL TDLib supervisor + open/close/transmit API
Sexy.TDL.Backend Port to tdlib_json_cli binary
Sexy.TDL.Handler JSON deserialization + event routing
Sexy.TDL.Registry ETS session storage
Sexy.TDL.Riser Per-account supervisor
Sexy.TDL.Object 1772 auto-generated TDLib object structs
Sexy.TDL.Method 786 auto-generated TDLib method structs
Sexy.Utils Query parsing, formatting, type conversion
Sexy.Utils.Bot Command parsing, pagination
Sexy.Utils.Object Message struct + type detectionMix Tasks
| Task | Description |
|---|---|
mix sexy.tdl.setup | Interactive TDLib configuration wizard |
mix sexy.tdl.generate_types [path] | Regenerate Method/Object structs from types.json |
Migration
Upgrading from an older version? See MIGRATION.md.
License
MIT