EV-Telegram-TDLib
view release on metacpan or search on metacpan
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. 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.
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.
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.
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.
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($query_id, \%result, $cb)" 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
"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
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
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.
* Not safe with Perl ithreads. The pump initialises once per process,
so on a threaded perl any "threads->create" after this module is
loaded dies with "the pump cannot serve a second interpreter" --
even for a thread that never touches Telegram. 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
( run in 1.142 second using v1.01-cache-2.11-cpan-007c89162af )