NAME
    EV::Telegram::TDLib - asynchronous Telegram client on TDLib and EV

SYNOPSIS
        use EV;
        use EV::Telegram::TDLib;

        my $chat_id = $ENV{TD_CHAT_ID};

        my $td = EV::Telegram::TDLib->new(
            api_id             => $ENV{TD_API_ID},
            api_hash           => $ENV{TD_API_HASH},
            phone_number       => '+10000000000',
            database_directory => 'tdlib-db',
            on_code    => sub {
                my ($info, $submit) = @_;
                print "code from Telegram: ";
                chomp(my $code = <STDIN>);
                $submit->($code);
            },
            on_message => sub {
                my ($msg) = @_;
                print "message $msg->{id} in chat $msg->{chat_id}\n";
            },
            on_error   => sub { warn "tdlib: $_[0]\n" },
        );

        # a die inside a callback is contained and reported, not propagated, so
        # leaving the loop is what ends the program; see L</ERROR HANDLING>
        my $status = 0;
        $td->login(sub {
            my (undef, $err) = @_;
            if ($err) {
                warn "login failed: $err->{message}\n";
                $status = 1;
                return EV::break;
            }
            $td->send_message($chat_id, 'hello', sub {
                my ($msg, $err) = @_;
                warn "send failed: $err->{message}\n" if $err;
                $status = 1 if $err;
                $td->close(sub { EV::break });
            });
        });

        EV::run;
        exit $status;

DESCRIPTION
    EV::Telegram::TDLib binds TDLib's tdjson C interface to the EV event
    loop. A dedicated reader thread blocks in td_receive, copies each JSON
    result, and wakes the loop through ev_async; the loop decodes,
    correlates replies to pending requests by @extra, drives the
    authorization state machine, maintains user and chat caches, and calls
    your handlers.

    Asynchronous callbacks follow the family idiom: they receive "($result,
    $err)" where $err is undef on success and a decoded TDLib error object
    on failure. A TDLib error is never thrown; see "CONVENTIONS" for what
    does croak.

    Requires a perl with 64-bit integers: Telegram ids are int64 and message
    ids are shifted left by 20 bits, so they must never round-trip through
    an NV. The Makefile refuses to build otherwise.

    The bundled TDLib is 1.8.66, pinned by Alien::TDLib at commit
    022d60202e446ad1287b9fb68e687c8a0760788b.

CONSTRUCTOR
  new(%opt)
    Creates a client and registers it in a process-global registry. The
    client is held under a strong reference until close() completes; see
    "CAVEATS". Options:

    api_id, api_hash
        Telegram application credentials from https://my.telegram.org. Keep
        them in the environment, not in source; see "SECURITY".

    phone_number
        Phone number in international format for user authorization. Used
        when the state machine reaches authorizationStateWaitPhoneNumber,
        unless bot_token is present. Setting on_qr as well does not override
        it: a QR link is requested only when on_qr is set and no
        phone_number was given.

    bot_token
        Bot token from BotFather. When present it is sent automatically at
        authorizationStateWaitPhoneNumber and no further credential
        callbacks are needed.

    database_directory
        Session and database directory. Default "tdlib-db". See "SECURITY".

    files_directory
        Downloaded files directory. Defaults to database_directory.

    database_encryption_key
        Encryption key for the local database. Empty by default; set it. Any
        byte string will do, but it must be bytes: a character above 255 is
        rejected while the first request is being built, which happens
        during update dispatch, so it reaches "on_error" rather than
        croaking out of new(). It is base64-encoded on the way out, so what
        you pass is what keys the database.

        Changed in 0.04. 0.03 sent this value raw, which TDLib refused for
        most passphrases -- taking login with it -- while accepting one that
        happened to look like base64 and keying the database with the
        decoded bytes instead. If a 0.03 database opened with such a key,
        open it now with MIME::Base64::decode_base64($old_key).

        Changing it later needs set_database_encryption_key(), and losing it
        loses the database.

    use_test_dc
        Use the Telegram test data centers instead of production. Always set
        this in tests.

    use_file_database, use_chat_info_database, use_message_database,
    use_secret_chats
        TDLib feature switches, all defaulting to true. TDLib turns a switch
        back on when a later one needs it -- the message database needs the
        chat info database, which needs the file database -- so
        "use_chat_info_database => 0" does nothing unless
        "use_message_database => 0" too, and "use_file_database => 0" needs
        both.

    application_name
        The platform identifier sent with every Mini App request, which the
        app receives as "tgWebAppPlatform". Defaults to "tdesktop". See
        "MINI APPS" for why the value matters and what it may contain.

    system_language_code, device_model, system_version, application_version
        Client identification sent with setTdlibParameters. Defaults: "en",
        "EV::Telegram::TDLib", $^O, this distribution's version.

    auto_auth
        Drive the authorization state machine automatically (default true).
        With auto_auth false, only the login and close lifecycle
        continuations run; every credential step is left to you via send().

    register
        Hashref "{ first_name => ..., last_name => ... }". When set,
        authorizationStateWaitRegistration is answered with registerUser;
        without it the state fails login.

    on_update, on_error, on_close, on_user, on_chat, on_message,
    on_connection_state
        Update handlers; see "UPDATES" and the mixin methods below.

    on_code, on_password, on_email, on_email_code, on_qr
        Authorization credential callbacks; see "AUTHORIZATION".

CONVENTIONS
    Three patterns run through the whole interface. Knowing them saves
    reading 300 method signatures.

  Cache readers against remote getters
    "chat($id)" and "user($id)" are synchronous cache reads: they take no
    callback, do no I/O, and return undef for something the client has not
    seen. Everything else named after a noun -- "folder", "topic",
    "secret_chat", "supergroup", "basic_group", "message", "file" -- is an
    asynchronous getter that takes a callback and asks TDLib.

    The two cache readers predate the rest and are kept as they are because
    renaming a method already published on CPAN would break working code.
    When you want the server's answer for a chat rather than the cached one,
    use fetch_chat().

  Options and handlers
    Options are "name => value" pairs after the positional arguments. An odd
    number of them croaks: a name that lands in a positional slot shifts
    every pair after it, so the request ends up saying something you never
    asked -- "install_sticker_set($id, archived => 1)" installed rather than
    archived, because "archived" was read as the $installed positional.

    A flag is any true or false value, as perl reads it, and a JSON boolean
    object works too. "\0" is a reference, so it is true to perl, not the
    false it is in JSON: pass 0. A flag passed straight through croaks on a
    reference that is not an object.

    The callback is the last argument and is recognised by being a coderef,
    so an extra argument cannot displace it. Written anywhere else it croaks
    rather than being taken for a value: before the options it would land in
    an optional flag or number, and be dropped. send() and call() are the
    exception, and take it before their options as well. A method that takes
    no options croaks on anything past its positional arguments, so
    "set_chat_title($chat, title => 'New')" is refused rather than renaming
    the chat "title".

    A handler that holds one callback -- on_update, on_error, on_message and
    the rest of the single-slot "on_*" methods -- is a getter when called
    with no argument, a setter when called with one, and is removed by
    passing "undef". What counts is that an argument was passed at all, so
    any false value removes it too: "$td->on_error($h{error})" with no
    "error" key in %h takes the handler away, including one given to new(),
    and errors go back to warning on stderr. Forwarding a handler you may
    not have is the way to lose one silently, so test for it before passing
    it. Changed in 0.04: 0.03 ignored a false value, so a handler could not
    be removed at all. The keyed ones, on_command, on_callback_data and
    on_upload, take the key first and remove that one entry when passed
    "undef"; they are not getters and croak without a callback. "on_close"
    and the login handlers ("on_code", "on_password", "on_email",
    "on_email_code", "on_qr") are constructor options with no method of
    their own.

    Three options recur widely enough that the entries below do not repeat
    them. "parse_mode" ("markdown" or "html") applies wherever a method
    takes text or a caption to format, and a text that fails to parse is
    reported to the callback with nothing sent. Wherever the text can carry
    formatting -- a message, a caption, a poll, a gift or story text, a
    contact note -- a formattedText hashref, which is what translate and
    parse_markdown answer with, is also accepted and sent as it is. A
    plain-text field such as a bio or a chat description refuses one. An
    object that stringifies, such as a URI or a Path::Tiny path, is text
    anywhere. Telegram limits how long a name or description may be, and
    TDLib cuts an over-long one without an error: a chat description at 255
    characters, a chat or topic title at 128, an account or business name
    and a sticker set title at 64, a bio at 70 (140 for Premium), an invite
    link name at 32 and a folder name at 12. A formatted text keeps at most
    100 entities, links, code and pre spans first, and the rest are dropped
    the same way. "reply_markup" takes a keyboard from
    "inline_keyboard(\@rows)" or reply_keyboard() wherever a message is sent
    or edited. "business_connection_id" makes a Stars or Payments call act
    for a connected business account rather than for you -- invoice_link,
    received_gifts, sell_gift, transfer_gift and upgrade_gift take it.

  Paging
    "limit" is a page size everywhere except invite_link and
    edit_invite_link, where it is the link's own member limit -- how many
    people may join through it. TDLib caps it without saying so: message
    history, search, load_chats, profile_photos, message_reactions,
    search_stickers and the server half of search_chats at 100,
    supergroup_members and search_members at 200, and top_chats at 30. So
    "limit => 500" quietly returns a hundred, and a loop that stops on a
    short page stops early. "offset" is two different things, because TDLib
    makes it two different things, and passing the wrong kind gets you an
    error from the server rather than from here.

    In six it is a number of entries to skip: blocked, profile_photos,
    search_stickers, story_album_stories, supergroup_members and
    trending_sticker_sets.

    In twelve it is an opaque string cursor that the previous response gave
    you, and the empty string asks for the first page:
    chat_revenue_transactions, chat_story_interactions,
    connected_affiliate_programs, inline_query, message_reactions,
    received_gifts, search_all, search_secret_messages, star_subscriptions,
    star_transactions, story_interactions and story_public_forwards.

    In the three that walk a chat's messages -- search_messages,
    thread_history and topic_history -- it is neither. There it works with
    "from_message_id" and must be 0 or negative: 0 starts exactly at that
    message, and a negative value additionally returns that many newer ones.
    A positive value is refused by the server, and so is one larger than
    "limit" allows: thread_history and topic_history want "offset >=
    -limit", search_messages the stricter "limit > -offset". At the default
    limit of 50 that is -50 to 0, and -49 to 0 respectively. thread_history
    and topic_history also stop at -99 however large "limit" is.

    Methods that page by an id of their own -- "from_message_id",
    "from_story_id", "offset_chat_id", "offset_sticker_set_id" -- name it
    after what it is, and take the id to start from with 0 or the empty
    string meaning the beginning.

  Booleans
    A method that turns something on or off takes the flag as its last
    positional argument and defaults to true, so "pin_topic($chat, $id)"
    pins and "pin_topic($chat, $id, 0)" unpins. That covers close_topic,
    pin_topic, pin_chat, mark_unread, hide_general_topic, folder_tags,
    pause_download, protect_content, default_disable_notification,
    toggle_username, toggle_bot_username, toggle_attachment_menu,
    session_accepts_secret_chats, the supergroup switches, the
    process_join_request pair, and in 0.04 install_sticker_set,
    post_story_to_page, toggle_gift_saved, edit_star_subscription and
    pause_connected_bot.

    A "name => value" pair written where the flag belongs puts the name in
    the flag, and a name is always truthy, so the call would do the opposite
    of what it says. Every one of them refuses that rather than acting on
    it.

    Two older methods invert through an option instead ("block_user($id,
    unblock => 1)", "react(..., remove => 1)"), and a few pairs are separate
    methods where the two directions differ in more than a flag:
    "mute"/"unmute", "archive"/"unarchive", "enable_proxy"/"disable_proxy".

  Identifiers
    TL "int64" values cross the JSON interface as strings, because a number
    would lose precision above 2**53. This module does that for you, and
    hands them back as strings: session ids, callback and inline query ids,
    profile photo ids, custom emoji ids and Web App launch ids are all
    strings you should keep as strings. Chat, message and user ids are
    "int53" and stay numbers.

    Those numeric ids are checked for shape, not just for presence, and so
    is every other number an argument carries: a limit, an offset, a
    duration, a coordinate, each id in a list. An integer is an optionally
    signed run of digits, with surrounding space allowed; anything else
    croaks rather than being coerced. That is what catches an option name
    landing in an id slot -- "mark_unread(unread => 0)" with the chat
    omitted put "unread" in "chat_id" -- which perl otherwise turns into a
    zero, with a warning from inside this module and a JSON float in a slot
    the schema declares an integer. It also catches a reference, which perl
    turns into its memory address: "reply_to => $message", the message
    rather than its id, sent that address, and TDLib quietly sent a plain
    message instead of a reply.

    A point in time -- "schedule", reschedule's $when, a ban's "until", an
    emoji status's "expires" -- is a Unix time, and a small positive number
    there croaks as the duration it almost certainly is. TDLib would read it
    as a moment in 1970 and, without an error, send the scheduled message at
    once, make the ban permanent or clear the status.

USERS AND BOTS
    TDLib refuses much of its API to one kind of account before anything
    reaches the server. A user-only method called from a bot session fails
    with "The method is not available to bots", and a bot-only one called
    from a user session with "Only bots can use the method"; both arrive at
    the callback like any other error. It is set out once here, as TDLib
    1.8.66 has it, and repeated below only where it shapes how a method is
    used.

    These are for bots only: answer_callback_query, answer_inline_query,
    answer_pre_checkout_query, answer_shipping_query, answer_web_app_query,
    bot_access_settings, bot_token, business_account_star_amount,
    business_connection, callback_query_message, commands, delete_commands,
    delete_business_messages, edit_business_message_text, the edit_inline_*
    methods, edit_message_markup, game_high_scores, inline_game_high_scores,
    invoice_link, menu_button, read_business_message, refund_star_payment,
    send_business_file, send_business_message, set_bot_access_settings, the
    set_business_account_* setters, set_commands, set_default_admin_rights,
    set_game_score, set_inline_game_score, set_menu_button, set_reactions
    and set_updates_status. So are the updates behind on_callback_query,
    on_inline_query, on_join_request, on_web_app_data, the pre-checkout and
    shipping handlers and the business message handlers: a user session
    never receives them.

    Sending, editing, forwarding and deleting messages and files work for
    both, as do creating and editing forum topics, sticker sets and proxies.
    Chat administration is split. For both: a member's status and bans,
    reading members and administrators, a chat's title, description, photo
    and permissions, creating, editing and revoking an invite link, and
    pinning. For users only: adding members, listing invite links and who
    joined through one, the bulk deletions (delete_messages_by_sender,
    delete_messages_by_date, delete_history), reschedule, and most
    supergroup switches -- slow mode, auto-delete, protected content,
    signatures, join rules, transfer_ownership and the event log.

    Most of the rest is for users only, in particular anything that reads
    the account's own view of Telegram: the chat list and history, message
    search, contacts, folders, the forum topic list, reading, editing and
    deleting stories (posting one works for both), sessions, privacy and
    notification settings, and react. sell_gift, transfer_gift and
    upgrade_gift are for users, or for a bot passing
    "business_connection_id".

METHODS
  Core
   send(\%request, $cb, %opt)
    Encodes the request, assigns a fresh @extra, and hands it to TDLib. The
    reply is delivered as "$cb->($result, $err)". Returns the assigned
    @extra sequence number.

    The callback may be written after the options instead, as it is on every
    other method: send and call are the only two that name it first, and
    both positions are accepted. Before 0.04 only the first was, so
    "send($request, timeout => 5, $cb)" -- the shape "CONVENTIONS" describes
    -- croaked.

    send() deliberately overwrites any caller-supplied @extra: it is the
    reply correlation channel, and a collision would misroute a reply to the
    wrong callback.

    Option: "timeout" in seconds. A timed-out request fails its callback
    with a synthetic error; the late reply, if it ever arrives, is dropped
    with a warning, never delivered to a reused @extra. See "ERROR
    HANDLING".

    Option: "retry", off unless asked for. See "Rate limiting: error code
    429" for the policy and its one important limit. Pass "retry => 1" for
    the defaults, or a hashref:

        retry => { attempts => 3, max_wait => 300, factor => 2, margin => 1 }

    "attempts" counts retries after the first send, so the default of 3
    means at most four requests. "max_wait" is a give-up threshold, not a
    clamp: if the server asks for longer than it, the 429 is returned
    unchanged rather than retried sooner than demanded. "factor" multiplies
    the module's own added margin on repeated 429s; it never shortens the
    delay the server stated. "margin" is that added margin in seconds, 1 by
    default, so the first retry waits the server's delay plus a second. A
    retry is issued only for a 429 that carries a parseable delay, never for
    any other error.

    A retry carries a fresh @extra, so the value send() returned identifies
    the first attempt only.

    "timeout" and "retry" are options of send() and call() only. The named
    methods have their own %opt namespace -- "kind", "caption", "wait",
    "silent" and so on -- and do not forward transport options, so
    "send_message($chat, $text, retry => 1, $cb)" accepts the key and
    ignores it. A named method that takes no options at all croaks on one
    instead. Set "retry" on the constructor to cover every request a client
    makes, including those from the named methods; there is no per-call
    equivalent for them.

    On a closed client send() sends nothing, registers nothing and returns
    undef: the callback is failed deferred with a synthetic "client is
    closed" error. A request waiting out a retry backoff when the client
    closes is failed too, with "client closed".

   execute(\%request)
    Synchronous td_execute. No network, usable before authorization, and
    usable as a class method as well as an instance method:

        my $me = EV::Telegram::TDLib->execute({ '@type' => 'getMe' });

    Only the TDLib methods documented as synchronous return a meaningful
    result here; anything else returns undef or an error.

   login($cb)
    Completes when the authorization state machine (see "AUTHORIZATION")
    reaches authorizationStateReady: "$cb->(undef, undef)". On failure the
    callback receives a decoded or synthetic error. The callback never fires
    synchronously, even when the state is already settled. Calling login()
    again before Ready chains the callbacks, as with close(); none is
    dropped. A login that has already failed fails a later login() deferred
    with the recorded error instead of hanging: the state machine stays in
    the failed state and never re-emits it.

   auth_state()
    Returns the last seen authorization state name.

   close($cb)
    Sends "{"@type":"close"}" and calls $cb once authorizationStateClosed
    arrives. close() is not optional; see "CAVEATS". Calling close() a
    second time before Closed is legal: the callbacks chain and none is
    dropped.

   keepalive([$on])
    An open client holds an ev_ref on the default loop so EV::run does not
    return while TDLib traffic is pending. keepalive(0) releases it, so the
    loop may exit with the client still open. Defaults to on. The reference
    is accounted per client: close() releases it only while still held, and
    keepalive() on a closed client is a no-op that returns off.

    With no argument this is a getter, like the "on_*" accessors: it reports
    the current setting and changes nothing. Before 0.04 it took the
    reference back, so a program that had released it re-pinned the loop
    merely by asking whether it had.

   on_update($cb), on_error($cb)
    Get or set the generic update and error handlers. on_error receives
    non-fatal internal errors (undecodable frames, callback exceptions);
    without it they go to warn.

   retry_after($err)
    Returns the delay in seconds that a 429 error asks for, parsed out of
    its message text, or undef for any other error or when no delay is
    stated. Usable as a method or a plain function. See "Rate limiting:
    error code 429"; the module still performs no retry of its own.

   call($function, \%args, $cb, %opt)
    Sends a raw request like "send(\%request, $cb, %opt)", but checks the
    argument names first against a catalogue of every TDLib function,
    generated from the "td_api.h" that Alien::TDLib ships. The @type is
    filled in from $function, so it is not repeated.

        $td->call(getChatMember => { chat_id => $c, member_id => $m }, sub {
            my ($member, $err) = @_;
            ...
        });

    A typo in a known function's arguments croaks and lists the valid names.
    An unknown function is passed straight through, so a TDLib newer than
    the shipped catalogue keeps working; a missing argument is not an error,
    since TDLib supplies its own defaults. This makes call() a usable way to
    reach the roughly 600 functions this module does not wrap by hand,
    without losing every check.

    "\%args" may be left out for a function that takes none, so
    "$td->call(getMe => sub { ... })" works. %opt is passed through to
    send(), so "timeout" and "retry" work here too.

   on_command($name => $cb), on_callback_data($pattern => $cb)
    Route incoming commands and inline-button presses. Both are methods, not
    constructor options: passing either to new() croaks, because new() lifts
    every "on_*" key onto the object and would otherwise store a callback
    that never fires.

        $td->on_command(start => sub {
            my ($msg, $args) = @_;
            $td->send_message($msg->{chat_id}, "hello $args");
        });

    The name is given without a leading slash, though one is accepted and
    stripped. Matching is case sensitive. The callback receives exactly
    "($msg, $args)", where $args is the rest of the line with leading
    whitespace removed, and the empty string when nothing follows; split it
    yourself if you want fields.

    "/name@username" fires only when the username is this account's own.
    That username is fetched in the background, once, either on reaching the
    ready state or when the first handler is registered, whichever comes
    later -- so registering handlers inside login()'s callback works. A
    command addressed with "@" that arrives before the fetch returns does
    not match; the plain "/name" form is never affected.

    Matching uses the bot-command entity at offset 0 when TDLib supplies
    one, so a message merely containing "/start" later in the line does not
    fire. Outgoing messages never fire a handler, so a bot echoing help text
    cannot trigger itself.

    "on_command($name => undef)" unregisters.

    on_callback_data takes a regex or a literal string. A literal is an
    exact match, so use a regex for the conventional "prefix:value" form.
    Handlers are tried in registration order and every match fires; the
    callback receives the query followed by any regex captures. Callback
    data is decoded from its wire encoding first, so patterns are written
    against the plaintext the bot set. Game callback queries, which carry no
    data, are skipped.

    "on_callback_data($pattern => undef)" unregisters every route registered
    with that pattern.

    Both routers dispatch against a snapshot of their handlers, so a handler
    may register or unregister routes while it runs: the change takes effect
    from the next update, never the one being dispatched. Without that, a
    handler that registers a route would dispatch to it immediately, and one
    that re-registers itself would never stop.

    Both routers fire in addition to "on_message($cb)" and
    "on_callback_query($cb)": a matched command does not consume the
    message.

   ask($chat_id, $user_id, $prompt, %opt, $cb), cancel_ask($chat_id, $user_id)
    Sends $prompt and waits for that user's next message in that chat, once.
    The callback receives "($msg, undef)" on an answer and "(undef, $err)"
    on failure, as everything else here does.

        $td->ask($chat, $user, 'What is your name?', sub {
            my ($reply, $err) = @_;
            return warn "no answer: $err->{message}\n" if $err;
            $td->send_message($chat, "hello $reply->{content}{text}{text}");
        });

    Options are those of "send_message($chat_id, $text, %opt, $cb)", plus
    "timeout" in seconds, defaulting to 300. "timeout => 0" waits
    indefinitely; read the next paragraph before choosing it.

    While an ask is pending it shadows the command router for that user in
    that chat: the answer goes to the ask and to on_message, but not to a
    command handler. That is deliberate, so a reply that happens to start
    with a slash cannot both answer the prompt and run a command. It does
    mean an unanswered prompt suppresses that user's commands until it
    resolves, which is why the timeout has a default and why cancel_ask
    exists. cancel_ask fails the pending ask with an "ask cancelled" error
    and returns whether there was one; a timeout fails it with "ask timed
    out".

    Asking the same user in the same chat again replaces the pending ask,
    failing the old one with "ask superseded" rather than dropping it.
    Asking from inside an answer or timeout callback is the intended way to
    build a multi-step flow and works as expected. Anonymous and channel
    senders never satisfy an ask, since it is keyed on a user, and neither
    do outgoing messages. If the prompt itself fails to send, the ask fails
    immediately rather than waiting out its timeout.

    send() is unaffected and stays entirely unvalidated.

  Users mixin
   me($cb)
    Fetches the current user (getMe) into the user cache and calls
    "$cb->($user, $err)".

   user($id)
    Returns the cached user hashref, or undef.

   set_name($first, $last, $cb), set_bio($text, $cb), set_username($name, $cb)
    Change the signed-in account's own profile. set_name requires a first
    name; the last name is optional. set_username takes the name with or
    without a leading at-sign, and an empty string removes it.

    These are user-account methods. A bot session is refused them by TDLib
    with "The method is not available to bots"; a bot changes its own
    profile through the Bots mixin instead.

   set_profile_photo($path, %opt, $cb)
    Sets the account's profile photo. "animation" treats the file as a video
    avatar, with "main_frame_timestamp" (seconds, default 0) selecting the
    still frame. "public => 1" sets the public photo instead: the fallback
    shown to users whom privacy settings deny the main one, which a user
    account alone can have. $path may be an InputFile hashref instead of a
    path. A bot sets its own photo this way too, or through set_bot_photo in
    the Bots mixin.

    Changed in 0.04. 0.03 set the public photo, so the same call now
    replaces the account's real one. Pass "public => 1" to keep 0.03's
    behaviour.

   on_user($cb)
    Handler for updateUser, called with the decoded user after the cache is
    updated.

   user_by_username($name, $cb)
    Resolves a public @name to a user, with or without the leading at-sign.
    A name that resolves to a channel or a group is reported as an error
    saying so, since only a private chat has a user behind it. Bots are
    users and resolve normally.

   set_birthdate(%opt, $cb), set_accent_color($color_id, %opt, $cb), profile_photos($user_id, %opt, $cb), delete_profile_photo($photo_id, $cb)
    Profile details. set_birthdate takes "day" and "month" and an optional
    "year"; calling it with none of the three clears the birthdate, and a
    day, month or year without the other two date parts croaks. The month
    runs from 1, not from the 0 that "localtime" gives, and a date that does
    not exist or a year outside 1800 to 3000 croaks: TDLib would take the
    first as a request to clear the birthdate and drop the second, both
    without an error. set_accent_color takes "background_custom_emoji_id", a
    custom emoji shown on the reply header and link preview background;
    omitting it clears any current one. Profile photo ids are TL "int64" and
    are sent as strings.

   contacts($cb), add_contact($user_id, %opt, $cb), remove_contacts(\@user_ids, $cb), search_contacts($query, %opt, $cb), import_contacts(\@contacts, $cb)
    The address book. add_contact options are "first_name", "last_name",
    "phone", "note" and "share_phone", which offers your own number in
    return. import_contacts takes hashrefs of the same shape and is how you
    find which of a list of phone numbers are on Telegram; Telegram matches
    on the number, so a contact with none is simply not matched.

   search_by_phone($phone_number, %opt, $cb), my_link($cb), toggle_username($username, $active, $cb)
    Finding a user by phone number, this account's own t.me link, and
    turning one of your usernames on or off. "local => 1" restricts the
    phone lookup to what is already cached.

   privacy($setting, $cb), set_privacy($setting, \@rules, $cb)
    Read and write one privacy setting, named "status", "profile_photo",
    "phone", "bio", "birthdate", "forwards", "invites", "calls" or
    "find_by_phone". Rules are an ordered list of UserPrivacySettingRule
    hashrefs and the first match wins, so their order is the policy.

  Chats mixin
   chat($id)
    Returns the cached chat hashref, or undef. The cache is fed by
    updateNewChat and kept current by the chat-field updates listed under
    "UPDATES".

   on_chat($cb)
    Handler for updateNewChat, called with the decoded chat.

   load_chats($limit, %opt, $cb)
    Loads more chats from TDLib (loadChats). TDLib answers with a 404 error
    once the list is exhausted; that is reported as success, not failure.
    Takes "list", so the archive and the folders can be paged too; without
    it only the main list is ever fetched.

   pin_message($chat_id, $message_id, %opt, $cb), unpin_message($chat_id, $message_id, $cb)
    Pins or unpins a message. Options: "silent" to pin without notifying,
    "only_for_self" to pin it just for you.

   set_chat_title($chat_id, $title, $cb), set_chat_photo($chat_id, $path, %opt, $cb)
    Changes a chat's title or photo. set_chat_photo takes the same
    "animation" and "main_frame_timestamp" options as
    "set_profile_photo($path, %opt, $cb)".

   add_chat_member($chat_id, $user_id, %opt, $cb)
    Adds a user to a chat. "forward_limit" (default 0) is how many recent
    messages they get to see, in a basic group only: a supergroup or channel
    ignores it.

   set_member_status($chat_id, $user_id, $status, %opt, $cb)
    Sets a member's status: "member", "left" or "banned". "left" is the
    plain kick, which leaves them free to come back; "banned" removes and
    blocks them. "until" is a unix timestamp for a temporary ban, 0 (the
    default) meaning forever; TDLib ignores it for "member", so a membership
    is always permanent. A temporary ban must fall between 30 seconds and
    366 days from now: TDLib silently makes anything outside that permanent,
    so a short cool-off computed from a config value bans for good. An
    unknown status croaks.

   block_user($user_id, %opt, $cb)
    Blocks a user. "unblock" reverses it, and "stories" acts on the stories
    block list rather than the main one.

   join_chat($chat_id, $cb), leave_chat($chat_id, $cb)
    Joins or leaves a chat. joinChat answers with a ChatJoinResult, which
    reports a join request awaiting approval as well as a plain success.

   chat_by_username($name, $cb)
    Resolves a public username (with or without the leading @) to a chat via
    searchPublicChat and caches it.

   mark_read($chat_id, %opt, $cb)
    Marks messages read with a single viewMessages, which TDLib honours on a
    chat that is not open because the request names its source and forces
    the read. Opening the chat would be worse than useless: it puts the chat
    at the top of the account's recently opened list, where closing it again
    does not remove it. "message_ids" defaults to the chat's last message,
    so "$td->mark_read($chat_id, sub {})" clears a chat. Fails if nothing is
    known to mark.

   chat_action($chat_id, $action, $cb)
    Sends a chat action, the "typing..." class of indicator. $action is one
    of "typing" (the default), "upload_document", "upload_photo",
    "upload_video", "upload_voice", "record_video", "record_voice",
    "cancel". An unknown action croaks. The indicator expires on its own
    after a few seconds, so repeat it while the work lasts.

   member($chat_id, $user_id, $cb), admins($chat_id, $cb), search_members($chat_id, $query, %opt, $cb)
    Read a chat's membership. search_members options: "limit" (default 50)
    and "filter", one of "contacts", "administrators", "members",
    "restricted", "banned", "bots"; an unknown filter croaks.

   set_permissions($chat_id, \%permissions, $cb)
    Sets the default permissions for ordinary members. TDLib replaces the
    whole set, so any permission not named is denied, and this method sends
    all of them explicitly rather than leaving the difference implicit.
    Valid keys are the chatPermissions fields: "can_send_basic_messages",
    "can_send_audios", "can_send_documents", "can_send_photos",
    "can_send_videos", "can_send_video_notes", "can_send_voice_notes",
    "can_send_polls", "can_send_other_messages", "can_add_link_previews",
    "can_react_to_messages", "can_edit_tag", "can_change_info",
    "can_invite_users", "can_pin_messages", "can_create_topics".

    An unrecognised key croaks rather than being ignored: since absence
    means denial, a typo would quietly take a right away.

   set_chat_description($chat_id, $description, $cb)
    Sets the description shown on a group or channel's profile. It is plain
    text, not formatted: pass the empty string to clear it. Leaving it out
    croaks.

   invite_link($chat_id, %opt, $cb), edit_invite_link($chat_id, $link, %opt, $cb), invite_links($chat_id, %opt, $cb), revoke_invite_link($chat_id, $link, $cb), replace_primary_invite_link($chat_id, $cb), invite_link_members($chat_id, $link, %opt, $cb)
    Manage a chat's invite links. Creating and editing accept "name",
    "expires" (a Unix time), "limit" (maximum members) and "join_request",
    which makes the link produce join requests to approve instead of
    admitting people directly; answer those with "join_requests($chat_id,
    %opt, $cb), process_join_request($chat_id, $user_id, $approve, $cb),
    process_join_requests($chat_id, $approve, %opt, $cb),
    on_join_request($cb)". TDLib replaces the whole link on an edit, so any
    option not passed reverts to its default: renaming a link clears its
    expiry and member limit. invite_links lists them, filtered by "creator"
    and "revoked" and paged with "offset_date", "offset_link" and "limit";
    invite_link_members lists who joined through one, "limit" at a time (up
    to 100), paged by passing the last member of a page as "offset_member";
    "expired_only => 1" returns only members whose subscription has lapsed,
    and applies only when the link is a subscription link.

   join_requests($chat_id, %opt, $cb), process_join_request($chat_id, $user_id, $approve, $cb), process_join_requests($chat_id, $approve, %opt, $cb), on_join_request($cb)
    The other half of a "join_request" invite link. join_requests lists who
    is waiting, filtered by "link" and "query" and bounded by "limit"; pass
    the last request of a page as "offset_request" for the next.
    process_join_request answers one, and the plural form answers everyone
    at once, optionally only those who used one "link". $approve defaults to
    true in both, so declining is explicit.

    on_join_request is the handler for updateNewChatJoinRequest, called with
    a flattened hashref carrying "chat_id", "user_id", "date", "bio",
    "invite_link" and "user_chat_id".

    Which account may call what differs here. on_join_request fires only for
    a bot, and join_requests and process_join_requests only work for a user;
    process_join_request works for both. So a bot answers each request as it
    arrives, and a user lists the queue.

   check_invite_link($link, $cb), join_by_link($link, $cb)
    Inspect or accept an invite link. These take only the link, since the
    chat is whatever it points at, and work for a chat you are not in.

   mute($chat_id, $seconds, $cb), unmute($chat_id, $cb)
    Silences a chat for a number of seconds, or indefinitely when no
    duration is given. Notification settings are a single object in TDLib,
    and a write replaces all of it, so these send the chat's cached settings
    back with only the mute changed: a chat's own sound and preview choices
    survive. A chat this client has not seen has nothing cached, and gets
    "use the default" for every other field.

   archive($chat_id, $cb), unarchive($chat_id, $cb), pin_chat($chat_id, $pinned, %opt, $cb), mark_unread($chat_id, $unread, $cb)
    Chat list housekeeping. The $pinned and $unread flags default to true,
    so pin_chat($chat) pins and "pin_chat($chat, 0)" unpins. pin_chat takes
    a "list" option, since a chat can be pinned separately in each list.

   chats(%opt, $cb), search_all($query, %opt, $cb)
    chats lists a chat list; search_all searches messages across every chat,
    unlike "search_messages($chat_id, $query, %opt, $cb)", which searches
    inside a single chat. Both take "list" and "limit"; search_all also
    takes "offset", "min_date" and "max_date".

    Wherever a "list" option appears it is "main" (the default), "archive",
    or a chat folder id as a number. search_all is the exception: with no
    "list" it searches every chat, archived ones included, and it can be
    narrowed to "main" or "archive" but not to a folder, which croaks.

    Changed in 0.04. search_all with no "list" searched the main list in
    0.03, so the same call now returns archived matches too. Pass "list =>
    'main'" for the old result.

   mute_scope($scope, $seconds, %opt, $cb), scope_settings($scope, $cb), reset_notifications($cb)
    Notification defaults for a whole class of chat, where $scope is
    "private", "groups" or "channels". Every scopeNotificationSettings field
    is sent outright, and unlike the per-chat settings the scope ones have
    no "use the value from elsewhere" flag at all -- its one "use_default_"
    field means something else, that story notifications come from your top
    contacts whatever "mute_stories" says. So every call replaces all of
    them, and anything you do not pass reverts to this module's default
    rather than staying as it was. Muting a scope for an hour therefore also
    turns previews back on unless you pass "show_preview => 0" with it. Read
    the current values with scope_settings and pass them back if you are
    changing one thing. The defaults are: both sounds ask for the app
    default (the TL spells that -1; 0 would disable the sound), previews and
    the story poster shown, pinned and mention notifications enabled, and
    story notifications left to that top-contacts rule unless you pass
    "mute_stories" yourself. Options: "show_preview" (also accepted as
    "preview", its original spelling), "no_pinned", "no_mentions",
    "sound_id", "mute_stories", "story_sound_id", "show_story_poster".
    reset_notifications puts everything back, including per-chat overrides.

   set_chat_reactions($chat_id, $reactions, %opt, $cb)
    Chooses which reactions a chat allows. Pass 'all' for everything the
    chat's tier permits, or an arrayref to restrict it; each element is an
    emoji string or "{ custom_emoji_id => $id }". Option: "max" for how many
    a single message may carry.

   read_all_reactions($chat_id, $cb), set_reaction_notifications(%opt, $cb)
    Mark every reaction in a chat as read, and configure which reactions
    raise a notification.

    set_reaction_notifications takes all five fields, because TDLib offers
    no getter for them: the current value arrives only through an update, so
    defaulting a field would silently clear it. "message_reaction_source",
    "story_reaction_source" and "poll_vote_source" are each 'none',
    'contacts' or 'all'; "sound_id" is an int64 notification sound id
    (stringified for you), and "show_preview" is a flag.

   blocked(%opt, $cb)
    Lists blocked senders. Options: "offset", "limit", and "stories" to read
    the separate list of senders whose stories are hidden.

   add_members($chat_id, \@user_ids, $cb), ban_member($chat_id, $user_id, %opt, $cb), transfer_ownership($chat_id, $user_id, $password, $cb), set_default_admin_rights(\%rights, %opt, $cb)
    Bulk membership and ownership. ban_member differs from
    "set_member_status($chat_id, $user_id, $status, %opt, $cb)" in taking
    "revoke", which also deletes what the banned member already sent, and
    "until" for a temporary ban. "revoke" reaches the server only for a
    basic group; in a supergroup or channel TDLib drops it, so purge with
    delete_messages_by_sender() instead. transfer_ownership needs the
    account password, which Telegram requires for an irreversible act.
    set_default_admin_rights sets what a bot asks for when added as an
    administrator; "channel => 1" targets channels rather than groups.

   create_group($title, %opt, $cb), upgrade_to_supergroup($chat_id, $cb), delete_chat($chat_id, $cb), delete_history($chat_id, %opt, $cb)
    create_group makes a supergroup by default; "channel" makes a channel
    and "forum" makes a forum. Passing "members" instead creates a basic
    group, which is a different TDLib call and needs its members up front.
    Also takes "description", "auto_delete" and "for_import", which creates
    the group ready to receive an imported message history. delete_history
    options: "remove_from_list" and "revoke", which deletes for everyone
    rather than only for you.

   set_slow_mode($chat_id, $seconds, $cb), set_auto_delete($chat_id, $seconds, $cb), set_discussion_group($chat_id, $discussion_chat_id, $cb), protect_content($chat_id, $on, $cb)
    Group settings: how often a member may post, how long messages live,
    which group holds a channel's comments, and whether forwarding and
    saving are blocked. The value is required: passing 0 clears the first
    two and unlinks the discussion group, and leaving it out or passing
    undef croaks rather than doing the same, since an undef from a failed
    lookup would otherwise unlink a channel's comments.

   make_forum($id, $on, %opt, $cb), sign_messages($id, $on, %opt, $cb), join_by_request($id, $on, %opt, $cb), join_to_send($id, $on, $cb), all_history_available($id, $on, $cb), hide_members($id, $on, $cb), set_supergroup_username($id, $username, $cb)
    Supergroup and channel switches. make_forum is what turns an ordinary
    supergroup into one that has topics, which create_topic() needs.

    These take a supergroup id, which is not the chat id you use everywhere
    else: a supergroup's chat id is -1000000000000 minus its supergroup id.
    Passing either works, because a negative id is converted for you. The
    flag defaults to true in all of them.

    make_forum takes "tabs => 1" to show the forum's topics as tabs rather
    than as a list. It is sent every time, so calling make_forum on a
    supergroup that is already a forum with tabs, and not passing "tabs",
    turns them off -- TDLib treats the call as setting both flags and only
    ignores it when both already match. Read "has_forum_tabs" from
    supergroup() first if you are toggling something else about an existing
    forum.

    sign_messages adds a sender signature to channel posts; "show_sender =>
    1" adds a link to the sender's account with it. "show_sender" is sent
    every time, like make_forum's "tabs", so signing a channel that already
    shows senders without passing it takes that link away. join_by_request
    takes "guard_bot", the user id of the bot that guards the group (0 for
    none, and ignored when the flag is false), and "apply_to_links => 1" to
    apply the change to existing invite links, primary links included.

   fetch_chat($chat_id, $cb), close_chat($chat_id, $cb), user_full_info($user_id, $cb), supergroup($id, %opt, $cb), basic_group($id, %opt, $cb), supergroup_members($id, %opt, $cb), groups_in_common($user_id, %opt, $cb)
    Reading chat and user records. fetch_chat asks TDLib, unlike
    "chat($id)", which reads the module's cache; it also loads a chat the
    client has not seen. close_chat balances an openChat you sent yourself
    through "send(\%request, $cb, %opt)"; nothing in the module opens a
    chat. "full => 1" asks supergroup and basic_group for the fuller record.
    supergroup_members takes "filter" ("recent", "contacts",
    "administrators", "restricted", "banned", "bots"), "offset" and "limit".
    groups_in_common pages with "limit" (up to 100) and "offset_chat_id",
    the chat id to start from; 0 asks for the first page.

   chat_event_log($chat_id, %opt, $cb), chat_statistics($chat_id, %opt, $cb), pinned_message($chat_id, $cb), clear_action_bar($chat_id, $cb), message_senders($chat_id, $cb), set_message_sender($chat_id, $sender_id, $cb)
    The administrator log, with "query", "from_event_id", "limit" and
    "users"; channel statistics, with "dark" for the dark-theme graphs; the
    chat's pinned message; and dismissing the bar Telegram shows above a
    chat it thinks may be spam.

    message_senders lists the identities allowed to post in a chat and
    set_message_sender chooses one, which is how an administrator posts as
    the channel rather than as themselves. A negative sender id is a chat, a
    positive one a user.

   search_chats($query, %opt, $cb), search_public_chats($query, $cb), top_chats($category, %opt, $cb), recommended_chats($cb), recently_opened_chats(%opt, $cb)
    Finding chats. search_chats looks through what this account already
    knows; search_public_chats reaches Telegram's public directory.
    top_chats takes a category: "users", "bots", "groups", "channels",
    "inline_bots", "calls" or "forwards".

   check_chat_username($chat_id, $username, $cb), report_chat($chat_id, %opt, $cb), default_disable_notification($chat_id, $on, $cb)
    Whether a public username is free for a chat, reporting a chat with
    "option_id", "messages" and "text", and whether messages sent to a chat
    are silent by default.

  Messages mixin
   send_message($chat_id, $text, %opt, $cb)
    Sends a text message.

    "topic" posts into a forum topic, and works on every sending method.
    Without it a message goes to the chat's General topic, which is why a
    bot answering in a forum must pass the topic it was addressed in.

    "schedule" takes a Unix time and has the server deliver the message
    then. A time less than ten seconds away, or already past, is sent at
    once, and the callback still looks like an accepted scheduled message; a
    bot cannot schedule at all. Because a scheduled message is not sent now,
    the confirmation "wait => 'sent'" waits for would not arrive until its
    due time, so "wait" defaults to "accepted" when scheduling and asking
    for "sent" explicitly croaks. Read pending ones back with
    "scheduled($chat_id, $cb)".

    TDLib will not send to a chat it has not loaded, and answers "Chat not
    found" instead. A chat id taken from an update or from
    "chat_by_username($name, $cb)" is already known; one you constructed
    yourself may not be, and that includes your own Saved Messages, whose
    chat id is your user id. Open it first with createPrivateChat and send
    to the id that returns:

        $td->send({ '@type' => 'createPrivateChat',
                    user_id => $me->{id} }, sub {
            my ($chat, $err) = @_;
            die "$err->{message}\n" if $err;
            $td->send_message($chat->{id}, 'note to self', sub { });
        });

    Options:

    parse_mode
        "markdown" (MarkdownV2) or "html", parsed through the synchronous
        parseTextEntities call. Unlike every other error path, a parse error
        is delivered synchronously: send_message invokes $cb with the error
        before returning, because parseTextEntities never reaches the
        network.

    wait
        "sent" (default) fires the callback on final delivery: sendMessage
        returns a message with a temporary id, and the real outcome arrives
        later as updateMessageSendSucceeded or updateMessageSendFailed keyed
        by that id. "accepted" fires the callback with the temporary message
        as soon as TDLib accepts the request.

    reply_to
        Message id to reply to. If that message cannot be replied to -- it
        is in another chat, has been deleted, or has not reached the server
        yet -- TDLib sends a plain message instead, and the callback hears
        nothing of it.

    silent
        Send without a notification.

    disable_preview
        Suppress the link preview.

    reply_markup
        A reply markup hashref, as built by "inline_keyboard(\@rows)".

   entity_text($formatted_text, $entity), entity_texts($formatted_text)
    Returns the text a formatting entity covers. TDLib measures "offset" and
    "length" in UTF-16 code units, so "substr" is wrong for any text
    containing a character outside the BMP -- an emoji is one Perl character
    but two UTF-16 units, and every entity after it is shifted. entity_text
    does the slicing; entity_texts does it for every entity at once,
    returning an arrayref whose elements carry the entity's own fields, its
    "type" flattened to the type name, and the "text" it covers.

        for my $e (@{ $td->entity_texts($msg->{content}{text}) }) {
            print "$e->{type}: $e->{text}\n";
        }

    The offsets themselves are left exactly as TDLib sent them. They are
    sent back unchanged when a message is forwarded, edited or copied, so
    rewriting them into character counts would corrupt the message.

   history($chat_id, %opt, $cb)
    Pages getChatHistory backwards. Options: "limit" (messages wanted,
    default 50), "max_pages" (default 10), "from_message_id". The callback
    receives "(\@messages, $err, $state)"; "$state->{complete}" is true when
    the requested limit was reached or the history was exhausted.

   edit_message($chat_id, $message_id, $text, %opt, $cb)
    Edits a text message. Accepts parse_mode and disable_preview; parse
    errors are synchronous, as in send_message.

   edit_message_markup($chat_id, $message_id, $markup, $cb)
    Replaces a message's reply markup and nothing else, for updating buttons
    after a tap. Omit the markup to remove the buttons: the request then
    carries an explicit null, which is how TDLib spells "no markup". An
    empty hashref does not work -- TDLib refuses it for having no @type.

    Note that "edit_message($chat_id, $message_id, $text, %opt, $cb)" takes
    "reply_markup" as an option, and an edit that omits it drops whatever
    buttons the message had.

   answer_poll($chat_id, $message_id, \@option_ids, $cb), stop_poll($chat_id, $message_id, %opt, $cb)
    Vote in a poll and close one. Option ids are zero-based positions in the
    list the poll was created with, and a single-answer poll takes a
    one-element arrayref. stop_poll takes "reply_markup".

   message($chat_id, $message_id, $cb), messages($chat_id, \@message_ids, $cb), replied_message($chat_id, $message_id, $cb)
    Fetch messages by id. replied_message returns the one a message replies
    to, without needing its id.

   message_link($chat_id, $message_id, %opt, $cb), message_link_info($url, $cb), message_count($chat_id, %opt, $cb)
    message_link builds a t.me link to a message, with "media_timestamp",
    "for_album" and "in_thread"; message_link_info resolves one back.
    message_count counts messages in a chat. "filter" is required, since
    TDLib cannot count unfiltered: give it "Photo", "Video", "Document",
    "Url", "Pinned" or any other searchMessagesFilter name, with or without
    the prefix. Also takes "topic" and "local", which counts only what is
    already cached.

   available_reactions($chat_id, $message_id, %opt, $cb), message_reactions($chat_id, $message_id, %opt, $cb), set_default_reaction($reaction, $cb)
    available_reactions lists what may be added to a message;
    message_reactions lists what already was, optionally filtered to one
    "reaction" (also accepted as "emoji"); set_default_reaction picks the
    one a long press sends. Both take the same forms as "react($chat_id,
    $message_id, $reaction, %opt, $cb)", so a custom emoji works as well as
    a plain one. Adding and removing a reaction is react().

    available_reactions takes "row_size", the keyboard width a client would
    lay the reactions out in, 8 by default and silently 8 again for anything
    outside 5 to 25; message_reactions takes "offset" and "limit" (50).

   set_draft($chat_id, $text, %opt, $cb), clear_drafts(%opt, $cb)
    Saves an unsent message against a chat, which other clients on the same
    account will see. An empty or undefined text clears the draft, which is
    how TDLib spells "no draft". Options: "parse_mode", "reply_to" and
    "topic". The draft's timestamp is not among them: TDLib stamps it from
    its own clock when it stores the draft, which is what decides between
    two clients that saved one. clear_drafts empties every chat, keeping
    secret chats unless "exclude_secret" is false.

   send_album($chat_id, \@contents, %opt, $cb)
    Sends several media as one group. "\@contents" are InputMessageContent
    hashrefs, such as those "send_file($chat_id, $path, %opt, $cb)" builds;
    "reply_to", "topic", "silent" and "schedule" work as they do there.
    "wait => 'sent'" is refused: the reply carries one message per item, so
    there is no single delivery to wait for, and the callback always runs
    once Telegram accepts the album, which is what "wait => 'accepted'"
    says.

   edit_message_caption($chat_id, $message_id, $caption, %opt, $cb), edit_message_media($chat_id, $message_id, \%content, %opt, $cb), edit_message_location($chat_id, $message_id, \%location, %opt, $cb), reschedule($chat_id, $message_id, $when, $cb)
    Editing a sent message beyond its text. The caption honours "parse_mode"
    and "caption_above"; the location takes "live_period", "heading" and
    "proximity_alert_radius", which it nests in the liveLocation object
    TDLib expects. An edit without "\%location" stops sharing a live
    location. "proximity_alert_radius" is sent every time, 0 when left out,
    so an edit that does not repeat it switches an active proximity alert
    off. reschedule moves a scheduled message, and sends it now when $when
    is omitted or is less than ten seconds away.

    Two fields are sent every time here, as they are for edit_message:
    "caption_above", so editing the caption of a message whose caption sat
    above the media without passing it puts the caption back below, and
    "reply_markup", so an edit that does not pass the keyboard takes the
    buttons away. Pass both when you mean to keep them.

   resend_messages($chat_id, \@message_ids, $cb), delete_messages_by_sender($chat_id, $sender, $cb), delete_messages_by_date($chat_id, $min_date, $max_date, %opt, $cb), unpin_all($chat_id, $cb), read_all_mentions($chat_id, $cb)
    Bulk operations over a chat's messages. $sender is a bare user id, or a
    negative chat id for a channel posting in the group. Deleting by date
    revokes for everyone unless "revoke => 0", which a basic group needs: it
    refuses the revoke. A supergroup cannot be cleared by date at all. TDLib
    moves a $max_date later than half a minute ago back to it, so a range
    lying entirely within the last 30 seconds deletes nothing and still
    reports success.

   message_thread($chat_id, $message_id, $cb), thread_history($chat_id, $message_id, %opt, $cb), read_date($chat_id, $message_id, $cb), message_viewers($chat_id, $message_id, $cb), message_properties($chat_id, $message_id, $cb), message_by_date($chat_id, $date, $cb), open_content($chat_id, $message_id, $cb)
    Reading around a message: its comment thread and that thread's history,
    when it was read and by whom, what may be done with it, the message
    nearest a timestamp. open_content marks self-destructing media as
    opened, which starts its timer. thread_history pages with "limit",
    "offset" and "from_message_id", the message to read backwards from; 0
    starts at the newest.

   parse_markdown($text, $cb), markdown_text(\%formatted, $cb), text_entities($text, $cb), translate($text, $to_language, %opt, $cb), link_preview($text, $cb), search_hashtags($prefix, %opt, $cb)
    Text utilities. parse_markdown turns markdown into a formattedText and
    markdown_text turns one back; text_entities finds links, mentions and
    the like in plain text without any markup. translate takes a string or a
    formattedText and a language code, and "tone", one of "formal",
    "neutral" (the default) or "casual". link_preview asks what Telegram
    would show for the links in a text, and fails with a 404 when the text
    has none, which is an ordinary answer rather than a fault.

   scheduled($chat_id, $cb)
    Lists the messages scheduled in a chat but not yet delivered.

   react($chat_id, $message_id, $reaction, %opt, $cb)
    Adds a reaction. Options: "remove" to take the reaction away again,
    "is_big" for the animated form, "update_recent" (default on) to fold the
    emoji into the sender's recent reactions.

    $reaction is an emoji string, or a hashref "{ custom_emoji_id => $id }"
    for a custom emoji; the id is int64 and is stringified for you. A paid
    reaction is not reachable here -- TDLib rejects it in this call and
    points at add_paid_reaction().

   set_reactions($chat_id, $message_id, \@reactions, %opt, $cb), delete_reactions_from_sender($chat_id, $message_id, $sender, $cb), clear_recent_reactions($cb)
    Replace the whole set of our reactions on a message at once -- the bot
    counterpart of react, which is for users; remove every reaction a given
    sender left ($sender is a bare user id, or a negative chat id); and
    clear the recent-reactions list. Each element of @reactions takes the
    same forms as react's $reaction. "is_big" applies.

   add_paid_reaction($chat_id, $message_id, $star_count, %opt, $cb), commit_paid_reactions($chat_id, $message_id, $cb), remove_paid_reactions($chat_id, $message_id, $cb), set_paid_reaction_type($chat_id, $message_id, $type, $cb), paid_reaction_senders($chat_id, $cb)
    Paid reactions are staged and then committed, which is why they are
    separate from react: add one or more pending reactions, then commit
    them, or drop them with remove. "type" selects who is credited:
    'regular' (the default), 'anonymous', or a chat id to react as that
    chat. paid_reaction_senders lists the senders available for a chat.

   delete_messages($chat_id, \@message_ids, %opt, $cb)
    Deletes messages. "revoke" defaults to true (delete for all
    participants).

   forward_messages($chat_id, $from_chat_id, \@message_ids, %opt, $cb)
    Forwards messages. Options: "send_copy", "remove_caption", "silent". The
    callback runs once Telegram accepts the forward, with the pending
    messages; like send_album it refuses "wait => 'sent'".

   on_message($cb)
    Handler for updateNewMessage, called with the decoded message. Its text
    is a character string, but any formatting entities on it are measured in
    UTF-16 code units: slice them with "entity_text($formatted_text,
    $entity), entity_texts($formatted_text)" rather than "substr".

   send_file($chat_id, $path, %opt, $cb)
    Sends a local file. "kind" selects the content: "document" (the
    default), "photo", "video", "audio", "animation", "voice_note",
    "video_note", "sticker"; an unknown kind croaks. $path may instead be an
    InputFile hashref, as returned by "upload($path)". "caption" is
    formatted with the same "parse_mode" rules as "send_message($chat_id,
    $text, %opt, $cb)", and "reply_to", "silent", "wait" and "reply_markup"
    behave as they do there.

    Each kind nests its InputFile inside a per-kind wrapper object --
    inputMessageDocument takes an inputDocument, inputMessagePhoto an
    inputPhoto, and so on. This method builds that nesting; handing TDLib
    the InputFile directly yields only "InputFile is not specified".

    Kinds accept the metadata their wrapper defines, and Telegram classifies
    media by what it is given: "width" and "height" for photo, animation,
    video and sticker; "duration" for animation, video, audio, voice_note
    and video_note; "title" and "performer" for audio; "length" for
    video_note; "emoji" for sticker. "sticker" and "video_note" have no
    caption field in the schema, so a caption passed with them is dropped
    rather than sent.

    A Telegram animation is an MP4, not a GIF. Sending a ".gif" file as
    "animation" succeeds but arrives as a plain document: the conversion is
    the sender's job, not the server's. Convert to MP4 first (H.264,
    "yuv420p") and it arrives as a real animation. Sending an existing
    sticker means sending its remote file id, since an arbitrary local file
    will not pass Telegram's sticker validation.

   send_poll($chat_id, $question, \@options, %opt, $cb)
    Sends a poll, which needs at least two options. Polls are anonymous
    unless "anonymous" is turned off, which is the opposite of TDLib's own
    default but matches what Telegram's clients create. For the same reason
    a vote can be changed or taken back unless "revoting" is turned off; in
    a quiz it is final. Options: "multiple" to allow several answers,
    "open_period" to close the poll after that many seconds,
    "allow_adding_options", and "quiz" with "correct" (an option index,
    default 0) and "explanation" for a quiz.

    Changed in 0.04. 0.03 sent no re-voting setting, so every poll it
    created refused a change of vote. Pass "revoting => 0" for that.

    All three of these, like the other senders, accept "reply_to", "silent",
    "reply_markup" and "wait".

   send_location($chat_id, $latitude, $longitude, %opt, $cb), send_contact($chat_id, $phone, $first_name, %opt, $cb)
    Sends a location or a contact. send_location takes "accuracy" in metres;
    send_contact takes "last_name", "vcard" and "user_id".

   search_messages($chat_id, $query, %opt, $cb)
    searchChatMessages over one chat. Options: "limit" (default 50),
    "from_message_id", "offset". The callback receives "(\@messages, $err,
    $info)", where $info carries "total_count" and "next_from_message_id"
    for paging.

  Files mixin
   download($file_id, %opt, $cb)
    Starts a download (downloadFile). "on_progress" receives the decoded
    file on every related updateFile; the main callback fires with the file
    once local.is_downloading_completed is true. A file that is already
    downloaded fires it from the downloadFile reply itself: TDLib emits no
    updateFile when nothing changed. A download that fails after starting
    (TDLib signals this only via updateFile, with is_downloading_active and
    is_downloading_completed both false) fails the callback with a synthetic
    "download failed" error. Option: "priority" (default 1). "on_progress"
    may be given without a main callback, for a download followed only
    through its progress.

    One registration per file id: a second download() for the same id while
    the first is in flight fails its callback immediately with a synthetic
    "already in progress" error, delivered synchronously like a parse_mode
    error since nothing is sent; the first download is left alone.

   cancel_download($file_id)
    Cancels a pending download and fails its callback. It takes no callback
    of its own, and croaks on one.

   upload($path)
    Returns an inputFileLocal hashref for use as message content or
    elsewhere in a request. It only builds the shape: nothing is sent and
    nothing is tracked. The actual upload is reported by TDLib through the
    same updateFile as downloads, but on the remote side of the file
    ("remote.uploaded_size" up to "remote.is_uploading_completed"), and is
    observed with "on_upload($file_id, $cb)".

    Every send that takes a path uploads asynchronously, so the file must
    still be on disk when TDLib gets to it, not merely when the call
    returns. A File::Temp object scoped to the enclosing block is the way
    this goes wrong: it unlinks on destruction and the upload then fails
    with "Need full local (or generate, or inactive remote) location for
    upload". Keep the handle alive until the callback runs.

   on_upload($file_id, $cb)
    Registers $cb to fire with the decoded file on every updateFile for
    $file_id. The registration is removed automatically once the last update
    has been delivered: the one with "remote.is_uploading_completed" true,
    or the one showing an upload that had started and stopped without
    completing. Pass an undef $cb to remove it earlier; leaving the callback
    out croaks. The file id becomes known only after the send is accepted:
    read it from the returned message content (for a document,
    "$msg->{content}{document}{document}{id}") and register then. Send with
    "wait => 'accepted'" for that: the default calls back on delivery, when
    the upload has already finished, so a watcher registered then never
    fires and is never removed.

    "Had started" means seen to start by this watcher, and TDLib reports the
    start before the send is accepted. So an upload stopped before its first
    progress update -- a send deleted at once, a file unreadable from the
    first byte -- looks like one still queued, and its watcher stays: remove
    it yourself when you cancel. On close the watchers are dropped silently.

    Unlike the other on_* methods, on_upload is a per-id registration, not a
    single-handler setter, and it returns nothing.

   file($file_id, $cb), remote_file($remote_id, %opt, $cb), delete_file($file_id, $cb), suggested_file_name($file_id, %opt, $cb)
    Local file records. remote_file resolves the persistent id that travels
    inside a message; its "file_type" must match what the file actually is,
    and may be given with or without the "fileType" prefix. A name the
    pinned schema does not have croaks rather than reaching the server.
    suggested_file_name takes "directory", the directory the name is being
    chosen for, so it can avoid colliding with what is already there.

   add_to_downloads($file_id, $chat_id, $message_id, %opt, $cb), remove_from_downloads($file_id, %opt, $cb), pause_download($file_id, $paused, $cb)
    The download list Telegram clients show. Options: "priority", and
    "delete_cache" to remove the downloaded bytes as well as the entry.

   storage_statistics($cb), optimize_storage(%opt, $cb)
    A long-lived client accumulates gigabytes of cached media.
    optimize_storage prunes it, bounded by "size", "ttl", "count" and
    "immunity_delay", restricted to "chats" or held back from
    "exclude_chats", each an arrayref of chat ids. An unset limit is sent as
    -1, which TDLib reads as its own default, not as no limit: 100 MB of
    files in total, nothing untouched for 23 hours, 40000 files, and an hour
    before a new file may be deleted. So optimize_storage with no options is
    a full prune at those defaults, not a no-op. Set every limit you care
    about.

    "chat_limit" affects only the statistics that come back, never what is
    deleted: it is the number of chats, largest usage first, reported
    separately, with every other chat folded into an entry whose chat id is
    0. "statistics" likewise only changes what is returned.

  Connection mixin
   connection_state()
    Returns the last seen connection state name, or undef before the first
    updateConnectionState arrives. One of connectionStateWaitingForNetwork,
    connectionStateConnectingToProxy, connectionStateConnecting,
    connectionStateUpdating or connectionStateReady. The first three mean
    there is no route to Telegram, so a request waits for one.
    connectionStateUpdating means the opposite: the connection is up and
    TDLib is fetching what it missed, and requests go out normally, so do
    not treat it as offline.

   option($name), my_id()
    TDLib reports its options as updates rather than replies, so the module
    caches them as they arrive; option() reads one back. Boolean options are
    cached as 1 or 0 and an empty option as undef. An integer option is TL
    int64 and so arrives as a string; numify one before putting it in a
    request you build yourself.

    my_id() is the signed-in account's own user id, which TDLib pushes right
    after login. It is undef until then.

   on_connection_state($cb)
    Handler for updateConnectionState, called with the state name string
    after connection_state() is updated.

   sessions($cb), terminate_session($session_id, $cb), terminate_other_sessions($cb), set_session_ttl($days, $cb)
    The devices logged into this account. sessions lists them;
    terminate_session logs one out and terminate_other_sessions logs out
    everything except this client. set_session_ttl sets how many days of
    inactivity ends a session automatically. Session ids are TL "int64" and
    are sent as strings.

   add_proxy(\%proxy, %opt, $cb), proxies($cb), enable_proxy($proxy_id, $cb), disable_proxy($cb), remove_proxy($proxy_id, $cb), ping_proxy(\%proxy, $cb)
    Proxy configuration. A proxy hashref takes "server", "port" and "type"
    ("socks5", "http" or "mtproto"), plus "username" and "password" for the
    first two, "http_only" for HTTP, and "secret" for MTProto. add_proxy
    enables the proxy unless "enable => 0", and takes "comment", a label of
    your own that TDLib stores with it.

   set_network_type($type, $cb), network_statistics(%opt, $cb)
    Telling TDLib the network changed ("none", "mobile", "roaming", "wifi",
    "other") lets it reconnect promptly rather than waiting for its own
    timers, which matters on a laptop that sleeps or a phone that changes
    network. network_statistics takes "current => 1" to report only the
    current session rather than everything since the counters were last
    reset.

   log_out($cb), password_state($cb), set_password($old, $new, %opt, $cb), account_ttl($days, $cb), register_device(\%token, %opt, $cb)
    Account-level operations. set_password takes "hint" and
    "recovery_email". account_ttl reads the inactivity period after which
    Telegram deletes the account when called with no $days, and sets it when
    given one. register_device takes a DeviceToken hashref for push
    notifications, plus "other_users".

  Bots mixin
   inline_keyboard(\@rows)
    Builds a replyMarkupInlineKeyboard for the "reply_markup" option of
    "send_message($chat_id, $text, %opt, $cb)" and "send_file($chat_id,
    $path, %opt, $cb)". Each row is an arrayref of buttons, and each button
    is "{ text => ..., data => ... }" for a callback button, "{ text => ...,
    url => ... }" for a link, or "{ text => ..., web_app => $url }" to
    launch a Mini App. A button with none of the three croaks.

    Callback data is TL "bytes", which the JSON interface carries base64
    encoded; this method encodes it, and "on_callback_query($cb)" decodes it
    again, so callers only ever handle the plain bytes.

   reply_keyboard(\@rows, %opt)
    Builds a replyMarkupShowKeyboard, the custom keyboard that replaces a
    user's normal one. A button may be a plain string or a hashref; "{ text
    => ..., request => 'phone' }" (or 'location') asks the user to share
    that instead of sending text. Options: "one_time", "resize" (default
    on), "persistent", "placeholder".

    Three further button shapes are available. "{ text => ..., web_app =>
    $url }" launches a Mini App, and the data it sends back arrives through
    "on_web_app_data($cb)". "{ text => ..., request_chat => \%spec }" and "{
    text => ..., request_users => \%spec }" ask the user to pick a chat or
    some users. In both, a constraint is applied only for a key you actually
    mention, so "{ bot => 0 }" means "not a bot" while leaving it out means
    "either".

    "channel" is the exception, because TDLib gives it no "restrict" flag of
    its own: it always applies, so omitting it asks for a group rather than
    for either. Pass "channel => 1" to ask for a channel.

    request_chat takes "id", "channel", "forum", "username", "created",
    "bot_is_member", "want_title", "want_username", "want_photo", and
    "user_rights" / "bot_rights" as chatAdministratorRights hashrefs.
    request_users takes "id", "bot", "premium", "max" (default 1),
    "want_name", "want_username", "want_photo".

   remove_keyboard(%opt)
    Builds a replyMarkupRemoveKeyboard, which takes a custom keyboard away
    again. Option: "personal".

   set_commands(\@commands, %opt, $cb)
    Sets the "/" command menu a bot offers. Each command is "['start',
    'Begin']" or "{ command => 'start', description => 'Begin' }"; a leading
    slash is stripped. An empty list clears the menu. Options: "scope" (a
    BotCommandScope hashref, default botCommandScopeDefault),
    "language_code".

   set_bot_name($name, %opt, $cb), set_bot_description($text, %opt, $cb), set_bot_short_description($text, %opt, $cb), set_bot_photo($path, %opt, $cb)
    Change a bot's own profile. The description is the long text shown on an
    empty chat screen with the bot; the short description is the one-liner
    shown in its profile and in search results. set_bot_photo takes the same
    "animation" and "main_frame_timestamp" options as
    "set_profile_photo($path, %opt, $cb)".

    TDLib addresses a bot by user id. These default to "option($name),
    my_id()", which is what a bot session wants; pass "bot_user_id" to act
    on a bot from another account that owns it. The three text setters
    accept "language_code" for a localised value; set_bot_photo does not,
    since TDLib keeps one photo per bot rather than one per language.

   on_callback_query($cb)
    Handler for updateNewCallbackQuery, called with a hashref carrying "id",
    "sender_user_id", "chat_id", "message_id", "type", and the decoded
    "data". Answer it with "answer_callback_query($id, %opt, $cb)"; Telegram
    shows the user a spinner until you do.

    A press on a message the bot sent through inline mode
    (updateNewInlineCallbackQuery) arrives here and at the router too. It
    has no chat: "chat_id" and "message_id" are undef, and
    "inline_message_id" names the message for the edit_inline_* methods. So
    does a press on a message sent for a connected business account
    (updateNewBusinessCallbackQuery), with its "chat_id" and "message_id"
    and the "connection_id" it came through -- which is what the
    edit_business_message_* methods take, and they are what edits such a
    message; the ordinary edit_message_* family addresses a different thing.

   on_inline_query($cb)
    Handler for updateNewInlineQuery, the typing-ahead queries an inline bot
    answers. It is called with a hashref carrying "id", "sender_user_id",
    "query", "offset", "chat_type" and "user_location", a location or undef.
    Inline mode must be turned on for the bot first, through BotFather.

   answer_inline_query($id, \@results, %opt, $cb)
    Answers an inline query with a list of article results. Each result is
    "{ title => ..., message => ..., description => ..., url => ...,
    thumbnail_url => ..., reply_markup => ... }"; "message" is the text sent
    when the result is picked, defaulting to the title, and "id" is
    generated if you leave it out. Options: "cache_time" (default 300),
    "personal" for per-user results, "next_offset" for paging.

   answer_callback_query($id, %opt, $cb)
    Answers a callback query. Options: "text", "show_alert", "url",
    "cache_time". The id is sent as a string, since it is a TL "int64" and
    would lose precision as a number.

   commands(%opt, $cb), delete_commands(%opt, $cb)
    Read back or clear the "/" menu set by "set_commands(\@commands, %opt,
    $cb)". Both take the same "scope" and "language_code" options.

   bot_name(%opt, $cb), bot_description(%opt, $cb), bot_short_description(%opt, $cb)
    Read back the values set by the corresponding set_bot_* methods, with
    the same "bot_user_id" and "language_code" options.

   press($chat_id, $message_id, $data, $cb)
    Presses an inline keyboard button on someone else's message, which is
    what a user's client does when you tap one. This is the other side of
    "on_callback_query($cb)": use it to drive a bot rather than to be one.
    $data is the plain payload, base64 encoded on the way out for you. The
    callback receives a callbackQueryAnswer carrying "text" and "url".

   inline_query($bot_user_id, $query, %opt, $cb), send_inline_result($chat_id, $query_id, $result_id, %opt, $cb)
    The user side of inline mode: ask a bot for results as if you had typed
    its username in a message box, then send one of them. inline_query
    options are "chat_id", "offset" and "location"; send_inline_result takes
    "hide_via_bot", and "reply_to", "silent", "schedule" and "topic" as
    send_message does, but calls back once the message is accepted and
    refuses "wait => 'sent'". The query id is sent as a string, being a TL
    "int64".

   start_bot($bot_user_id, $parameter, %opt, $cb)
    Sends the "/start" that a deep link produces, passing $parameter along.
    Option: "chat_id", which defaults to the bot's own chat.

   attachment_menu_bot($bot_user_id, $cb), toggle_attachment_menu($bot_user_id, $on, %opt, $cb)
    Read and change whether a bot sits in the attachment menu. This matters
    for Mini Apps: open_web_app() accepts an empty URL only for a bot that
    is in the menu, and answers BOT_INVALID otherwise. Option:
    "allow_write_access".

   edit_inline_text($inline_message_id, $text, %opt, $cb), edit_inline_caption($inline_message_id, $caption, %opt, $cb), edit_inline_media($inline_message_id, \%content, %opt, $cb), edit_inline_markup($inline_message_id, \%markup, $cb), edit_inline_location($inline_message_id, \%location, %opt, $cb)
    Edit a message a bot sent through inline mode. These address it by its
    "inline_message_id" string, which is a different thing from the
    "(chat_id, message_id)" pair the edit_message_* methods take; the two
    are not interchangeable. The id is not the result id you gave
    "answer_inline_query($id, \@results, %opt, $cb)": it arrives with a
    press on one of the message's buttons, as the callback query's
    "inline_message_id", or in updateNewChosenInlineResult once the user
    picks a result -- sent only when inline feedback is enabled for the bot
    through BotFather, and carrying the id only for a result that went out
    with buttons, which is what gives Telegram something to edit. Read that
    update with on_update().

    edit_inline_text and edit_inline_caption honour "parse_mode" and hand a
    parse failure to the callback rather than to TDLib. All accept
    "reply_markup", and all send it every time, so an edit that leaves it
    out drops the message's buttons. edit_inline_caption also takes
    "caption_above", sent every time in the same way, as
    edit_message_caption() does. edit_inline_location takes "live_period",
    "heading" and "proximity_alert_radius", which it nests in the
    liveLocation object where TDLib expects them.

   share_chat_with_bot($chat_id, $message_id, $button_id, $shared_chat_id, %opt, $cb), share_users_with_bot($chat_id, $message_id, $button_id, \@user_ids, %opt, $cb)
    The user's answer to a "request_chat" or "request_users" keyboard
    button. The chat and message ids identify the message the button was on,
    and $button_id is the "id" given to that button when the keyboard was
    built, which is how a bot tells two pickers apart. Option: "check_only",
    to test whether the share would be allowed without performing it.

   allow_bot_messages($bot_user_id, $cb), can_bot_message($bot_user_id, $cb)
    Whether a bot you have blocked or never started may message you.
    can_bot_message asks; allow_bot_messages grants.

   callback_query_message($chat_id, $message_id, $callback_query_id, $cb)
    Fetches the message a callback query came from, for a bot that did not
    keep it. The query id is TL "int64" and is sent as a string.

   check_bot_username($username, $cb), toggle_bot_username($bot_user_id, $username, $active, $cb)
    Check whether a username is free for a bot, and turn one of a bot's
    usernames on or off. The flag defaults to on.

   create_bot($name, $username, %opt, $cb), owned_bots($cb), bot_token($bot_user_id, %opt, $cb), bot_access_settings($bot_user_id, $cb), set_bot_access_settings($bot_user_id, \%settings, $cb)
    Creating and managing bots from an account rather than through
    BotFather. create_bot takes "manager", the bot that will own the new
    one, and "via_link". bot_token reads a managed bot's token; "revoke"
    issues a new one and invalidates the old, so anything still using it
    stops working.

   set_updates_status($pending_count, %opt, $cb), recent_inline_bots($cb), similar_bots($bot_user_id, $cb), similar_bot_count($bot_user_id, %opt, $cb), open_similar_bot($bot_user_id, $opened_bot_user_id, $cb)
    set_updates_status reports a bot's backlog to Telegram, with an optional
    "error". The rest are discovery: recently used inline bots, and bots
    Telegram considers similar to a given one. similar_bot_count takes
    "local => 1" to answer from what TDLib already holds rather than asking
    the server, and then answers -1 when it holds no count at all.

   bot_media_previews($bot_user_id, %opt, $cb), add_bot_media_preview($bot_user_id, \%content, %opt, $cb), edit_bot_media_preview($bot_user_id, $file_id, \%content, %opt, $cb), delete_bot_media_previews($bot_user_id, \@file_ids, %opt, $cb), reorder_bot_media_previews($bot_user_id, \@file_ids, %opt, $cb)
    The sample media shown on a bot's profile, which are stored per
    language. Passing "language_code" to bot_media_previews asks for one
    language's set rather than the list of languages. "\%content" is an
    InputStoryContent hashref, passed through as given.

   set_game_score($chat_id, $message_id, $user_id, $score, %opt, $cb), game_high_scores($chat_id, $message_id, $user_id, $cb), set_inline_game_score($inline_message_id, $user_id, $score, %opt, $cb), inline_game_high_scores($inline_message_id, $user_id, $cb)
    Reporting and reading HTML5 game results. "edit" updates the message to
    show the new score and is on by default; "force" allows a score lower
    than the player's best, which is otherwise refused.

   set_menu_button($user_id, %opt, $cb), menu_button($user_id, $cb)
    The button beside the message box in a chat with a bot. Options "text"
    and "url"; a Mini App URL makes it open the app. "commands => 1" puts
    back the list of commands instead, and a call with neither a text nor a
    url puts back Telegram's default button. A url without a text croaks,
    since TDLib refuses that pair. Passing user id 0 sets it for every user.

  WebApps mixin
    Mini Apps, which Telegram also calls Web Apps. TDLib does not render
    anything: it hands back a URL and a launch id, and hosting the webview
    is the application's job. See "MINI APPS".

    Every method here builds the webAppOpenParameters object itself from the
    "application_name" the client was constructed with, so %opt carries only
    "mode" ("full_size", the default, "compact" or "full_screen"), "theme",
    and a per-call "application_name" override.

   web_app($bot_user_id, $short_name, $cb)
    Looks up one Mini App by the short name it was given in BotFather. The
    callback receives a foundWebApp carrying the "web_app" itself, plus
    "request_write_access" and "skip_confirmation".

   web_app_link($chat_id, $bot_user_id, $short_name, %opt, $cb), web_app_url($bot_user_id, %opt, $cb), main_web_app($chat_id, $bot_user_id, %opt, $cb)
    Three ways to get a launch URL: from a direct link short name, from a
    button URL ("url" option), and from a bot's main Mini App. web_app_link
    and main_web_app accept "start_parameter"; web_app_link also accepts
    "allow_write_access".

   open_web_app($chat_id, $bot_user_id, $url, %opt, $cb), close_web_app($launch_id, $cb)
    Open and close a Mini App session. The callback of open_web_app receives
    a webAppInfo carrying "launch_id" and "url"; pass that launch id to
    close_web_app when the webview goes away. $url should be the one from a
    Web App button; an empty string is accepted only for a bot in the
    attachment menu, and otherwise answers BOT_INVALID.

   send_web_app_data($bot_user_id, $button_text, $data, $cb)
    Sends data back to a bot as if the Mini App had called
    "Telegram.WebApp.sendData()". The bot sees it through
    "on_web_app_data($cb)". This is the reply-keyboard flow, so $button_text
    must be the text of the Web App button that was pressed.

   on_web_app_data($cb)
    Handler for a messageWebAppDataReceived message. Called as
    "$cb->($message, $data, $button_text)" with the payload and button text
    lifted out of the content for convenience. "on_message($cb)" still sees
    these messages too.

   answer_web_app_query($query_id, \%result, $cb), web_app_request($bot_user_id, $method, $parameters, $cb), web_app_placeholder($bot_user_id, $cb)
    answer_web_app_query is for a bot: an app opened from an inline button,
    the menu button or the attachment menu receives a "query_id" in its init
    data, and the bot answers it with one InputInlineQueryResult, which
    Telegram posts on the user's behalf; an app opened from a keyboard
    button has no query id. web_app_request is for a user client: it sends a
    custom method call on the Mini App's behalf, with $parameters as a JSON
    string. web_app_placeholder fetches the outline shown while an app
    loads.

  Forum mixin
    Topics in a forum supergroup. A topic id is the topic's
    "forum_topic_id", an int32 of its own and not a message id: read it from
    "$msg->{topic_id}{forum_topic_id}" or from topics(), never from the
    message that opened the topic.

    To post into a topic, pass "topic" to any sending method rather than
    calling something different; see "send_message($chat_id, $text, %opt,
    $cb)".

   create_topic($chat_id, $name, %opt, $cb), edit_topic($chat_id, $topic_id, %opt, $cb), delete_topic($chat_id, $topic_id, $cb)
    Create, rename and remove topics. create_topic options: "color" (an RGB
    integer) and "custom_emoji_id" for the icon, and "name_implicit".
    edit_topic options: "name" and "custom_emoji_id"; the icon is only
    touched when "custom_emoji_id" is given, so renaming leaves it alone,
    and leaving "name" out keeps the current name.

   topic($chat_id, $topic_id, $cb), topics($chat_id, %opt, $cb), topic_history($chat_id, $topic_id, %opt, $cb), topic_link($chat_id, $topic_id, $cb)
    Read topics and their messages. topics options: "query", "limit"
    (default 100) and the "offset_date", "offset_message_id",
    "offset_forum_topic_id" triple for paging. topic_history takes
    "from_message_id", "offset" and "limit".

   close_topic($chat_id, $topic_id, $closed, $cb), pin_topic($chat_id, $topic_id, $pinned, $cb), unpin_topic_messages($chat_id, $topic_id, $cb), hide_general_topic($chat_id, $hidden, $cb), topic_icons($cb)
    State changes. The flag defaults to true, so "close_topic($chat, $id)"
    closes and "close_topic($chat, $id, 0)" reopens. hide_general_topic
    takes no topic id, since the General topic is identified by its absence.
    topic_icons lists the icons a client may offer.

   read_all_topic_reactions($chat_id, $topic_id, $cb)
    Marks every reaction in one forum topic as read.

  Folders mixin
    Chat folders, which Telegram's own clients show as tabs above the chat
    list. The folder list itself arrives through updateChatFolders rather
    than being fetched.

    A folder's name is a chatFolderName wrapping a formattedText, not a
    plain string; these methods build that from the "name" you give, so a
    folder spec is an ordinary hashref.

   folder($id, $cb), create_folder(\%spec, $cb), edit_folder($id, \%spec, $cb), delete_folder($id, %opt, $cb), reorder_folders(\@ids, %opt, $cb)
    Telegram truncates a folder name to 12 characters and does not say so,
    which is worth knowing before a longer name comes back shortened.

    A folder spec takes "name" (required; a string, or a formattedText for a
    name with custom emoji, then animated with "animate_emoji"), "icon" (an
    icon name such as "Work" or "Party"), "color_id", "shareable", the chat
    lists "pinned_chat_ids", "included_chat_ids" and "excluded_chat_ids",
    and the flags "exclude_muted", "exclude_read", "exclude_archived",
    "include_contacts", "include_non_contacts", "include_bots",
    "include_groups" and "include_channels". An unknown key croaks. TDLib
    replaces the whole folder on an edit, so any key not passed reverts to
    its default: editing only the name empties the membership.

    delete_folder takes "leave_chats", the chats to leave along with the
    folder rather than merely un-filing. reorder_folders takes
    "main_position", where the unfiled main list sits among the tabs; only a
    Premium account can move it, and for any other TDLib puts it back first
    without an error.

   recommended_folders($cb), folder_chat_count(\%spec, $cb), folder_tags($on, $cb)
    recommended_folders lists the ready-made folders Telegram suggests.
    folder_chat_count reports how many chats a spec would match without
    creating it. folder_tags turns the coloured tags on or off.

   folder_invite_link($id, %opt, $cb), folder_invite_links($id, $cb), edit_folder_invite_link($id, $link, %opt, $cb), delete_folder_invite_link($id, $link, $cb), check_folder_invite_link($link, $cb), add_folder_by_link($link, %opt, $cb)
    A shareable folder is handed out as a link that adds its chats to
    someone else's folder list. Creating and editing take "name" and
    "chats", the chats the link includes. TDLib replaces the whole link on
    an edit, so an edit needs "chats" every time: without them it fails with
    "At least one chat must be included". The last two take only the link,
    and "add_folder_by_link" takes "chats" to choose which of the offered
    chats to actually join.

  Payments mixin
    The seller's half of Telegram payments: offering something and answering
    the checkout. Actually paying for something is the buyer's half and is
    not wrapped; reach it through "call($function, \%args, $cb, %opt)".

    Amounts are integers in the currency's smallest unit, so 500 is five
    euros in "EUR". The exception is "XTR", Telegram Stars, where one unit
    is one Star, and where selling digital goods needs no payment provider
    at all: leave "provider_token" unset.

   send_invoice($chat_id, \%invoice, %opt, $cb), invoice_link(\%invoice, %opt, $cb)
    Sends an invoice as a message, or builds a shareable link to one. The
    invoice hashref takes "title", "description", "payload", "currency" and
    "prices" (all required), where each price is "[ $label => $amount ]" or
    "{ label => ..., amount => ... }". Optional: "provider_token" and
    "provider_data" for a real payment provider, "photo_url" and its
    dimensions, "start_parameter", "max_tip" and "tips", "test", and the
    "need_name", "need_phone", "need_email", "need_shipping" and "flexible"
    flags. send_invoice also takes the usual sending options, "topic" and
    "silent" included.

    "payload" is your own order identifier and comes back at checkout. It is
    TL "bytes", so it is base64 encoded on the way out for you.

   on_pre_checkout_query($cb), answer_pre_checkout_query($id, %opt, $cb)
    The last gate before money moves. Telegram gives a bot only seconds to
    answer, and an unanswered query fails the payment, so answer from the
    handler. The handler receives "id", "sender_user_id", "currency",
    "total_amount", "payload", "shipping_option_id" and "order_info".
    Answering with no "error" approves; any "error" string declines and is
    shown to the buyer.

   on_shipping_query($cb), answer_shipping_query($id, %opt, $cb)
    Only fires for an invoice with a flexible price, "flexible => 1", which
    is how the shipping options -- and so the final price -- get to depend
    on the address; "need_shipping" alone collects an address and asks
    nothing. The handler receives "id", "sender_user_id", "payload" and
    "shipping_address"; answer with "options", an arrayref of "{ id => ...,
    title => ..., prices => [...] }", or with an "error" to refuse delivery
    there.

    The "payload" in this handler is a plain string, while the one in
    "on_pre_checkout_query($cb), answer_pre_checkout_query($id, %opt, $cb)"
    is TL "bytes". Both arrive already in their correct form; the difference
    is TDLib's, and is noted here only because it looks like an
    inconsistency worth double-checking rather than a bug.

  Secret mixin
    End-to-end encrypted chats. A secret chat is a separate object from the
    chat that displays it: creating one yields a chat whose type is
    chatTypeSecret, and the methods below take the secret chat id found in
    that type, not the chat id.

    Secret chats live only in the local database. They are not on the
    server, cannot be read from another device, and do not survive losing
    the database.

   new_secret_chat($user_id, $cb), open_secret_chat($secret_chat_id, $cb), secret_chat($secret_chat_id, $cb), close_secret_chat($secret_chat_id, $cb)
    Start a secret chat with a user, reopen a known one, read its state, and
    close it.

   search_secret_messages($query, %opt, $cb)
    Searches the local database, since secret messages exist nowhere else.
    Options: "chat_id" to scope to one chat, "filter" (a
    searchMessagesFilter name, with or without the prefix), "offset",
    "limit".

   set_database_encryption_key($key, $cb), session_accepts_secret_chats($session_id, $on, $cb)
    Change the key the local database is encrypted with, and choose whether
    a logged-in session may accept secret chats at all. Losing the key loses
    every secret chat with it, as nothing on the server can restore them.

    The key is TL "bytes" and is base64-encoded on the way out, exactly as
    the "database_encryption_key" constructor option is -- 0.03 sent both
    raw, so a database opened by 0.03 with a key that happened to look like
    base64 is keyed with different bytes. See "new(%opt)" for what to pass
    in that case.

  Stories mixin
    Stories are int32 ids scoped to the chat that posted them, so every
    method here takes a poster chat id alongside the story id. Note the
    asymmetry with the rest of the API: a story id is not an int64 and stays
    a JSON number.

   post_story($chat_id, $content, %opt, $cb)
    Posts a story and, by default, waits for it to finish uploading.

    TDLib answers immediately with a provisional story whose id changes once
    the upload completes, so "wait" works exactly as it does for
    "send_message($chat_id, $text, %opt, $cb)": "sent" (the default) calls
    back with the final story from updateStoryPostSucceeded, and "accepted"
    calls back at once with the provisional one. Deleting by a provisional
    id does not work, so prefer the default unless you have a reason not to.

    $content is a path for a photo story. A video story needs the explicit
    form, because TDLib requires a duration and a cover frame timestamp and
    neither can be inferred from a file. A story video may run no longer
    than 60 seconds:

        $td->post_story($chat, 'sunset.jpg', sub { ... });
        $td->post_story($chat,
            { video => 'clip.mp4', duration => 12, cover_frame_timestamp => 1.5 },
            sub { ... });

    Options: "privacy" ('everyone' by default, or 'contacts',
    'close_friends', or an arrayref of user ids; TDLib ignores it for a
    story posted as a supergroup or channel, which everyone in the chat
    sees), "except" (an arrayref of user ids to exclude, for the first two),
    "caption" with "parse_mode", "active_period" (86400 by default; other
    values are a Premium feature), "album_ids", "post_to_page",
    "protect_content", "areas" and "from_story".

    "areas" and "from_story" reach TDLib as they are given, so they take the
    TL objects rather than anything friendlier. "areas" is an
    inputStoryAreas object wrapping the list, not the list itself -- passing
    an arrayref is refused outright with "Expected Object, but receive
    Array" -- and "from_story", which marks the story a repost, is a
    storyFullId:

        areas => { '@type' => 'inputStoryAreas', areas => [
            { '@type'   => 'inputStoryArea',
              position  => { '@type' => 'storyAreaPosition', ... },
              type      => { '@type' => 'inputStoryAreaTypeLocation', ... } },
        ] },
        from_story => { '@type'         => 'storyFullId',
                        poster_chat_id  => $chat_id,
                        story_id        => $story_id },

    edit_story takes "areas" in the same form.

   edit_story($chat_id, $story_id, %opt, $cb), edit_story_cover($chat_id, $story_id, $timestamp, $cb), delete_story($chat_id, $story_id, $cb)
    Change a posted story's content, caption or areas; move a video story's
    cover frame; remove one. edit_story takes "content" in the same forms
    post_story accepts, "caption" with "parse_mode", and "areas" as the
    inputStoryAreas object described under post_story(). Each is sent only
    when given, so an edit changes what you name and leaves the rest alone
    -- with one constraint of TDLib's: areas cannot be edited unless the
    content changes too, so pass "content" alongside them.

   story($chat_id, $story_id, %opt, $cb), active_stories($chat_id, $cb), archived_stories($chat_id, %opt, $cb), page_stories($chat_id, %opt, $cb)
    Read one story, a chat's currently active stories, its archive, or the
    stories it has pinned to its profile page. story takes "only_local => 1"
    to answer from what TDLib already holds rather than asking the server.
    archived_stories and page_stories page with "limit" and "from_story_id",
    the story to start from; 0 starts at the most recent.

   load_active_stories(%opt, $cb)
    Asks TDLib to load a story list, named by "list": 'main' by default, or
    'archive'. The callback receives a bare Ok and no stories: they arrive
    through updateChatActiveStories, so register on_story() family handlers
    to see them. Call it again for more; once the list is exhausted TDLib
    answers 404, which is reported as success with an undef result, as
    load_chats does.

   story_interactions($story_id, %opt, $cb), chat_story_interactions($chat_id, $story_id, %opt, $cb)
    Who viewed, forwarded or reacted. The first reads our own stories and
    takes no chat id; the second is the variant for a chat's stories and
    takes a "reaction" option to filter by reaction type. Both page with
    "offset" and "limit" and take "prefer_forwards => 1" to put forwards and
    reposts first, then reactions, then other views; story_interactions also
    takes "query" to search names, usernames and titles, "only_contacts",
    and "prefer_with_reaction", which puts interactions carrying a reaction
    first and is ignored when prefer_forwards is set. Without either, the
    order is by date.

   set_story_privacy($story_id, $privacy, %opt, $cb), post_story_to_page($chat_id, $story_id, $on, $cb), can_post_story($chat_id, $cb), chats_to_post_stories($cb)
    Change who can see a story, pin it to the chat page, and ask in advance
    whether posting is allowed at all. set_story_privacy takes the same
    $privacy forms as post_story, with "except" an arrayref of user ids to
    exclude from 'everyone' or 'contacts'.

   open_story($chat_id, $story_id, $cb), close_story($chat_id, $story_id, $cb), report_story($chat_id, $story_id, %opt, $cb), story_public_forwards($chat_id, $story_id, %opt, $cb)
    View bookkeeping, reporting, and public reposts of a story. Pair every
    open_story with a close_story: while a story is open TDLib refetches it
    every minute, and one of your own every ten seconds for its view count,
    and only close_story stops that. report_story takes "option_id", a
    reason id from a previous report_story reply, and "text" for the
    free-text form some reasons ask for.

   story_reactions(%opt, $cb), set_story_reaction($chat_id, $story_id, $reaction, %opt, $cb)
    The reactions available for stories, and reacting to one. $reaction
    takes the same forms as "react($chat_id, $message_id, $reaction, %opt,
    $cb)". story_reactions takes "row_size", the keyboard width a client
    would lay the reactions out in, 8 by default and silently 8 again for
    anything outside 5 to 25. set_story_reaction takes "update_recent", on
    by default.

   story_albums($chat_id, $cb), create_story_album($chat_id, $name, \@story_ids, $cb), set_story_album_name($chat_id, $album_id, $name, $cb), delete_story_album($chat_id, $album_id, $cb)
    Albums group posted stories on a profile.

   story_album_stories($chat_id, $album_id, %opt, $cb), add_album_stories($chat_id, $album_id, \@ids, $cb), remove_album_stories($chat_id, $album_id, \@ids, $cb), reorder_album_stories($chat_id, $album_id, \@ids, $cb), reorder_story_albums($chat_id, \@album_ids, $cb)
    Read and amend an album's contents, and order the albums themselves.

   on_story($cb), on_story_deleted($cb), on_active_stories($cb)
    Story updates. on_story receives a story object; on_story_deleted
    receives "($poster_chat_id, $story_id)"; on_active_stories receives a
    chatActiveStories, which is how the results of load_active_stories
    arrive.

  Stickers mixin
    Sticker set ids and custom emoji ids are int64 and cross the JSON
    interface as strings, including inside a vector: a list of set ids is a
    list of strings.

   sticker_set($set_id, $cb), search_sticker_set($name, %opt, $cb), search_sticker_sets($query, %opt, $cb)
    Fetch a set by id, by its exact name, or search for sets by a query. The
    middle one resolves a known name and takes "ignore_cache => 1" to re-ask
    the server rather than answer from what TDLib already holds; the last is
    the discovery call and takes "type".

   installed_sticker_sets(%opt, $cb), archived_sticker_sets(%opt, $cb), trending_sticker_sets(%opt, $cb), owned_sticker_sets(%opt, $cb)
    The sets installed, archived, trending, or created by this account. The
    first three take "type", one of 'regular' (the default), 'mask' or
    'custom_emoji'; owned_sticker_sets does not, since TDLib returns every
    type you own. All but installed_sticker_sets page with "limit", and
    archived_sticker_sets and owned_sticker_sets page with
    "offset_sticker_set_id" while trending_sticker_sets uses a numeric
    "offset".

   stickers($query, %opt, $cb), search_stickers($emojis, %opt, $cb), custom_emoji_stickers(\@ids, $cb)
    Find individual stickers, by query or by the emoji they represent, and
    resolve custom emoji ids to their stickers. Both searches take "type",
    one of 'regular' (the default), 'mask' or 'custom_emoji'. stickers also
    takes "chat_id", which changes nothing unless "type" is 'custom_emoji':
    it admits premium custom emoji when the chat is your own Saved Messages,
    and returns none for an old secret chat; search_stickers takes
    "languages", an arrayref of language codes for the emoji-to-sticker
    mapping, and "query" to narrow the emoji match further.

   favorite_stickers($cb), add_favorite_sticker($sticker, $cb), remove_favorite_sticker($sticker, $cb), recent_stickers(%opt, $cb), add_recent_sticker($sticker, %opt, $cb), remove_recent_sticker($sticker, %opt, $cb), clear_recent_stickers(%opt, $cb)
    Favourites and recents. Each takes an InputFile, so a path works, and
    the recents calls take "attached" to address the attached-sticker list
    instead.

   upload_sticker_file($user_id, $sticker, %opt, $cb)
    Uploads one file for later use in a set. A set is built from uploaded
    files rather than local paths, so this comes first; "format" is 'webp'
    (the default), 'tgs' or 'webm'. See the cookbook for the full
    upload-then-create sequence.

   create_sticker_set($user_id, $title, $name, \@stickers, %opt, $cb)
    Creates a set. Each element of @stickers is a hashref:

        { file => $uploaded_or_path, emojis => '...', keywords => [...],
          format => 'webp', mask_position => {...} }

    "file" and "emojis" are required. %opt takes "type" ('regular', 'mask'
    or 'custom_emoji'), "needs_repainting" and "source". $name must be
    globally unique and, for a bot-owned set, must end in "_by_<bot
    username>"; check it first with check_sticker_set_name().

   add_sticker_to_set($user_id, $name, $sticker, $cb), replace_sticker_in_set($user_id, $name, $old, $new, $cb), remove_sticker_from_set($sticker, $cb), set_sticker_position($sticker, $position, $cb)
    Amend a set. $sticker and $new take the same hashref as
    create_sticker_set; $old is a plain InputFile.

   set_sticker_set_title($name, $title, $cb), set_sticker_set_thumbnail($user_id, $name, $thumbnail, %opt, $cb), delete_sticker_set($name, $cb), install_sticker_set($set_id, $installed, %opt, $cb), reorder_sticker_sets(\@set_ids, %opt, $cb), check_sticker_set_name($name, $cb)
    Set housekeeping. install_sticker_set installs by default and uninstalls
    when passed a false second argument; "archived" archives instead.
    delete_sticker_set removes a set you own completely.
    set_sticker_set_thumbnail takes "format" ('webp' by default, or 'tgs' or
    'webm'), which must match the thumbnail file. reorder_sticker_sets takes
    "type", since each sticker type has its own order.

   set_emoji_status($custom_emoji_id, %opt, $cb), default_emoji_statuses($cb)
    Set the account's emoji status, or clear it by passing undef. "expires"
    is the Unix time it ends; one already past clears the status instead,
    without an error. Premium accounts only.

  Stars mixin
    Telegram Stars: gifts, subscriptions, revenue and affiliate programs.
    The seller-side invoice and checkout flow is in the "Payments mixin"
    instead.

    Gift ids are int64 and cross as strings; a received gift id is a TL
    string already and is passed through unchanged.

   available_gifts($cb), can_send_gift($gift_id, $cb), send_gift($gift_id, $owner, %opt, $cb)
    The gifts on offer, whether one can be sent, and sending it. $owner is a
    bare user id or a negative chat id, coerced to the right message sender.
    Options: "text" with "parse_mode", "private" and "pay_for_upgrade".

   received_gifts($owner, %opt, $cb), received_gift($received_gift_id, $cb)
    Gifts an account has received. $owner is required. Paging is "offset" (a
    string cursor) and "limit"; "sort_by_price" orders the list and
    "collection_id" narrows it to one collection. The filters are
    "exclude_saved", "exclude_unsaved", "exclude_unlimited",
    "exclude_upgradable", "exclude_non_upgradable", "exclude_upgraded",
    "exclude_without_colors" and "exclude_hosted".

   toggle_gift_saved($id, $saved, $cb), sell_gift($id, %opt, $cb), upgrade_gift($id, %opt, $cb), transfer_gift($id, $new_owner, %opt, $cb), gift_upgrade_preview($gift_id, $cb)
    Display a received gift on the profile, convert it to Stars, upgrade it
    to a unique gift, or hand it to someone else. The preview shows what an
    upgrade would produce before paying for one. upgrade_gift takes
    "keep_original_details => 1" to keep the original gift's text, sender
    and receiver on the upgraded one, and "star_count", the Stars the
    upgrade costs: pass the gift's own "upgrade_star_count", or 0 when it
    already carries a "prepaid_upgrade_star_count". It defaults to 0, which
    is the wrong value for a gift that was not prepaid. transfer_gift takes
    "star_count" the same way, for a transfer the receiving side charges
    for, and defaults it to 0 with the same trap.

   set_gift_settings(%opt, $cb)
    Which gifts this account accepts: "show_button", "unlimited", "limited",
    "upgraded", "from_channels" and "premium_subscription". Every flag is
    sent, so one left out is turned off -- pass the whole set, as with
    set_permissions. An unknown name croaks.

   star_transactions(%opt, $cb), star_subscriptions(%opt, $cb), edit_star_subscription($id, $canceled, $cb), reuse_star_subscription($id, $cb), star_payment_options($cb)
    The Star ledger and recurring Star subscriptions. star_transactions
    takes "owner" (this account by default), "direction" ('incoming' or
    'outgoing'), "subscription_id", "offset" and "limit". star_subscriptions
    pages with "offset" and takes "only_expiring => 1", which is narrower
    than it sounds: it returns only subscriptions there are not enough Stars
    to extend.

   refund_star_payment($user_id, $charge_id, $cb)
    Refunds a Star payment. The charge id comes from the successful payment
    message.

   star_revenue_statistics(%opt, $cb), chat_revenue_statistics($chat_id, %opt, $cb), chat_revenue_transactions($chat_id, %opt, $cb)
    Earnings for an account or a channel. "dark" selects graph colours.
    star_revenue_statistics takes "owner", a user or chat id to read instead
    of this account.

   star_withdrawal_url($owner, $star_count, $password, $cb), chat_revenue_withdrawal_url($chat_id, $password, $cb)
    Begin a withdrawal. Both require the account's two-factor password as a
    required argument, not an option, because omitting it would otherwise
    read as a request that merely failed.

   connected_affiliate_programs(%opt, $cb), connect_affiliate_program($bot_user_id, %opt, $cb), disconnect_affiliate_program($url, %opt, $cb)
    Affiliate programs. "affiliate" selects who is affiliating: this account
    by default, or "{ bot => $id }" or "{ channel => $chat_id }".

  Business mixin
    Telegram Business: connections to a business account, quick replies,
    away and greeting messages, and acting on a connected account as a bot.

    This plane is unverified against a live server and is covered by offline
    tests only; see "LIMITATIONS" for which methods need a Business
    subscription and which do not.

    A "business_connection_id" is a string, not a number, and arrives only
    through on_business_connection(). There is no other source for it, so a
    bot must keep the handler registered to send anything at all.

   on_business_connection($cb), on_business_message($cb)
    Connection changes, and messages arriving in a connected business
    account. on_business_message receives "{ connection_id => ..., message
    => ... }", where "message" is a businessMessage: the message itself is
    "$ev->{message}{message}", and "$ev->{message}{reply_to_message}" is the
    one it answers. Edited and deleted business messages reach
    "on_update($cb), on_error($cb)" rather than having hooks of their own.

   business_connection($connection_id, $cb), connected_bot($cb), set_connected_bot($bot_user_id, %opt, $cb), confirm_connected_bot($bot_user_id, $cb), delete_connected_bot($bot_user_id, $cb)
    Inspect a connection, and manage the bot connected to this business
    account. set_connected_bot takes "rights" and "recipients", each a
    hashref of flags; an unknown flag name croaks rather than being ignored.
    Both are sent whole, so leaving "rights" out connects a bot that may do
    nothing, and leaving "recipients" out selects no chats.

   pause_connected_bot($chat_id, $paused, $cb), remove_connected_bot_from_chat($chat_id, $cb)
    Suspend the connected bot in one chat, or detach it from that chat.

   send_business_message($connection_id, $chat_id, $text, %opt, $cb), send_business_file($connection_id, $chat_id, $path, %opt, $cb)
    Send as the connected business account. Options: "parse_mode",
    "disable_preview", "silent", "protect_content", "effect_id", "reply_to"
    and "reply_markup", plus "kind" and "caption" for the file form.

    Unlike "send_message($chat_id, $text, %opt, $cb)" these take no
    "schedule", no "wait" and no "topic": TDLib gives sendBusinessMessage
    flat notification and protection fields rather than a message-send
    options object, and does not promise a delivery update.

   edit_business_message_text($connection_id, $chat_id, $message_id, $text, %opt, $cb), read_business_message($connection_id, $chat_id, $message_id, $cb), delete_business_messages($connection_id, \@message_ids, $cb)
    Edit, mark read, and delete messages in a connected account.

   set_business_account_name($id, $first, $last, $cb), set_business_account_bio($id, $bio, $cb), set_business_account_username($id, $username, $cb), set_business_account_photo($id, $photo, %opt, $cb), business_account_star_amount($id, $cb)
    Change the connected account's profile, and read its Star balance.
    set_business_account_photo takes "public => 1" to set the public photo,
    the fallback for users whom privacy settings deny the main one, rather
    than the main photo itself.

   set_away_message($shortcut_id, %opt, $cb), set_greeting_message($shortcut_id, %opt, $cb)
    Automatic replies, each pointing at a quick-reply shortcut.
    set_away_message takes "schedule": 'always' (the default),
    'outside_opening_hours', or "{ start => $ts, end => $ts }", plus
    "offline_only". set_greeting_message takes "inactivity_days", which must
    be 7, 14, 21 or 28 and croaks otherwise: TDLib turns the greeting off
    for any other value and reports success. Both take "recipients", and
    select no chat at all without it, so the replies go to nobody until you
    say which chats they cover. $shortcut_id must name a shortcut the server
    already has; one still being created counts as none, which turns the
    reply off the same silent way.

   set_opening_hours($time_zone_id, \@intervals, $cb), set_business_location($address, %opt, $cb), set_start_page(%opt, $cb), business_features(%opt, $cb)
    Opening hours are minutes from the start of the week, each interval
    either "[$start, $end]" or "{ start => .., end => .. }". An interval
    must start before it ends and end within eight days, so one running past
    the end of the week continues past 10080 rather than wrapping back to
    the start. TDLib drops an invalid interval without an error, and removes
    the opening hours altogether when none is left. set_business_location
    takes "latitude", "longitude" and "accuracy" in metres, the first two
    together or not at all; set_start_page takes "title", "message" and
    "sticker". The sticker must already be on Telegram's servers, as one
    taken from a message is: a local path is dropped without an error, and
    with no title or message either the start page is removed.
    business_features takes "source", the feature whose promotion screen
    prompted the call, as a BusinessFeature name with or without its prefix
    ("Location" or "businessFeatureLocation").

   business_chat_links($cb), create_business_chat_link($text, %opt, $cb), edit_business_chat_link($link, $text, %opt, $cb), delete_business_chat_link($link, $cb), business_chat_link_info($link_name, $cb)
    Links that open a chat with a prefilled message. The text is formatted
    text, so "parse_mode" applies; "title" names the link.

   load_quick_replies($cb), load_quick_reply_messages($shortcut_id, $cb)
    Ask TDLib to load quick-reply shortcuts or one shortcut's messages. Both
    call back with a bare Ok: the data arrives through updates, so watch
    "on_update($cb), on_error($cb)" for updateQuickReplyShortcut and
    updateQuickReplyShortcuts, and for a shortcut's own messages
    updateQuickReplyShortcutMessages.

   add_quick_reply_message($shortcut_name, $text, %opt, $cb), edit_quick_reply_message($shortcut_id, $message_id, $text, %opt, $cb), delete_quick_reply($shortcut_id, $cb), delete_quick_reply_messages($shortcut_id, \@ids, $cb)
    Build and amend quick replies. Adding takes a shortcut name and creates
    the shortcut if it does not exist; the rest take its id.
    add_quick_reply_message takes "reply_to", the id of an earlier message
    in the same shortcut to reply to.

   set_quick_reply_name($shortcut_id, $name, $cb), reorder_quick_replies(\@ids, $cb), send_quick_reply($chat_id, $shortcut_id, %opt, $cb), check_quick_reply_name($name)
    Rename, order and send a shortcut. send_quick_reply takes "sending_id",
    a non-persistent id of your own choosing that comes back in the
    messages' messageSendingStatePending, which is how you match them to the
    updateNewMessage updates that follow. It calls back once Telegram
    accepts the messages, and refuses "wait => 'sent'".
    check_quick_reply_name is synchronous: it returns the result, and croaks
    if given a callback.

MINI APPS
    A Mini App (Telegram also calls it a Web App) is a web page a bot
    offers, opened inside a Telegram client. TDLib does not render it. It
    resolves the app, returns a URL and a launch id, and relays the data the
    page sends back; hosting a webview and loading the URL is the
    application's job. Nothing here needs a browser if all you want is the
    data channel.

    The usual flow is:

        $td->web_app($bot_id, 'probe', sub {
            my ($found, $err) = @_;
            ...
        });
        $td->open_web_app($chat_id, $bot_id, $button_url, sub {
            my ($info, $err) = @_;
            # hand $info->{url} to a webview, keep $info->{launch_id}
        });
        $td->close_web_app($launch_id, sub { });

    Data flows back either through "send_web_app_data($bot_user_id,
    $button_text, $data, $cb)", which a client calls on the page's behalf
    and the bot receives through "on_web_app_data($cb)", or through
    answer_web_app_query() for the inline variant.

  The platform identifier
    "application_name" is not a free-form label. It is sent to Telegram as
    the platform string and handed to the page as "tgWebAppPlatform".
    Telegram accepts 0-64 characters from "A-Za-z0-9_" and rejects anything
    else with "PLATFORM_INVALID", an error that names nothing near the real
    cause; a hyphen is the easy way to trip it. This module validates the
    value when the client is constructed, so the failure arrives with an
    explanation instead.

    Any accepted value works, but the value still matters. Real clients send
    a conventional identifier ("android", "ios", "macos", "tdesktop",
    "weba", "webk") and Mini App pages branch on it to pick layout, theming
    and available features. An invented name passes validation and then
    lands in whatever an app does with an unrecognised platform. The default
    is "tdesktop".

  Launch URLs carry credentials
    The URL returned by open_web_app and the web_app_*_url methods has the
    signed init data in its fragment: the user's name, username, photo URL
    and an authentication hash. It is a credential. Do not log it, paste it
    into a bug report, or store it anywhere the page itself would not go.

AUTHORIZATION
    TDLib drives authorization as a state machine reported through
    updateAuthorizationState; "auth_state()" exposes the current state. With
    auto_auth on (the default), each state is answered automatically or
    routed to a credential callback:

    authorizationStateWaitTdlibParameters
        setTdlibParameters is sent automatically from the constructor
        options. No callback. An error reply (bad api credentials, an
        unwritable database_directory) fails login: the values come from the
        constructor, so there is no interactive channel to retry through.

    authorizationStateWaitPhoneNumber
        bot_token is sent when given; otherwise requestQrCodeAuthentication
        when on_qr is set and no phone_number was given; otherwise the
        phone_number is sent. No callback in any branch. An error reply (an
        invalid phone number or bot token) fails login, for the same reason
        as above.

    authorizationStateWaitCode
        "on_code" receives "($info, $submit)": $info is the decoded
        authenticationCodeInfo, $submit is a code ref that sends the code.
        The split exists so the code can come from anywhere (a prompt, a
        GUI, a queue) without blocking the loop. A missing callback fails
        login.

        A rejected submission does not fail login: TDLib stays in the state
        after an error reply (a mistyped code, an expired one), so the
        handler is called again as "($info, $submit, $err)" with the decoded
        error as the third argument, and may submit a corrected value. To
        give up instead, close the client.

    authorizationStateWaitPassword
        "on_password" receives "($info, $submit)"; $info carries the
        password_hint. A missing callback fails login. A rejected password
        re-asks with the error as a third argument, as above.

    authorizationStateWaitEmailAddress, authorizationStateWaitEmailCode
        "on_email" and "on_email_code", same "($info, $submit)" shape and
        the same retry-on-error behaviour.

    authorizationStateWaitOtherDeviceConfirmation
        "on_qr" receives "($link)" only. The signature is deliberately
        asymmetric: QR confirmation has nothing to submit, the other device
        confirms the login, so there is no $submit callback.

    authorizationStateWaitRegistration
        Answered automatically from the register option; without it login
        fails. An error reply from registerUser fails login: like the other
        automatic steps, it has no interactive channel.

    authorizationStateWaitPremiumPurchase
        Cannot be satisfied programmatically; login fails with an error.

    authorizationStateReady
        The login() callback succeeds.

    authorizationStateClosed
        Pending requests, in-flight sends and downloads are failed, close()
        callbacks run, then on_close fires.

    A login failure that arrives when no login() is pending is reported to
    the on_error handler instead (or warn, when none is set), and recorded:
    a login() called after the failure fails deferred with the same error
    rather than waiting for a state that never comes.

UPDATES
    Anything arriving without a pending @extra is an update -- with one
    exception: a reply whose @extra matches no pending and no recently
    timed-out request is a stray, dropped with a warning rather than
    dispatched as an update. Dispatch order: the authorization state
    machine, then the per-type handlers that maintain the user and chat
    caches, track the connection state and drive downloads, upload watchers
    and in-flight sends, then the generic on_update handler.

    A live client emits updateOption traffic (and other service updates)
    that reaches on_update as soon as a loop runs, before any request is
    made. Handlers must tolerate updates they do not recognize.

    The chat cache is maintained against a fixed table of chat-field
    updates: twenty-one of the forty-one chat-field updates TDLib 1.8.66
    sends, covering what a client normally reads -- including the chat's
    "positions" in each list and "chat_lists", the lists it belongs to. The
    rest -- among them updateChatUnreadReactionCount, updateChatEmojiStatus,
    updateChatHasProtectedContent, updateChatVideoChat and the accent
    colours -- reach on_update but are not merged. A field outside the table
    therefore keeps whatever value it had when the chat entered the cache,
    for as long as the client runs, and no amount of waiting refreshes it:
    call fetch_chat() to re-read one from TDLib, or watch the update
    yourself.

    The table targets the pinned TDLib 1.8.66, commit
    022d60202e446ad1287b9fb68e687c8a0760788b. A newer TDLib that renames
    these updates would leave the cache stale; the unknown updates would
    fall through to on_update only.

    Payload fields the schema marks nullable (last_message, draft_message,
    photo, action_bar, theme, block_list, pending_join_requests) are
    assigned even when the update omits them: TDLib drops null object fields
    from its JSON entirely, so an absent key means the value was cleared,
    not that it stayed unchanged.

    Neither cache is evicted: they hold every chat and user the client has
    been told about, for as long as the client lives, and a chat record is
    whatever TDLib sent. That is what makes "chat($id)" and "user($id)"
    answer without a round trip, but it means a long-lived client in many
    chats keeps them all resident. Nothing else accumulates: updates about
    chats already cached, and file updates for ids nothing is watching, do
    not grow anything.

ESCAPE HATCH
    The convenience methods cover under half the API. send() and execute()
    take any raw TDLib request hashref, so the roughly 600 unwrapped TDLib
    methods remain fully usable:

        $td->send({ '@type' => 'getCountries' }, sub {
            my ($res, $err) = @_;
            ...
        });

    Do not set @extra yourself; see "send(\%request, $cb, %opt)".

    For offline tests, inject_raw($json) feeds a JSON string through the
    normal dispatch path. It is an internal test hook, not part of the
    supported API.

    Injecting an authorizationStateClosed makes the module forget the
    client. That is only safe while nothing has been sent to it: tdjson
    creates a client on its first request, so an id that never carried one
    has nothing behind it. Inject it after real traffic and the module stops
    tracking a client TDLib still holds.

UNICODE
    Work in character strings. Text you pass in is encoded for you, and text
    you get back is decoded for you; the conversion happens at the XS
    boundary, where TDLib's JSON is read and written as UTF-8 octets.

        $td->send_message($chat_id, "\x{41F}\x{440}\x{438}\x{432}\x{435}\x{442}", sub { });

        $td->on_message(sub {
            my ($msg) = @_;
            my $text = $msg->{content}{text}{text};
            # a character string: length is in characters, not bytes
            printf "%d characters\n", length $text;
        });

    Do not encode it yourself. Passing bytes you have already run through
    "Encode::encode" sends those bytes as though each one were a character,
    and Telegram stores the result:

        use Encode ();
        my $text = "\x{410}\x{411}";               # two characters

        $td->send_message($chat_id, $text, sub { });
        # on the wire: d0 90 d0 91   -- correct

        $td->send_message($chat_id, Encode::encode('UTF-8', $text), sub { });
        # on the wire: c3 90 c2 90 c3 90 c2 91   -- mojibake, and no error

    Nothing warns about this. Both calls succeed, and the damage is only
    visible in the message itself, so it is worth being deliberate about
    where text enters your program: decode once at the edge, and pass
    characters from there on.

    File paths are strings too, and are where this bites hardest. "readdir",
    "glob", @ARGV and %ENV hand you octets, not characters, so a path with a
    non-ASCII name in it goes out double-encoded and names a file that does
    not exist -- TDLib then fails with an error about the wrong filename.
    Decode a path before passing it to upload($path), send_file, or as
    "database_directory" or "files_directory":

        use Encode ();
        my $path = Encode::decode('UTF-8', $bytes_from_readdir);

    Paths coming back from TDLib are already characters and need nothing.

    Formatting entities are counted differently again: TDLib gives "offset"
    and "length" in UTF-16 code units, not characters. Use
    "entity_text($formatted_text, $entity), entity_texts($formatted_text)"
    rather than "substr", which is right only while the text stays inside
    the BMP.

    The same applies to every string the module sends -- captions, chat
    titles, bot descriptions, poll questions and options, keyboard labels,
    inline query results, search queries -- and to the @extra correlation
    ids, which are generated internally and never contain anything but
    digits.

    A few fields are the exception, because TDLib declares them as bytes
    rather than text: callback button data, the data given to
    "press($chat_id, $message_id, $data, $cb)", an invoice payload, and the
    two database encryption keys. The module base64-encodes them for you, so
    pass the value you mean -- a "database_encryption_key" of "hunter2" is
    stored as exactly those seven octets. A character above 255 is rejected
    rather than guessed at, since there is no encoding the module can pick
    on your behalf:

        data => Encode::encode('UTF-8', $label)

    Printing text you received to a filehandle with no encoding layer raises
    "Wide character in print". Set the layer once:

        binmode STDOUT, ':encoding(UTF-8)';

    Reading a code or password from STDIN in an interactive login is the
    mirror image: "binmode STDIN, ':encoding(UTF-8)'" if it may contain
    anything but ASCII, so that what you submit is characters.

ERROR HANDLING
    A TDLib error is never thrown: invalid arguments croak at the call site,
    but anything the server rejects arrives through the callback. Every
    asynchronous callback follows the contract "$cb->($result, $err)": $err
    is undef on success and a hashref on failure, and $result is undef
    whenever $err is set. Test $err, not $result.

    "history($chat_id, %opt, $cb)" is the one exception, and it is
    deliberate: paging can fail partway, so a failure after some pages have
    arrived hands you both the messages collected so far and the error that
    stopped it.

    A TDLib error arrives as the decoded object:

        { '@type' => 'error', code => 400, message => 'PHONE_NUMBER_INVALID' }

    Synthetic errors generated by the module itself use the same shape with
    code -1:

    *   "timeout" -- a send() request whose "timeout" option expired. The
        late reply, if it ever arrives, is dropped with a warning; it is
        never delivered to a reused @extra.

    *   "client closed" -- delivered to every in-flight request, pending
        send and active download when the client closes; a pending login()
        fails with "client closed during login".

    *   "client is closed" -- a send() attempted after the client closed;
        nothing is sent and the callback fails deferred.

    *   "download canceled" -- delivered by "cancel_download($file_id)".

    *   "download failed" -- a "download($file_id, %opt, $cb)" that failed
        after starting; TDLib reports a permanent download failure only
        through updateFile, never as a request reply.

    *   "download of file N already in progress" -- a second download() of a
        file whose first one has not finished.

    *   "message deleted before it was sent" -- a send waiting for delivery
        whose message TDLib deleted first. "message send failed" and "story
        post failed" stand in for the error object on the rare failure
        update that carries none.

    *   "ask timed out", "ask cancelled" and "ask superseded" -- the
        module's own reasons an ask() ends without an answer. A prompt that
        TDLib refuses fails the ask with TDLib's own error, and a close
        fails it with "client closed".

    *   "@name is not a user" -- user_by_username() resolved the name to a
        chat that is not a user; "nothing to mark read in chat N" --
        mark_read() with no message ids and no cached last message.

    *   A failed login. The module's own failures -- "client is already
        closed", a credential callback that was not given, a state no option
        can satisfy -- use code -1. A step TDLib refused keeps TDLib's code
        behind a message that names the step, so retry_after() reads a login
        flood wait.

  Rate limiting: error code 429
    Telegram answers too-frequent requests with error code 429 and a message
    of the form "Too Many Requests: retry after N", where N is the number of
    seconds to wait. In this TDLib the generic error type carries only
    "code" and "message", so the delay exists only in the message text and
    must be parsed from it. Do not retry immediately, and never retry in a
    tight loop: that is the pattern that gets an account limited. Back off
    for at least the stated delay, with a timer rather than a blocking
    sleep. TDLib performs its own internal rate limiting for many operations
    -- it queues and paces requests on its own -- so a 429 that reaches you
    is a hard signal, not routine operation.

    The module never retries unless you ask it to: a retry policy imposed by
    a binding hides the signal and can make limiting worse. Without the
    "retry" option nothing has changed, and the back-off policy is yours to
    write; see "Handling rate limits" in EV::Telegram::TDLib::Cookbook.

    Passing "retry" to "send(\%request, $cb, %opt)" or "call($function,
    \%args, $cb, %opt)", or to the constructor as a default, opts in to a
    capped back-off. It waits at least the delay the server stated,
    lengthens on repeated 429s, gives up rather than retrying a delay longer
    than "max_wait", and stops after "attempts". It fires only for a 429
    that states a delay.

    It does not cover message sends. A flood wait on a send is not a 429
    reply at all, as the next paragraph explains, so no reply-level retry
    can see it.

    A failed message send surfaces through updateMessageSendFailed. The
    "send_message($chat_id, $text, %opt, $cb)" callback receives that
    update's error, a 429 whose message states the delay, so
    "retry_after($err)" reads it there. The update's message also carries a
    messageSendingStateFailed with a numeric "retry_after" field in seconds,
    next to "can_retry"; watch updateMessageSendFailed via "on_update($cb),
    on_error($cb)" when you need the structured field.

  Internal failures
    Internal failures that own no request -- a TDLib frame that fails JSON
    decoding, a user callback that dies -- are reported to the "on_error"
    handler, or to warn when none is set. A dying callback is contained by
    the dispatch (wrapped in G_EVAL): it is reported, and the remaining
    updates in the same batch still run; the drain does not abort. The close
    chain is contained per callback as well: one dying callback during close
    cannot skip the remaining pending failures, the close() callbacks or
    on_close.

    One update can reach more than one of your callbacks: a new message goes
    to a command handler, then on_message, then on_web_app_data if it
    carries Mini App data. How far a die travels depends on which callback
    it is. The module guards each one it dispatches itself -- command
    handlers, ask answers, callback data routes, on_callback_query,
    on_inline_query and the file watchers -- so a die there is reported and
    the rest of the update still runs; you cannot use it to stop on_message
    from seeing the message too. A die in one of the plain handlers
    (on_message, on_user, on_chat and the rest) skips what is left of that
    update, on_update included. Either way the next update is unaffected.
    The two read differently in the report: "a callback died" for a guarded
    one, "dispatch died" for the rest.

    The practical consequence is that die is not how a callback ends the
    program. It is reported and the loop keeps running, so a script whose
    only exit path was the die simply hangs. Leave the loop instead, and let
    the exit status carry the failure:

        $td->login(sub {
            my (undef, $err) = @_;
            if ($err) {
                warn "login failed: $err->{message}\n";
                $status = 1;
                return EV::break;
            }
            ...
        });

        EV::run;
        exit $status;

    The containment covers every asynchronous delivery, not only the ones
    that arrive through the dispatch. A callback answered from a timer -- a
    send() timeout, an ask timing out, a deferred failure on a closed client
    -- is guarded the same way, so a die there reaches "on_error" rather
    than only EV's own stderr.

    Some errors are delivered synchronously, before the method returns: a
    parse_mode failure in "send_message($chat_id, $text, %opt, $cb)" or
    "edit_message($chat_id, $message_id, $text, %opt, $cb)", and equally in
    send_file, send_poll and answer_inline_query, invokes the callback with
    the parseTextEntities error before the method returns, and nothing is
    sent. A download already in progress and a mark_read with nothing to
    mark report the same way.

ENVIRONMENT
    EV_TDLIB_SHUTDOWN_TIMEOUT
        Seconds the END block waits for open clients to finish closing
        before giving up, default 3. Giving up tears TDLib's statics down
        while it is still closing, which can abort the process at exit --
        TDLib detaches its scheduler thread rather than joining it once exit
        has begun, so the crash is a race and will not show on every run.
        Raise this on a heavily loaded machine or under a sanitizer, where
        everything runs several times slower.

    TDLIB_LOG_VERBOSITY
        TDLib's log verbosity level, applied once when the module is loaded.
        Defaults to 1; TDLib's own default of 5 is very noisy on stderr.

    TD_API_ID, TD_API_HASH, TD_PHONE, TD_BOT_TOKEN, TD_DATABASE_DIRECTORY
        Not the module's API: the credential convention shared by the
        scripts in eg/ and by xt/live_auth.t. The module itself takes
        credentials only as constructor options; see "new(%opt)".

EXAMPLES
    Runnable scripts live in eg/. Once the module is installed, run one from
    anywhere with "perl eg/NAME.pl"; from an unpacked distribution "perl
    -Mblib eg/NAME.pl" works after "make", and not before it, since there is
    no blib until then. Credentials come from the environment, see
    "ENVIRONMENT":

    eg/01-login.pl
        user login with phone, SMS code and 2FA; creates the session
        database

    eg/02-bot-echo.pl
        bot login via token; echoes incoming text messages

    eg/03-list-chats.pl
        loads the chat list and prints id and title per chat

    eg/04-send-message.pl
        sends a markdown message and waits for real delivery

    eg/05-download-file.pl
        downloads a file id with progress percentage

    eg/06-raw-method.pl
        raw send()/execute() for methods the mixins do not wrap

    eg/07-gtk4-chat.pl
        a two-pane GTK4 chat window driven by EV rather than by gtk_main

    eg/08-tickit-chat.pl
        the same two panes in a terminal, on the same single loop

    eg/09-mcp-server.pl
        exposes Telegram as MCP tools over JSON-RPC on stdio

    eg/10-webapp-bot.pl
        offers a Mini App button and prints the data the page sends back

    eg/11-command-bot.pl
        command routing, inline button routing and ask()

    EV::Telegram::TDLib::Cookbook has task-oriented recipes.

CAVEATS
    *   Not fork-safe. TDLib itself is not fork-safe, so every method that
        reaches it croaks after fork: new, send, call, close, execute and
        every convenience method built on them. What does not croak is
        everything answered from this process's own memory -- "chat($id)",
        "user($id)", option() and the "on_*" accessors -- so a forked child
        can read a plausible-looking cache and only fails when it tries to
        do something with it. Do not fork with an open client. Forking
        before this process has ever made one is allowed, and is how a
        preforking worker pool should be built: the child inherits a pump
        that was never used.

    *   One reader thread per process, shared by all clients. It starts with
        the first client and runs until the process ends: closing every
        client releases the loop reference, so EV::run can return, but the
        reader itself is only joined by the END-block shutdown.

    *   The default EV loop only. Requests are delivered on EV_DEFAULT; a
        non-default loop cannot receive them. Destroying the default loop
        ("default_destroy" in EV) while a client is open is out of contract:
        the reader thread would keep signalling the freed loop through
        ev_async_send. Close every client first.

    *   Re-entering the loop from inside a callback reorders deliveries.
        TDLib orders its updates, and this module preserves that order only
        as long as the loop is not re-entered: the drain takes a whole batch
        from the reader, then calls your callbacks one by one, so a nested
        "EV::run" inside one of them -- the "wait for just this reply"
        helper a GUI or an embedded host tends to write -- drains and
        delivers whatever has arrived since, ahead of the rest of the batch
        it interrupted. Nothing is lost or delivered twice, and internal
        state stays consistent; only the order you observe changes. If order
        matters, return from the callback and let the loop come back to you.

    *   Not safe with Perl ithreads. The pump initialises once per process,
        and nothing stops a thread created after this module is loaded: perl
        clones the interpreter without re-running XS bootstrap, so both
        interpreters silently share one pump and the dispatch callback the
        parent owns. The "cannot serve a second interpreter" guard only
        fires when a second interpreter loads the module itself, which is
        the rarer case. Do not create threads in a process that has loaded
        this module; fork instead, subject to the fork rules above.

        Pinned assumption: TDLib's receive/execute buffer is thread-local.
        The no-lock design -- the reader thread copies every td_receive
        result before anything else runs, and execute() needs no lock
        against it -- rests on the "current_output" buffer in TDLib's
        ClientJson.cpp being "TD_THREAD_LOCAL". That is an implementation
        detail, not a public guarantee: the header only promises the pointer
        stays valid until the next call. Verified against the bundled TDLib
        1.8.66, commit 022d60202e446ad1287b9fb68e687c8a0760788b; re-verify
        whenever the pin moves, because a process-global buffer would make
        concurrent execute() a use-after-free race.

    *   Not designed for subclassing. Hold a client in your own object
        instead: the class is assembled from mixins, keeps its state in the
        object hash, and calls many of its own methods internally, so a
        subclass method of the same name replaces part of the machinery. The
        methods documented here are the interface; anything else is internal
        and may change.

    *   Clients stay registered until closed. The registry holds a strong
        reference on purpose: TDLib requires every client to be closed
        before process exit, so an object must not vanish when the caller
        drops its last reference. "close($cb)" is not optional; dropping
        your last Perl reference does not close anything. See
        "AUTHORIZATION" for the Closed state that ends the lifecycle.

    *   An END block closes leftover clients and pumps the loop for a
        bounded interval (three seconds, or "EV_TDLIB_SHUTDOWN_TIMEOUT") so
        TDLib flushes its database, then joins the reader thread. Pumping
        the loop runs your callbacks, so a program that exits early -- from
        a login-failure path, say, or one that never ran the loop at all --
        still sees on_update and reply callbacks fire during global
        destruction, after its own END blocks have run. Write them so they
        are safe to run then, or close() before you leave. The three seconds
        bound the pump only: the join has its own two-second wait, after
        which it prods TDLib to wake the reader and then waits without a
        deadline, and a reader sitting in td_receive can take up to ten
        seconds more to notice. Shutdown is bounded, but by the sum rather
        than by the three. It is a safety net, not a substitute for close().

    *   A callback that dies is contained and reported through on_error; the
        drain continues. See "ERROR HANDLING".

SECURITY
    The database directory holds the session: whoever reads it owns the
    account. Treat it as exactly as sensitive as a password: restrictive
    permissions, no commits, no backups to third-party storage.

    TDLib creates that directory 0750 and its binlog 0600, but the sqlite
    files are created 0644 masked by your umask, so 022 and 002 both leave
    them world-readable and only a narrower umask changes that: 027 gives
    0640, 077 gives 0600. The 0750 directory is what keeps other users out.
    This module does not set a umask, which is the caller's to choose; set
    one before creating a client if the default is not what you want. Note
    also that the examples default to a "tdlib-db" in the working directory,
    so running one from a checkout leaves a live session there.

    Credentials stay in the object. api_id, api_hash, bot_token, the phone
    number and the encryption key are held for the life of the client, so a
    core dump or a process-memory read exposes them. Perl cannot reliably
    erase a string, so this is a property to design around rather than
    something the module can fix: disable cores where it matters.

    Set database_encryption_key so the local database is encrypted at rest,
    and keep the key out of source control.

    api_id and api_hash identify your application to Telegram. Pass them
    from your environment rather than hardcoding them; new() does not read
    any environment variable itself, but the examples in eg/ take them from
    TD_API_ID and TD_API_HASH.

    The login callbacks receive credentials from wherever you choose to read
    them. If that is a terminal, do not echo the 2FA password: it lands in
    the scrollback, in a screen recording and in whatever the session is
    logged to. eg/01-login.pl shows the shape -- turn echo off through a
    guard object, so an interrupt cannot leave the terminal silent for
    whatever runs next.

    TDLib's log can carry credentials. Above the lowest levels it writes the
    requests it makes, and those include the ones carrying your api_hash and
    bot token; logging in with verbosity raised has been observed to put
    both on stderr in clear. TDLib's own default is 5, so this module sets 1
    at load, and a "TDLIB_LOG_VERBOSITY" it cannot read as a level falls
    back to 1 rather than leaving TDLib at 5. Raise it to debug something
    and whatever collects your stderr collects that too.

REQUIREMENTS
    *   perl 5.12 or later, built with 64-bit integers. Makefile.PL refuses
        to configure when ivsize is below 8: Telegram chat and user ids are
        int64, and message ids are shifted left by 20 bits, so they must
        never round-trip through an NV.

    *   EV 4.11 or later.

    *   Cpanel::JSON::XS 4.00 or later.

    *   Alien::TDLib, at configure and build time. It provides TDLib 1.8.66,
        pinned at commit 022d60202e446ad1287b9fb68e687c8a0760788b; TDLib
        itself is licensed under the Boost Software License 1.0.

LIMITATIONS
    Deliberately out of scope:

    *   No Bot API (HTTP) client; this binding speaks tdjson only.

    *   No voice or video calls.

    *   No group calls or video chats.

    *   Around 418 of TDLib's roughly one thousand methods have a
        hand-written wrapper. For the rest, call() validates argument names
        against a shipped schema catalogue, and send() and
        "execute(\%request)" are the escape hatch (see "ESCAPE HATCH").

    *   The Business plane is unverified against a live server: exercising
        it needs a Telegram Business subscription, which the author does not
        have. Its methods are covered by offline tests only. Note that the
        requirement differs across the plane. The bot-side calls
        (business_connection, send_business_message, read_business_message,
        delete_business_messages, business_account_star_amount and the
        set_business_account_* setters) are documented "for bots only" and
        need no subscription on the bot itself, since the bot acts on a
        connected business account. The TL marks eight as needing the
        current account to hold a Business subscription: set_away_message,
        set_greeting_message, send_quick_reply, set_business_location,
        set_opening_hours, set_start_page, create_business_chat_link and
        edit_business_chat_link. business_features, business_chat_link_info,
        check_quick_reply_name, connected_bot and load_quick_replies are
        callable by anyone.

    *   Gift auctions, gift crafting and live-story streaming are not
        wrapped.

    *   No log message callback (TDLib's setLogMessageCallback is not
        bound); TDLIB_LOG_VERBOSITY (see "ENVIRONMENT") is the only log
        control.

    *   Linux and macOS are CI-tested. The BSDs are unsupported:
        Alien::TDLib ships no prebuilt TDLib for them, so a build there
        means compiling TDLib from source, which is impractical inside a CI
        runner.

    *   The default EV loop only; see "CAVEATS".

SEE ALSO
    Alien::TDLib, EV, EV::Telegram::TDLib::Cookbook,
    <https://core.telegram.org/tdlib> and the td_api documentation linked
    from it, Telegram::JsonAPI (synchronous prior art on CPAN).

AUTHOR
    vividsnow

LICENSE
    This library is free software; you can redistribute it and/or modify it
    under the same terms as Perl itself.

