Hex.pm

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:

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"}]
end

2. 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
end

4. 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
end

5. 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:

FieldTypeDefaultDescription
chat_idintegernilTelegram chat id (required)
textstring""Message text or caption (HTML supported)
mediastring/nilnilContent type selector (see table below)
kbmap%{inline_keyboard: []}Telegram reply markup
entitylist[]Telegram entities (bold, links, etc.). When non-empty, parse_mode is omitted
update_datamap%{}App-specific data passed to Session.on_message_sent/4
filebinary/string/nilnilFile content (binary) or path for multipart uploads
filenamestring/nilnilFilename for multipart uploads
upload_typeatom/nilnil:photo, :video, :animation, :document — forces multipart upload

Content type detectionupload_type wins, otherwise falls back to media:

ConditionSent asAPI method
upload_type: :photophoto uploadsendPhoto (multipart)
upload_type: :videovideo uploadsendVideo (multipart)
upload_type: :animationanimation uploadsendAnimation (multipart)
upload_type: :documentdocument uploadsendDocument (multipart)
media: niltext messagesendMessage
media: "file" (legacy)document uploadsendDocument (multipart)
media starts with "A"photo by file_idsendPhoto
media starts with "B"video by file_idsendVideo
media starts with "C"animation by file_idsendAnimation

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..."}
# Upload from binary or path
%{chat_id: id, text: "Your report", upload_type: :document, file: csv_binary, filename: "report.csv"}
%{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"}
# 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.

OptionDefaultDescription
update_mid: truetrueDelete previous message, save new mid via Session
update_mid: falseSend 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:

OptionDefaultDescription
replace: falsefalseOverlay — sends without replacing current screen, adds dismiss button
replace: trueReplace — becomes new active screen (mid updated via Session)
navigate: {text, path}nilAdds a button that deletes the notification and calls Session.handle_transit/3
navigate: {text, fn}nilSame, 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
after: secondsnilAuto-delete notification after N seconds
# 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"}]]
)
# Auto-delete after 3 seconds
Sexy.Bot.notify(chat_id, %{text: "Saved!"}, after: 3)

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}
])

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

3. 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:

Regenerate from a different TDLib version: mix sexy.tdl.generate_types /path/to/types.json (run inside the sexy repository or a fork — in a consumer project the generated modules would duplicate the ones compiled in the dependency; see UPGRADING.md).


API Reference

Sexy.Bot

FunctionDescription
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(chat_id, file, name, text, kb)Send photo by file_id (JSON) or multipart upload
send_video(body) / send_video(chat_id, file, name, text, kb)Send video by file_id (JSON) or multipart upload
send_animation(body) / send_animation(chat_id, file, name, text, kb)Send animation by file_id (JSON) or multipart upload
send_document(chat_id, file, name, text, kb)Send document (multipart upload)
edit_text(body)Edit message text
edit_reply_markup(body)Edit buttons
delete_message(chat_id, mid, opts)Delete message. after: seconds for delayed deletion
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

FunctionDescription
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

CallbackRequiredDescription
get_message_id(chat_id)yesReturn current active mid
on_message_sent(chat_id, mid, type, update_data)yesSave new active mid
handle_command(update)yes/command messages
handle_query(update)yesButton callbacks
handle_message(update)yesText messages
handle_chat_member(update)yesJoin/leave events
handle_poll(update)noPoll responses
handle_transit(chat_id, cmd, query)noTransit button clicks
handle_pre_checkout(update)noPayment pre-checkout (auto-approved if missing)
handle_successful_payment(update)noSuccessful 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 detection

Mix Tasks

TaskDescription
mix sexy.tdl.setupInteractive TDLib configuration wizard
mix sexy.tdl.generate_types [path]Regenerate Method/Object structs from types.json

License

MIT