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/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: 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
# 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}
])

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