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:

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/string/nil nil File content (binary) or path for multipart uploads
filename string/nil nil Filename for multipart uploads
upload_type atom/nil nil:photo, :video, :animation, :document — forces multipart upload

Content type detectionupload_type wins, otherwise falls back to media:

Condition Sent as API method
upload_type: :photo photo upload sendPhoto (multipart)
upload_type: :video video upload sendVideo (multipart)
upload_type: :animation animation upload sendAnimation (multipart)
upload_type: :document document upload sendDocument (multipart)
media: nil text message sendMessage
media: "file" (legacy) document upload sendDocument (multipart)
media starts with "A" photo by file_id sendPhoto
media starts with "B" video by file_id sendVideo
media starts with "C" animation by file_id 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..."}

# 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.

Option Default Description
update_mid: truetrue 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: falsefalseOverlay — sends without replacing current screen, adds dismiss button
replace: trueReplace — 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
after: secondsnil Auto-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


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(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

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 detection

Mix 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