Skip to main content

UPGRADING.md

# Upgrading to 0.10.0

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'd `session:` module started
  a green supervision tree that silently never worked.
* **Now:** raises `ArgumentError` at 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_TOKEN` before 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-object `send/2` is unchanged.

### Rate limiting — automatic single retry

* **Before:** a 429 from Telegram was logged and lost.
* **Now:** the send is retried once after the `retry_after` interval 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_data` over 64 bytes), `notify` returned
  `"ok" => true` and the user got an undismissable notification.
* **Now:** returns that error map (`"ok" => false`) with `"result"` still
  holding the sent message, so `message_id` stays 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.DecodeError` in *your* calling process; some
  transport errors raised `Protocol.UndefinedError` inside the error handler.
* **Now:** every `Sexy.Bot.Api` method returns the documented
  `%{"ok" => false, "description" => ...}` map for any transport/JSON failure.
* **Do:** remove any `rescue Jason.DecodeError` you added around send calls.
  Note: transport error descriptions now use `inspect/1`
  (`"HTTP error: :timeout"` instead of `"HTTP error: timeout"`).

### Callback query format — dashes in values now parse

* **Before:** `Sexy.Utils.split_query/1` split pairs on every `-`, silently
  corrupting values containing dashes: `"id=-1001234-page=2"` parsed as
  `%{id: "", page: 2}`. The library's own `notify(..., navigate: ...)` flow was
  affected.
* **Now:** `-` splits pairs only when followed by `key=`. 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/1` was documented optional but dispatched
  unconditionally (crash per poll update if missing); `handle_transit/3` was
  invoked unguarded, *after* the message was already deleted.
* **Now:** all optional callbacks are guarded. `/_transit` verifies the handler
  exists before deleting anything. `handle_poll/1` now 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_binary` returned `{: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/1` stopped the session supervisor, which the
  `DynamicSupervisor` then 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, and `close/1` never exits the caller.
* **Do:** nothing — `close/1` now 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, same
  `handle_info` clauses — 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 `:ok` when 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 `:ok` now.

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