0.10.0 is a reliability release: 37 audited defects fixed across the Bot core, the TDL runtime, and the mix tasks. Most fixes are invisible. This guide lists every public call whose observable behavior changed and what to do about it.
Sexy.Bot
Sexy.Bot.start_link/1 — fails fast on bad config
- Before:
token: nil(unset env var) or a typo'dsession:module started a green supervision tree that silently never worked. - Now: raises
ArgumentErrorat boot for a nil/empty token; raises if the session module can't be loaded. - Do: nothing if your config is correct. If your deploy relied on the bot
booting without a token, set
BOT_TOKENbefore start.
Sexy.Bot.send/2 with a list — returns per-item results
- Before:
send([a, b, c])returned:ok, discarding every response. - Now: returns the list of Telegram responses in order, so partial failures (blocked users, rate limits) are visible.
- Do: if you pattern-matched
:ok = Sexy.Bot.send(list), match the list instead:results = Sexy.Bot.send(list). Single-objectsend/2is unchanged.
Rate limiting — automatic single retry
- Before: a 429 from Telegram was logged and lost.
- Now: the send is retried once after the
retry_afterinterval Telegram asks for. The call blocks for that time (seconds). - Do: nothing. For large broadcasts, still check per-item results.
Sexy.Bot.notify/3 — button failure is no longer silent
- Before: if the message was sent but the dismiss/navigate buttons could not
be attached (rate limit,
callback_dataover 64 bytes),notifyreturned"ok" => trueand the user got an undismissable notification. - Now: returns that error map (
"ok" => false) with"result"still holding the sent message, somessage_idstays available. - Do: if you check
notify(...)["ok"], decide how to handle the partial case;["result"]["message_id"]is present either way when the send worked.
API error returns — error maps instead of exceptions
- Before: a non-JSON response (e.g. an HTML 502 from a proxy during a
Telegram outage) raised
Jason.DecodeErrorin your calling process; some transport errors raisedProtocol.UndefinedErrorinside the error handler. - Now: every
Sexy.Bot.Apimethod returns the documented%{"ok" => false, "description" => ...}map for any transport/JSON failure. - Do: remove any
rescue Jason.DecodeErroryou added around send calls. Note: transport error descriptions now useinspect/1("HTTP error: :timeout"instead of"HTTP error: timeout").
Callback query format — dashes in values now parse
- Before:
Sexy.Utils.split_query/1split pairs on every-, silently corrupting values containing dashes:"id=-1001234-page=2"parsed as%{id: "", page: 2}. The library's ownnotify(..., navigate: ...)flow was affected. - Now:
-splits pairs only when followed bykey=. Negative IDs, UUIDs, and dates in values round-trip correctly. Old callback buttons already in chats keep parsing the same (or better). - Do: nothing, unless a value must contain the literal sequence
-key=— that sequence is reserved as the pair separator;=in values remains unsupported.
Session callbacks — optional means optional
- Before:
handle_poll/1was documented optional but dispatched unconditionally (crash per poll update if missing);handle_transit/3was invoked unguarded, after the message was already deleted. - Now: all optional callbacks are guarded.
/_transitverifies the handler exists before deleting anything.handle_poll/1now also receives%{poll_answer: ...}updates (individual votes), which were previously dropped as unknown. - Do: if you implement
handle_poll/1, be ready for both%{poll: ...}and%{poll_answer: ...}shapes.
Delivery semantics (informational)
The poller now survives malformed updates, network noise, and handler crashes,
and the update offset survives poller restarts. Delivery remains
at-least-once: a batch can be re-dispatched after a crash in the
confirmation window. Keep payment handlers idempotent (deduplicate by
update_id or telegram_payment_charge_id).
Updates of the same chat are now processed in order (partitioned dispatch keyed by chat id) — previously all updates ran as unordered concurrent tasks and two quick messages from one user could be handled in reverse. Different chats still run concurrently. Note the ceiling: a slow handler delays other chats that hash into the same partition.
Sexy.TDL
Sexy.TDL.open/3 — honest returns
- Before: opening a session name that was already running silently clobbered
the running session's registry entry and started a second tdlib on the same
database. Opening with a broken
:tdlib_binaryreturned{:ok, pid}and the failure event went nowhere. - Now: returns
{:error, {:already_started, pid}}for a duplicate name and{:error, reason}(synchronously) when the port can't be opened. - Do: handle the two error tuples if you retry opens.
Sexy.TDL.close/1 — actually closes
- Before:
close/1stopped the session supervisor, which theDynamicSupervisorthen immediately resurrected: the session survived as a zombie or crash-looped the whole account supervisor. Closing a stale session could exit the caller with:noproc. - Now: the session is terminated for real (
terminate_child), never resurrected, andclose/1never exits the caller. - Do: nothing —
close/1now does what its docs always claimed.
Port death — automatic restart, then permanent stop
- Before (no proxy): tdlib dying was undetectable — transmits silently vanished, no event, no restart. Before (proxy): detected but the session stayed a zombie forever.
- Now: the Backend/Handler pair restarts with a fresh port automatically
(up to 5 times in 30s). You still get
{:system_event, :port_exited, status}before each restart. A session that keeps failing dies alone — it does not cascade into other sessions. Its registry entry is cleaned up automatically. - Do: monitor the pid from
open/3(or watch for repeated:port_exited) if you want to re-open persistently failing sessions yourself.
System/proxy events — delivered directly
- Before:
{:system_event, ...}/{:proxy_event, ...}were relayed through the Handler and were lost on cold start and during restarts. - Now: the Backend sends them straight to your
app_pid. Same tuples, samehandle_infoclauses — they just actually arrive now. - Do: nothing.
Supervision shape
Sexy.TDL's children now use :rest_for_one: if the session Registry crashes,
dependent processes restart into a consistent empty state instead of running as
zombies against an empty table. After such a reset (or any session supervisor
death) re-open your sessions — entries no longer linger with dead pids.
Sexy.TDL.transmit/2 — meaningful return value
- Before: returned the raw message tuple from
Kernel.send/2(useless), and "successfully" wrote into dead ports (the data silently vanished). During a Backend restart it exited the caller with:noproc. - Now: returns
:okwhen the command was actually written to the port,{:error, :no_backend}/{:error, :no_port}otherwise. Writing to a dead port fails loudly and triggers the pair restart. - Do: if you matched on transmit's return, match
:oknow.
Inline keyboards in received messages
vector<vector<T>> fields (e.g. ReplyMarkupInlineKeyboard.rows) are now
recursively deserialized: inner elements arrive as %Sexy.TDL.Object.* structs
instead of raw string-keyed maps. If you worked around this by matching raw
maps, switch to matching the structs.
Mix tasks
mix sexy.tdl.setup
No longer offers to generate types — Sexy.TDL.Object/Method are bundled
with the library. Prompts fail with a clear message instead of a crash when run
without an interactive terminal.
mix sexy.tdl.generate_types
Still available for regenerating types when a new TDLib version ships — run it
inside the sexy repository (or a fork). It now refuses to run in a consumer
project (the generated modules would duplicate the ones compiled in the :sexy
dependency and break mix release); --force overrides. Generation is atomic:
a malformed types.json no longer destroys the previous files. Doc text from
types.json is escaped, closing a compile-time code-execution hole.
Compatibility
Declared Elixir support (~> 1.14) is now real: the accidental use of an
Elixir 1.17+ guard was removed, so the library compiles on 1.14–1.16 again.