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" },
);

$td->login(sub {
    my (undef, $err) = @_;
    die "login failed: $err->{message}\n" if $err;
    $td->send_message($chat_id, 'hello', sub {
        my ($msg, $err) = @_;
        die "send failed: $err->{message}\n" if $err;
        $td->close(sub { EV::break });
    });
});

EV::run;

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. Errors are never thrown.

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.

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.

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

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.

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

On a closed client send() sends nothing, registers nothing and returns undef: the callback is failed deferred with a synthetic client is closed error.

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.

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.

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 (default on) controls whether users who cannot see the full profile get this photo. $path may be an InputFile hashref instead of a path.

Bots do not use this: a bot session is refused it with BOT_FALLBACK_UNSUPPORTED. See set_bot_photo in the Bots mixin.

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.

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, $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.

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.

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 or membership, 0 (the default) meaning forever. 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: openChat followed by viewMessages, which TDLib only honours while the chat is open. 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.

Messages mixin

send_message($chat_id, $text, %opt, $cb)

Sends a text message.

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.

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. Pass an empty hashref to remove them.

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.

react($chat_id, $message_id, $emoji, %opt, $cb)

Adds an emoji 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.

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.

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, %opt)". 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. 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.

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

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.

upload($path, %opt)

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 update with remote.is_uploading_completed true has been delivered; pass an undef $cb to remove it earlier. 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. 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.

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. Anything but the last means you are offline (or catching up): requests may still be sent, but they will not reach Telegram until the state returns to connectionStateReady.

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.

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.

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 or { text => ..., url => ... } for a link. A button with neither 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.

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. All four accept language_code for a localised value.

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.

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 and chat_type. 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.

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 (updateChatTitle, updateChatLastMessage, updateChatPosition and friends) that 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.

ESCAPE HATCH

The convenience methods cover a small fraction of the API. send() and execute() take any raw TDLib request hashref, so the roughly one thousand 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.

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.

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

Errors are never thrown. 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.

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 deliberately implements no automatic retry: a wrong retry policy inside a binding hides the signal and can make limiting worse. The back-off policy belongs to the caller; see "Handling rate limits" in EV::Telegram::TDLib::Cookbook.

One structured exception: a failed message send surfaces through updateMessageSendFailed, whose message carries a messageSendingStateFailed with a numeric retry_after field in seconds, next to can_retry. The "send_message($chat_id, $text, %opt, $cb)" callback receives only the error object; 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.

The containment covers dispatch context. An exception in a send() timeout callback runs in an EV timer, not in the dispatch: it is not contained and propagates out of EV::run like any other EV watcher callback.

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/ (from the distribution root: perl -Mblib eg/NAME.pl; 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

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

CAVEATS

  • Not fork-safe. TDLib itself is not fork-safe, so every method croaks after fork. 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.

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

  • 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) so TDLib flushes its database, then joins the reader thread. 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.

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. Read them from the environment (TD_API_ID, TD_API_HASH), never hardcode them.

REQUIREMENTS

  • perl 5.12 or later, built with 64-bit integers. The Makefile refuses to build 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 not in 0.01:

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

  • No voice or video calls.

  • No secret-chat sugar beyond the use_secret_chats switch.

  • No per-method wrappers for the roughly one thousand TDLib methods; send() and "execute(\%request)" are the escape hatch (see "ESCAPE HATCH").

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

  • Linux-focused: developed and CI-tested on Linux, untested elsewhere.

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