EV-Telegram-TDLib
view release on metacpan or search on metacpan
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
"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
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.
* 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
( run in 0.989 second using v1.01-cache-2.11-cpan-3f962061493 )