NAME
EV::Telegram::TDLib::Cookbook - task-oriented recipes for EV::Telegram::TDLib
DESCRIPTION
Recipes for common tasks, showing how the pieces of EV::Telegram::TDLib combine. This is not the reference; the main EV::Telegram::TDLib manual is, and it documents every option and callback named here.
Conventions in every recipe:
Callbacks receive
($result, $err);$erris undef on success. Errors are never thrown.use EV; use EV::Telegram::TDLib;is assumed, as is a constructed$tdclient. Credentials come from the environment (TD_API_ID, TD_API_HASH, TD_PHONE, TD_BOT_TOKEN) -- a convention of these recipes and eg/, not the module's API.Every snippet ends up inside a running EV loop; stand-alone recipes finish with
EV::run.Where a recipe writes
diein a callback it is shorthand for "stop here", not a way to end the program. A callback that dies is caught and reported, and the loop keeps running, so a real script warns and callsEV::breakinstead. See "ERROR HANDLING" in EV::Telegram::TDLib.
The recipes that need no credentials (formatted text, raw methods, multiple clients) were run for real against TDLib 1.8.66. The rest were checked field by field against the pinned TDLib scheme (td_api.tl at commit 022d60202e446ad1287b9fb68e687c8a0760788b).
RECIPES
Logging in as a user, and reusing the session
Problem: authorize a user account with phone number, SMS code and optional 2FA password -- and do it only once.
The database directory IS the session. The first run asks for the code; every later run with the same database_directory goes straight to authorizationStateReady and on_code never fires. Keep the callbacks in place anyway: sessions can be revoked server-side.
A mistyped code or password does not fail login: the callback fires again with the TDLib error as a third argument, so prompt again and resubmit. What fails login outright is a rejected automatic step -- an invalid phone number or bot token, or parameters Telegram refuses.
my $td = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID},
api_hash => $ENV{TD_API_HASH},
phone_number => $ENV{TD_PHONE},
database_directory => 'tdlib-db', # the session; keep it secret
on_code => sub {
my ($info, $submit) = @_;
print "code from Telegram: ";
chomp(my $code = <STDIN>);
$submit->($code);
},
on_password => sub { # only fires when 2FA is enabled
my ($info, $submit) = @_;
# a password typed here reaches the scrollback and whatever is
# recording the session: eg/01-login.pl reads it with terminal
# echo off, which is what you want anywhere it is typed rather
# than piped in
print "2FA password: ";
chomp(my $password = <STDIN>);
$submit->($password);
},
on_error => sub { warn "tdlib: $_[0]\n" },
);
$td->login(sub {
my (undef, $err) = @_;
die "login failed: $err->{message}\n" if $err;
$td->me(sub {
my ($me, $err) = @_;
die "getMe failed: $err->{message}\n" if $err;
# usernames is a plural object in this TDLib; there is
# no user->{username} field
my $names = $me->{usernames};
my $username = $names ? $names->{active_usernames}[0] : undef;
print "logged in as $me->{first_name}",
(defined $username ? " (\@$username)" : ''), "\n";
$td->close(sub { EV::break });
});
});
EV::run;
Logging in as a bot
Problem: authorize with a bot token from BotFather.
bot_token is answered automatically at authorizationStateWaitPhoneNumber; no credential callbacks are involved. A bot session needs its own database directory -- do not point it at a user session.
my $td = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID},
api_hash => $ENV{TD_API_HASH},
bot_token => $ENV{TD_BOT_TOKEN},
database_directory => 'tdlib-bot-db',
on_error => sub { warn "tdlib: $_[0]\n" },
);
$td->login(sub {
my (undef, $err) = @_;
die "login failed: $err->{message}\n" if $err;
print "bot authorized\n";
});
EV::run;
Logging in with a QR code
Problem: authorize by scanning a QR code with the Telegram app, instead of typing a phone number and code.
Set on_qr and give no phone_number: the state machine requests QR authentication only in that branch. Note the asymmetric signature: on_qr receives ($link) only. There is no $submit, because nothing is submitted -- the other device confirms the login. TDLib refreshes the link periodically, and on_qr fires again with the new one.
my $td = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID},
api_hash => $ENV{TD_API_HASH},
database_directory => 'tdlib-db',
on_qr => sub {
my ($link) = @_;
print "scan as a QR code with your phone:\n$link\n";
},
# an account with two-step verification still asks for the password
# once the scan is confirmed
on_password => sub {
my ($info, $submit) = @_;
print "password: ";
chomp(my $pw = <STDIN>);
$submit->($pw);
},
on_error => sub { warn "tdlib: $_[0]\n" },
);
$td->login(sub {
my (undef, $err) = @_;
die "login failed: $err->{message}\n" if $err;
print "confirmed by the other device\n";
});
EV::run;
Render $link with any QR tool; it is a login URL, not image data.
Reacting to incoming messages
Problem: handle new messages, ignoring your own.
on_message fires on updateNewMessage, including echoes of messages you sent. Text lives in $msg->{content}: a messageText content wraps a formattedText, so the plain string is one more level down.
$td->on_message(sub {
my ($msg) = @_;
return if $msg->{is_outgoing}; # our own messages echo back
my $content = $msg->{content} // {};
return unless ($content->{'@type'} // '') eq 'messageText';
my $text = $content->{text}{text}; # messageText wraps formattedText
return unless defined $text && length $text;
my $sender = $msg->{sender_id} // {};
my $who = $sender->{user_id} // $sender->{chat_id} // '?';
printf "%s in chat %d: %s\n", $who, $msg->{chat_id}, $text;
});
A full echo bot that adds send_message to this is eg/02-bot-echo.pl.
Sending formatted text safely
Problem: send markdown or HTML without hand-building entity arrays -- and without blowing up when the markup is bad.
send_message with parse_mode runs the text through the synchronous parseTextEntities call first. A parse error is delivered to your callback before send_message even returns, and nothing is sent -- one of the few errors that do not wait for TDLib.
$td->send_message($chat_id, 'bold *here*, _italic_ too',
parse_mode => 'markdown',
sub {
my ($msg, $err) = @_;
if ($err) { warn "not sent: $err->{message}\n"; return }
print "delivered, message id $msg->{id}\n";
});
To parse ahead of time (or to inspect entities), call parseTextEntities yourself through the synchronous execute():
my $formatted = EV::Telegram::TDLib->execute({
'@type' => 'parseTextEntities',
text => 'bold *here*',
parse_mode => { '@type' => 'textParseModeMarkdown', version => 2 },
});
if (($formatted->{'@type'} // '') eq 'error') {
die "parse failed: $formatted->{message}\n";
}
# { '@type' => 'formattedText', text => 'bold here',
# entities => [ { offset => 5, length => 4,
# type => { '@type' => 'textEntityTypeBold' } } ] }
parse_mode accepts markdown (MarkdownV2, as above) and html ({ '@type' => 'textParseModeHTML' }).
That offset => 5 is in UTF-16 code units, not characters. The two agree here only because the text is ASCII; put an emoji in front and they part company. See "Reading the formatting off a message" before slicing a formattedText by hand.
Reading the formatting off a message
Problem: a message arrived with bold runs, links or a code block, and you want the text each one covers.
Do not reach for substr. TDLib counts entity offsets and lengths in UTF-16 code units, so the moment a message contains an emoji -- one Perl character, two UTF-16 units -- every offset after it is shifted and substr quietly returns the wrong run:
my $ft = $msg->{content}{text};
# wrong: right only while the text is entirely inside the BMP
substr($ft->{text}, $_->{offset}, $_->{length}) for @{ $ft->{entities} };
# right
for my $e (@{ $td->entity_texts($ft) }) {
print "$e->{type}: $e->{text}\n";
}
An ASCII example agrees under both readings, which is exactly why this is easy to get wrong and hard to notice.
The offsets are left as TDLib sent them on purpose. Forwarding, editing or copying a message sends its entities back, and offsets rewritten into character counts would corrupt the result -- so the module hands you a slicer rather than converting anything.
Replying to a message, and sending silently
Problem: reply to a specific message, or send without waking up the chat's members.
$td->send_message($chat_id, 'ack', reply_to => $msg->{id}, sub {
my ($reply, $err) = @_;
warn "send failed: $err->{message}\n" if $err;
});
A reply that cannot be made -- to a message from another chat, one since deleted, or one of yours still being sent -- is not an error: TDLib sends a plain message instead. Pass the id, not the message itself.
$td->send_message($chat_id, 'background sync done', silent => 1, sub {
my ($sent, $err) = @_;
warn "send failed: $err->{message}\n" if $err;
});
On the wire these are NOT the classic Bot-API-style fields. The pinned TDLib schema has no reply_to_message_id and no disable_notification on sendMessage; the module emits the 1.8.66 shapes:
reply_to => { '@type' => 'inputMessageReplyToMessage',
message_id => $msg->{id} },
options => { '@type' => 'messageSendOptions',
disable_notification => \1 }, # JSON true
If you write raw sendMessage requests by hand, use those shapes.
Resolving a chat by @username
Problem: turn a public @username into a chat id you can send to.
$td->chat_by_username('durov', sub { # leading @ is optional
my ($chat, $err) = @_;
die "resolve failed: $err->{message}\n" if $err;
print "chat id $chat->{id} ($chat->{title})\n";
});
The resolved chat is also stored in the cache, so $td->chat($id) works afterwards. Only public usernames resolve this way; private chats appear through the chat list.
Walking the chat list
Problem: enumerate the user's chats.
TDLib hands chats out asynchronously as updateNewChat, and loadChats nudges more of them into existence. Collect ids from on_chat, page with load_chats until TDLib reports the list exhausted (a 404 the module normalizes to an undef result with no error), then read the cache:
my %seen;
$td->on_chat(sub { $seen{ $_[0]{id} } = 1 });
$td->login(sub {
my (undef, $err) = @_;
die "login failed: $err->{message}\n" if $err;
my $load;
$load = sub {
$td->load_chats(100, sub {
my ($res, $err) = @_;
die "load_chats failed: $err->{message}\n" if $err;
return $load->() if $res; # Ok: more may exist
for my $id (sort { $a <=> $b } keys %seen) {
my $chat = $td->chat($id) or next;
printf "%d\t%s\n", $chat->{id}, $chat->{title};
}
$td->close(sub { EV::break });
});
};
$load->();
});
This is eg/03-list-chats.pl.
Knowing when you are offline
Problem: stop sending (or tell the user) when the network is gone, and know when you are back.
TDLib reports connectivity through updateConnectionState. The module tracks it: connection_state returns the last seen state name (undef until the first update arrives), and on_connection_state fires with the state name on every change. The state is connectionStateReady only when the client is connected and caught up; connectionStateUpdating means connected but still syncing, and connectionStateWaitingForNetwork, connectionStateConnectingToProxy and connectionStateConnecting mean there is no usable route to Telegram.
my $online = 0;
$td->on_connection_state(sub {
my ($state) = @_;
$online = ($state eq 'connectionStateReady');
warn $online ? "back online\n" : "offline ($state)\n";
});
sub send_when_online {
my ($chat_id, $text) = @_;
if (!$online) { warn "offline, not sending\n"; return }
$td->send_message($chat_id, $text, sub {
my ($msg, $err) = @_;
warn "send failed: $err->{message}\n" if $err;
});
}
Requests made while offline are not rejected: TDLib queues them and they go out when the connection returns, and only then does the callback fire. A missing reply can mean "still offline", not "request lost" -- check connection_state before concluding a request failed, and give send a timeout (see "send(\%request, $cb, %opt)" in EV::Telegram::TDLib) when waiting indefinitely is not acceptable.
Paging through history
Problem: fetch more messages than a single getChatHistory page returns, and resume later.
history pages backwards from the newest message (or from from_message_id), up to limit messages in at most max_pages requests:
$td->history($chat_id, limit => 200, sub {
my ($msgs, $err, $state) = @_;
die "history failed: $err->{message}\n" if $err;
printf "got %d messages%s\n", scalar @$msgs,
$state->{complete} ? '' : ' (max_pages hit, more exist)';
# newest first; resume older with:
# $td->history($chat_id, limit => 200,
# from_message_id => $state->{last_message_id}, sub { ... });
});
$state->{complete} is true when the limit was reached or the history was exhausted; $state->{last_message_id} is the id of the oldest message fetched, for resuming.
Downloading a file with progress
Problem: download a file attachment, showing progress.
File ids come from message content and are per-session. For a document message the file is two levels down (messageDocument wraps a document, which wraps a file); for a photo, pick a size:
# my $file_id = $msg->{content}{document}{document}{id}; # document
# my $file_id = $msg->{content}{photo}{sizes}[-1]{photo}{id}; # largest photo
$td->download($file_id,
on_progress => sub {
my ($file) = @_;
my $total = $file->{expected_size} // $file->{size} // 0;
return unless $total > 0;
my $done = $file->{local} ? $file->{local}{downloaded_size} // 0 : 0;
printf "\r%d%%", int(100 * $done / $total);
},
sub {
my ($file, $err) = @_;
die "\ndownload failed: $err->{message}\n" if $err;
print "\nsaved at $file->{local}{path}\n";
$td->close(sub { EV::break });
});
The main callback fires once $file->{local}{is_downloading_completed} is true; the path under it is inside files_directory. cancel_download($file_id) aborts a pending download and fails its callback.
Uploading a file
Problem: send a local file as a document.
$td->send_file($chat_id, '/tmp/report.pdf',
caption => 'quarterly report', sub {
my ($msg, $err) = @_;
warn "upload failed: $err->{message}\n" if $err;
});
send_file builds the nesting TDLib expects: every media content wraps its InputFile in a per-kind object, so inputMessageDocument takes an inputDocument which in turn holds the inputFileLocal. Passing the InputFile straight to inputMessageDocument is the common mistake, and it reports only "InputFile is not specified", which does not point anywhere near the real problem.
upload($path) still exists and returns just the inputFileLocal hashref, for building a request by hand.
Watching upload progress
Problem: the upload above is not fire-and-forget -- for a large file you want progress, like download() gives you.
Upload progress arrives through the same updateFile as downloads, but on the remote side of the file: remote.uploaded_size grows until remote.is_uploading_completed is true. Register the file id with on_upload; the id becomes known once the send is accepted, from the returned message content. wait => 'accepted' is what makes this work: the default resolves on final delivery, by which point the upload has already finished and the watcher would never see a single update.
$td->send_file($chat_id, '/tmp/report.pdf', wait => 'accepted', sub {
my ($msg, $err) = @_;
die "send failed: $err->{message}\n" if $err;
my $file_id = $msg->{content}{document}{document}{id};
$td->on_upload($file_id, sub {
my ($file) = @_;
my $total = $file->{expected_size} // $file->{size} // 0;
my $done = $file->{remote} ? $file->{remote}{uploaded_size} // 0 : 0;
printf "\ruploaded %d%%", int(100 * $done / $total) if $total > 0;
print "\nupload complete\n"
if $file->{remote} && $file->{remote}{is_uploading_completed};
});
});
The watcher fires on every updateFile for that id and is removed automatically once the completing update has been delivered; $td->on_upload($file_id, undef) removes it earlier. Early progress updates can precede the registration -- the file id does not exist before the send is accepted -- so the first events may be missed. For a completed upload that costs only the first percentage printed; for one you cancel before its first progress update it leaves the watcher behind, so remove it when you cancel.
Sending photos, animations and other media
Problem: send something other than a document.
kind picks the content type: document (the default), photo, video, audio, animation, voice_note, video_note, sticker.
$td->send_file($chat_id, '/tmp/chart.png', kind => 'photo',
caption => 'this quarter', sub { ... });
Telegram classifies media by the metadata it is given, so pass what the kind defines: width and height for photo, animation, video and sticker; duration for the time-based kinds; title and performer for audio; length for video_note.
A Telegram animation, the thing the apps call a GIF, is an MP4. A real .gif sent as animation is accepted without error and arrives as a plain document -- the conversion is yours to do, not the server's:
# convert first, e.g. with ffmpeg:
# ffmpeg -i loop.gif -pix_fmt yuv420p loop.mp4
$td->send_file($chat_id, '/tmp/loop.mp4', kind => 'animation',
duration => 3, width => 320, height => 240,
sub { ... });
Stickers and video notes have no caption in the schema, so a caption passed with them is dropped. An existing sticker is sent by its remote file id; an arbitrary local file will not pass sticker validation.
A bot with buttons
Problem: send an inline keyboard and react when someone taps it.
Callback data is TL bytes, which the JSON interface carries base64 encoded. inline_keyboard encodes it and on_callback_query decodes it again, so your code only ever sees the plain string:
my $kb = $td->inline_keyboard([
[ { text => 'Yes', data => 'vote:yes' },
{ text => 'No', data => 'vote:no' } ],
[ { text => 'Docs', url => 'https://example.org' } ],
]);
$td->send_message($chat_id, 'Coming?', reply_markup => $kb, sub { });
$td->on_callback_query(sub {
my ($q) = @_;
# $q->{data} is 'vote:yes', already decoded
$td->answer_callback_query($q->{id},
text => "recorded $q->{data}", sub { });
});
Answer every query: until you do, the tapping client shows a spinner. An answer with show_alert set is shown as a dialog instead of a toast.
Where $chat_id comes from matters, and getting it wrong produces errors that do not explain themselves. Take it from an incoming update -- $msg->{chat_id} in "Reacting to incoming messages", or $q->{chat_id} on the query above. A bot cannot open a conversation: sending to a user id it has never heard from fails with "Chat not found", and resolving the chat first only moves the failure to "Not enough rights to send text messages to the chat". Both mean the same thing -- that user has not started the bot.
Receiving data from a Mini App
Problem: your bot offers a Mini App and you want what the page sends back through Telegram.WebApp.sendData().
Offer the app with a Web App button on a reply keyboard, then handle the data. The page's payload is a plain string, usually JSON you encoded yourself:
$td->on_message(sub {
my ($msg) = @_;
return if $msg->{is_outgoing};
return unless ($msg->{content}{text}{text} // '') =~ m{^/start};
$td->send_message($msg->{chat_id}, 'Open the app to order:',
reply_markup => $td->reply_keyboard([
[ { text => 'Order', web_app => 'https://example.com/app' } ],
]), sub { });
});
use Cpanel::JSON::XS ();
# $data is a character string, so decode it as characters: decode_json
# wants UTF-8 octets and dies on the first accented letter, which the
# eval below would swallow
my $json = Cpanel::JSON::XS->new;
$td->on_web_app_data(sub {
my ($msg, $data, $button_text) = @_;
my $order = eval { $json->decode($data) } or return;
$td->send_message($msg->{chat_id}, "Got your order", sub { });
});
Nothing renders the page here: the button carries a URL and Telegram's own client opens it. The bot only ever sees the data.
Treat $data as untrusted. It is whatever the page put in it, and a page can be opened by anyone who can reach the bot, so validate it before acting rather than trusting its shape.
Two related methods. answer_web_app_query is the bot's: an app opened from an inline button, the menu button or the attachment menu gets a query_id in its init data, and the bot answers it with one inline result, which Telegram posts on the user's behalf. An app opened from a keyboard button, as here, gets no such id. web_app_request belongs to the user side: it makes a custom method call on the app's behalf, taking its parameters as a JSON string.
To drive the same flow from a user client, for a test or an automation, call "send_web_app_data($bot_user_id, $button_text, $data, $cb)" in EV::Telegram::TDLib with the button's exact text. That is what the real client does when the page calls sendData, so the bot cannot tell the difference.
Launching a Mini App from a client
Problem: you are writing a client, not a bot, and want to open a bot's Mini App.
TDLib does not render anything. It gives you a URL and a launch id; you load the URL in a webview and tell TDLib when it closes. Get the URL from a Web App button, a direct link short name, or the bot's main app:
$td->web_app_link($chat_id, $bot_id, 'probe', sub {
my ($url, $err) = @_;
die "link: $err->{message}\n" if $err;
# $url->{url} is ready to load
});
$td->open_web_app($chat_id, $bot_id, $button_url, sub {
my ($info, $err) = @_;
die "open: $err->{message}\n" if $err;
show_webview($info->{url}, on_close => sub {
$td->close_web_app($info->{launch_id}, sub { });
});
});
Closing is not optional: TDLib keeps the launch alive by pinging Telegram every minute until close_web_app, so a launch you forget goes on costing a request a minute for the life of the client.
There is a third route: main_web_app opens the app a bot nominates as its main one, which is what tapping the bot's own button does. A bot with only named apps answers "The bot has no main Mini App", so treat that as a normal answer rather than a failure.
web_app_placeholder fetches the outline a client can draw while the page loads, if you want the launch to look less abrupt.
$button_url comes from a Web App button on a message. An empty string is accepted only for a bot that sits in the attachment menu, and otherwise answers BOT_INVALID; toggle_attachment_menu is what puts a bot there.
The URL you get back carries the signed init data in its fragment: the user's name, username, photo URL and an authentication hash. Treat it as a credential and keep it out of logs.
Driving a bot from a user account
Problem: script an interaction with someone else's bot, or test your own bot end to end.
These are the user's half of the bot API. start_bot sends the /start a deep link produces, press taps an inline keyboard button on a message the bot sent, and inline_query asks a bot for inline results as if you had typed its username:
$td->start_bot($bot_id, 'ref9', sub { });
use MIME::Base64 qw(decode_base64);
$td->on_message(sub {
my ($msg) = @_;
my $markup = $msg->{reply_markup} or return;
# only an inline keyboard has buttons to press; a reply keyboard
# just offers text to type
return unless $markup->{'@type'} eq 'replyMarkupInlineKeyboard';
my $button = $markup->{rows}[0][0] or return;
return unless $button->{type}{'@type'} eq 'inlineKeyboardButtonTypeCallback';
$td->press($msg->{chat_id}, $msg->{id},
decode_base64($button->{type}{data}), sub {
my ($answer, $err) = @_;
print "bot said: $answer->{text}\n" unless $err;
});
});
press takes and returns plain payload bytes; the base64 the JSON interface requires is handled for you, the same way on_callback_query decodes it on the bot side. The decode above is needed because press takes plain bytes, while a button read off a raw message still carries the encoded form.
To drive a Mini App without a webview, send_web_app_data sends data as if the page had called Telegram.WebApp.sendData(), so a bot cannot tell the difference. That is how xt/live_webapp.t tests the round trip with no browser involved.
Asking the user to pick a chat or some people
Problem: your bot needs a chat or a few users, and typing a username is error-prone.
Two reply keyboard buttons ask Telegram's own picker instead. A constraint applies only for a key you actually mention, so leaving one out means "either" -- except channel, which TDLib always applies, so omitting it asks for a group:
$td->send_message($chat_id, 'Where should I post?',
reply_markup => $td->reply_keyboard([
[ { text => 'Pick a channel',
request_chat => { id => 1, channel => 1, created => 1 } } ],
[ { text => 'Pick people',
request_users => { id => 2, max => 5, bot => 0 } } ],
]), sub { });
{ bot => 0 } means "not a bot"; omitting bot accepts either. The id is yours to choose and comes back in the update so you can tell two pickers apart. Ask for extra detail with want_title for a chat or want_name for users, and want_username and want_photo for either.
The other half is the answer. A user's client responds with share_chat_with_bot or share_users_with_bot, naming the message the button was on and the id you gave it:
$td->share_chat_with_bot($chat_id, $message_id, 1, $picked_chat, sub { });
You need this when scripting a client, or when testing your own bot end to end; a real client sends it for the user when they tap the button. Pass check_only => 1 to ask whether the share would be permitted without actually making it.
Editing a message a bot sent through inline mode
Problem: update a message that came from an inline query result.
An inline-mode message has no chat: it lives wherever the user inserted it, and is addressed by an inline_message_id string. That is a different address from the (chat_id, message_id) pair edit_message takes, and the two are not interchangeable. The id is not the result id your answer gave: it comes with a press on the message's buttons, where the callback query carries it:
$td->on_callback_data(again => sub {
my ($q) = @_;
return unless defined $q->{inline_message_id};
$td->answer_callback_query($q->{id});
$td->edit_inline_text($q->{inline_message_id}, '*Updated*',
parse_mode => 'markdown',
reply_markup => $td->inline_keyboard([
[ { text => 'Again', data => 'again' } ] ]),
sub { my (undef, $err) = @_; warn $err->{message} if $err });
});
To edit one the moment it is sent, before anyone presses anything, enable inline feedback for the bot in BotFather and read the id from updateNewChosenInlineResult through on_update.
edit_inline_caption and edit_inline_media change a media result, edit_inline_markup only the buttons, and edit_inline_location a live location. A markdown or HTML parse failure reaches your callback instead of being sent to TDLib, exactly as with send_message.
Marking a chat read, and showing typing
Problem: clear a chat's unread count, and look like a real client.
$td->mark_read($chat_id, sub { }); # last message
$td->mark_read($chat_id, message_ids => \@ids, sub { });
mark_read is one viewMessages, so a sweep over many chats costs one request each and leaves the account's recently opened list alone.
$td->chat_action($chat_id, 'typing', sub { });
The indicator expires by itself after a few seconds, so repeat it while the work lasts, and send cancel when it ends early. Other actions: upload_document, upload_photo, upload_video, upload_voice, record_video, record_voice.
Searching a chat
Problem: find a message without paging the whole history.
$td->search_messages($chat_id, 'invoice', limit => 20, sub {
my ($msgs, $err, $info) = @_;
die "search failed: $err->{message}\n" if $err;
printf "%d of %d\n", scalar @$msgs, $info->{total_count};
});
$info->{next_from_message_id} continues the search: pass it back as from_message_id for the next page.
Dressing a bot: name, descriptions and photo
Problem: set up a bot's profile from code instead of from BotFather.
TDLib addresses a bot by user id, and a bot session's own id arrives as an option right after login, so my_id covers the common case and these need no argument for it:
$bot->login(sub {
$bot->set_bot_name('Helper', sub { });
$bot->set_bot_short_description('answers questions', sub { });
$bot->set_bot_description('Ask me anything.', sub { });
$bot->set_bot_photo('/tmp/avatar.png', sub { });
$bot->set_commands([
[ 'start', 'Begin' ],
[ 'help', 'Show help'],
], sub { });
});
The short description is the one-liner in the profile and in search results; the long description is what fills an empty chat with the bot. The three text setters and set_commands take language_code for a localised value; the photo is one for all languages. The name, description and photo setters take bot_user_id to dress a bot from an account that owns it, but set_commands works only from the bot's own session.
The signed-in account has its own set -- set_name, set_bio, set_username, set_profile_photo -- which take no user id because they always act on the account you are logged in as. The first three are for user accounts only -- a bot session is refused them with "The method is not available to bots" -- which is why the set_bot_* family exists at all; set_profile_photo works for both.
Running a group
Problem: moderate a group and keep its profile up to date.
$td->set_chat_title($chat_id, 'Release war room', sub { });
$td->set_chat_photo($chat_id, '/tmp/logo.png', sub { });
$td->add_chat_member($chat_id, $user_id, sub { });
$td->pin_message($chat_id, $message_id, silent => 1, sub { });
Removing someone has two flavours. left is the plain kick and they can come back; banned removes and blocks them, with until as a unix timestamp for a temporary ban:
$td->set_member_status($chat_id, $user_id, 'left', sub { });
$td->set_member_status($chat_id, $user_id, 'banned',
until => time + 3600, sub { });
block_user($user_id) is the account-level block, independent of any chat; block_user($user_id, unblock => 1) reverses it.
Moderating: who is in a chat, and what they may do
Problem: inspect membership and set what ordinary members can do.
member and admins read one member and the administrator list; search_members scans by name, optionally filtered to administrators, bots, restricted, banned, contacts or members:
$td->search_members($chat, '', filter => 'administrators', sub {
my ($res, $err) = @_;
print "$_->{member_id}{user_id}\n" for @{ $res->{members} };
});
Permissions are all-or-nothing, and that is the part worth care. set_permissions replaces the entire set, so anything you leave out is denied, not left alone:
# members may talk and post photos, and nothing else
$td->set_permissions($chat, {
can_send_basic_messages => 1,
can_send_photos => 1,
}, sub { });
Read the current permissions off the chat first if you mean to change one thing rather than define the lot. A misspelled key croaks instead of being ignored, precisely because absence means denial and a silent typo would remove a right you meant to keep.
Inviting people with a link
Problem: hand out a joinable link, with limits, and see who used it.
$td->invite_link($chat, name => 'launch', limit => 10,
expires => time + 86400, sub {
my ($link, $err) = @_;
print "share this: $link->{invite_link}\n" unless $err;
});
join_request => 1 makes the link produce requests to approve rather than admitting people straight away. invite_links lists what exists, invite_link_members shows who came in through one, and revoke_invite_link kills it. replace_primary_invite_link rotates the chat's main link, invalidating the old one.
check_invite_link and join_by_link take only the link and work for a chat you are not in yet, which is how you accept an invitation.
Selling something for Telegram Stars
Problem: charge for a digital product from a bot.
Stars (currency XTR) need no payment provider, no merchant account and no shipping flow, which makes the whole sale three steps: offer, approve, deliver.
$td->send_invoice($chat, {
title => 'Sticker pack',
description => 'Ten stickers',
payload => "order-$order_id", # your id, handed back at checkout
currency => 'XTR',
prices => [ [ 'Pack' => 100 ] ], # 100 Stars
}, sub { });
Telegram then asks the bot to confirm, and gives it only seconds to answer. An unanswered query fails the payment, so answer inside the handler rather than after any slow work:
$td->on_pre_checkout_query(sub {
my ($q) = @_;
my ($order) = $q->{payload} =~ /^order-(\d+)$/;
if (still_available($order)) {
$td->answer_pre_checkout_query($q->{id}, sub { });
}
else {
$td->answer_pre_checkout_query($q->{id},
error => 'Sorry, that just sold out', sub { });
}
});
Approving is not delivery. The payment completes afterwards and reaches the bot as an ordinary message whose content is a messagePaymentSuccessfulBot -- not messagePaymentSuccessful, which is the buyer's side of the same payment and carries no payload. Unlike the pre-checkout query, this arrives undecoded, so the payload is base64:
$td->on_message(sub {
my ($msg) = @_;
my $c = $msg->{content};
return unless $c->{'@type'} eq 'messagePaymentSuccessfulBot';
my $payload = MIME::Base64::decode_base64($c->{invoice_payload});
my ($order) = $payload =~ /^order-(\d+)$/ or return;
deliver($order, $c->{telegram_payment_charge_id});
});
Keep telegram_payment_charge_id: refund_star_payment needs it to undo the charge. star_transactions reads the ledger.
For real currencies the shape is the same, plus a provider_token from BotFather and amounts in the smallest unit -- 500 is five euros. To offer shipping options priced by address, set flexible => 1 as well as need_shipping => 1, and answer on_shipping_query under the same few-second deadline; without flexible the address is collected and no query is sent.
Approving join requests
Problem: you handed out a join_request link and now people are queued behind it.
A link created with join_request => 1 does not admit anyone; it files a request you have to answer, and until you do, nothing happens. How you hear about one depends on the account. A bot admin is told as each request is filed:
$td->on_join_request(sub {
my ($req) = @_;
# bio is whatever the applicant typed; treat it as untrusted
my $ok = ($req->{bio} // '') =~ /invited by dave/i;
$td->process_join_request($req->{chat_id}, $req->{user_id}, $ok, sub { });
});
A user admin gets no such update, but can list who is waiting -- which a bot cannot:
$td->join_requests($chat, sub {
my ($res, $err) = @_;
return warn "join_requests: $err->{message}\n" if $err;
$td->process_join_request($chat, $_->{user_id}, 1, sub { })
for @{ $res->{requests} };
});
A page holds limit requests (100 by default); pass the last one as offset_request for the next. process_join_requests, also for a user only, answers everyone at once, optionally only those who came through one link. The approve flag defaults to true in both, so declining is always explicit.
Answering in the right forum topic
Problem: your bot is in a supergroup with topics and its replies all land in the wrong place.
A forum supergroup splits into topics, and a message carries the one it belongs to. Without saying which, everything you send goes to General, which is what makes a bot look broken in a modern group. Pass topic, and take it from the message you are answering:
$td->on_message(sub {
my ($msg) = @_;
return if $msg->{is_outgoing};
my $topic = $msg->{topic_id}{forum_topic_id};
$td->send_message($msg->{chat_id}, 'on it',
topic => $topic, reply_to => $msg->{id}, sub { });
});
topic works on every sending method, not just send_message. Manage the topics themselves with create_topic, edit_topic, close_topic and delete_topic; listing them with topics and pinning with pin_topic are for a user account, not a bot. A topic id is the topic's own forum_topic_id, as read above, not a message id.
Sending a message later
Problem: post at a chosen time without keeping a process alive until then.
Hand the send a Unix time and the server holds it. It must be a time, not a delay -- add time() to a number of seconds -- and at least ten seconds away, or the message goes out at once:
$td->send_message($chat, 'good morning', schedule => $epoch, sub {
my ($msg, $err) = @_;
warn $err->{message} if $err;
});
The callback fires as soon as Telegram accepts the message for later delivery, not when it is delivered. That is why wait defaults to accepted here, and why asking for wait => 'sent' with a schedule croaks rather than hanging until the due date. scheduled lists what is pending in a chat.
Asking a question with a poll
Problem: collect answers without parsing replies.
$td->send_poll($chat_id, 'Ship it?', [ 'Yes', 'Not yet' ], sub {
my ($msg, $err) = @_;
die "poll failed: $err->{message}\n" if $err;
});
Polls here are anonymous unless you say otherwise -- the opposite of TDLib's own default, and what Telegram's clients actually create. Add multiple for several answers, open_period to close it after so many seconds, or quiz with correct for a quiz. A vote can be changed unless revoting => 0; in a quiz it is final. Answers reach a user session as updateMessageContent for the poll message, carrying the new messagePoll content, and a bot as updatePoll instead, which is bots-only and is the only form a bot gets. Watch for either with "on_update($cb), on_error($cb)" in EV::Telegram::TDLib.
Taming a noisy chat list
Problem: a group will not stop talking, and your chat list is a mess.
$td->mute($chat, 8 * 3600, sub { }); # eight hours
$td->mute($chat, sub { }); # until you say otherwise
$td->unmute($chat, sub { });
$td->archive($chat, sub { });
$td->pin_chat($chat, sub { }); # unpin with a false second argument
$td->mark_unread($chat, sub { });
Muting deserves one note. TDLib keeps all of a chat's notification choices in a single object, so a naive write replaces the lot. These methods send the chat's cached settings back with only the mute changed, so muting keeps the chat's own sound and preview choices -- provided the chat is in the cache, which a loaded chat list ensures.
chats lists a chat list, and every method taking a list option accepts main (the default), archive, or a folder id as a number:
$td->chats(list => 'archive', limit => 50, sub {
my ($res, $err) = @_;
print scalar @{ $res->{chat_ids} }, " archived\n" unless $err;
});
Searching one chat, or all of them
Problem: find a message when you do not know where it was.
search_messages looks inside one chat; search_all looks across every chat, archived ones included, unless list narrows it to main or archive:
$td->search_all('invoice', limit => 20, sub {
my ($res, $err) = @_;
printf "%d matches\n", $res->{total_count} unless $err;
});
search_all pages with offset, which is an opaque cursor from the previous answer, and search_messages with from_message_id, as above; search_all also takes min_date and max_date to bound the range.
Voting in a poll, and closing it
Problem: your bot posted a poll and now needs to act on it, or you want to answer someone else's.
Option ids are zero-based positions in the list the poll was created with, and the arrayref is what makes multiple-answer polls work:
$td->answer_poll($chat, $message_id, [0], sub { });
$td->stop_poll($chat, $message_id, sub { });
Results reach a user as an updateMessageContent carrying the updated poll. A bot gets updatePoll instead, and updatePollAnswer for a vote it may see; both are bots-only, and no updateMessageContent follows. Read them with on_update.
Finding out which of your contacts use Telegram
Problem: you have phone numbers and want the accounts behind them.
import_contacts matches on the number and tells you which resolved:
$td->import_contacts([
{ phone => '+15550001', first_name => 'Ann' },
{ phone => '+15550002', first_name => 'Bob' },
], sub {
my ($res, $err) = @_;
# user_ids has a 0 for each number with no account
print "matched: @{ $res->{user_ids} }\n" unless $err;
});
contacts lists the address book, search_contacts filters it, and add_contact and remove_contacts change it. Sharing your own number back is the share_phone option, and it is off unless you ask.
Auditing the devices logged into an account
Problem: check what has access, and cut off what should not.
$td->sessions(sub {
my ($res, $err) = @_;
for my $s (@{ $res->{sessions} }) {
printf "%s %s %s %s\n", $s->{id}, $s->{device_model},
$s->{platform}, $s->{is_current} ? '(this one)' : '';
}
});
terminate_session takes one id, and terminate_other_sessions clears everything except the client you are running. Session ids are TL int64, so keep them as strings; a number would lose precision. set_session_ttl sets how many days of inactivity ends a session by itself.
Organising chats into folders
Problem: group chats into the tabs Telegram's clients show above the chat list.
A folder is a filter, not a container: it names chats explicitly and also takes flags for whole categories.
$td->create_folder({
name => 'Work',
icon => 'Work',
included_chat_ids => [ $chat_a, $chat_b ],
include_groups => 1,
exclude_muted => 1,
}, sub { });
folder_chat_count answers how many chats a spec would match without creating anything, which is the cheap way to try a filter out. The folder list itself arrives through updateChatFolders rather than being fetched, so watch for it with on_update if you need to track changes.
A shareable folder can be handed out as a link with folder_invite_link, and add_folder_by_link accepts one, taking a chats option to pick which of the offered chats to actually join.
Text that is not ASCII
Problem: your messages are in Russian, or Chinese, or contain emoji, and you want to be sure nothing mangles them.
Pass characters and read characters. The module encodes on the way out and decodes on the way in, so there is nothing to do at the boundary:
binmode STDOUT, ':encoding(UTF-8)';
# escapes here so this page stays ASCII; write the letters directly
# in your own source and add "use utf8" so perl reads them as text
my $greeting = "\x{41F}\x{440}\x{438}\x{432}\x{435}\x{442}, \x{4E16}\x{754C}";
$td->send_message($chat_id, $greeting, sub { });
$td->on_message(sub {
my ($msg) = @_;
my $text = $msg->{content}{text}{text} // return;
print "$text\n"; # already characters
printf "%d characters\n", length $text;
});
The one way to get this wrong is to encode it yourself first. Doing so sends each byte as its own character, and nothing complains:
use Encode ();
$td->send_message($chat_id, Encode::encode('UTF-8', $text), sub { });
# arrives as mojibake, with no error anywhere
use utf8 matters only for literals in your own source. Text read from a socket, a file or a database is bytes until you decode it, so decode once where it enters and pass characters from there on. See "UNICODE" in EV::Telegram::TDLib for the details.
Calling a TDLib method with no wrapper
Problem: use one of the roughly 600 TDLib methods the mixins do not cover.
call is the usual way in. It fills in the @type from the method name and checks the argument names against a catalogue of every TDLib function, so a typo fails in Perl with the valid names listed instead of coming back as an opaque server error:
$td->call(getChatMember => {
chat_id => $chat_id,
member_id => { '@type' => 'messageSenderUser', user_id => $user_id },
}, sub {
my ($member, $err) = @_;
die "getChatMember: $err->{message}\n" if $err;
print "status: $member->{status}{'\@type'}\n";
});
A method the catalogue does not know is passed straight through, so a TDLib newer than the shipped catalogue still works; a missing argument is not an error, since TDLib supplies its own defaults.
send is the same thing without the check, and is what call uses underneath. Reach for it when you want no validation at all. It takes any raw request hashref and correlates the reply by @extra, which it assigns itself and returns. Never set @extra by hand: a caller-supplied value is overwritten on purpose, because a collision would misroute a reply to the wrong callback. execute is the synchronous twin for the few methods TDLib documents as synchronous; it needs no network and no authorization.
my $version = EV::Telegram::TDLib->execute(
{ '@type' => 'getOption', name => 'version' });
print "TDLib $version->{value}\n"; # an optionValueString reply
my $extra;
$extra = $td->send({ '@type' => 'getOption', name => 'version' }, sub {
my ($res, $err) = @_;
die "getOption failed: $err->{message}\n" if $err;
print "async reply: $res->{value} (\@extra $extra)\n";
$td->close(sub { EV::break });
});
This recipe runs with auto_auth => 0 and no credentials at all; eg/06-raw-method.pl is exactly that.
Timing out a request
Problem: do not wait forever for a reply that may never come.
send accepts a timeout in seconds. On expiry the callback fails with a synthetic error -- code -1, message timeout:
$td->send({ '@type' => 'getMe' }, sub {
my ($user, $err) = @_;
if ($err) {
if (($err->{code} // 0) == -1 && $err->{message} eq 'timeout') {
warn "getMe gave up after 5s\n";
} else {
warn "getMe failed: $err->{message}\n";
}
return;
}
print "hello $user->{first_name}\n";
}, timeout => 5);
If TDLib's reply arrives after the timeout, it is dropped with a warning on stderr. It is never delivered to your callback a second time, and never to a later request that reused the @extra sequence.
Handling rate limits
Problem: Telegram answered error code 429, "Too Many Requests: retry after N" -- and a naive immediate-retry loop is how accounts get limited.
Code 429 means slow down. In this TDLib the generic error type is just code and message, so the delay lives in the message text; "retry_after($err)" in EV::Telegram::TDLib digs it out, returning undef for anything that is not a 429 carrying a delay. The retry is then scheduled with an EV::timer, never a sleep and never an immediate retry:
sub send_with_backoff {
my ($req, $cb) = @_;
$td->send($req, sub {
my ($res, $err) = @_;
if (defined(my $wait = $td->retry_after($err))) {
$wait += 1; # a little margin
warn "rate limited, retrying in ${wait}s\n";
# the loop's clock is only refreshed while it runs; if an
# earlier callback in this batch took a while, a timer armed
# from the stale value fires early -- the early retry this
# recipe exists to avoid
EV::now_update();
my $t; $t = EV::timer $wait, 0, sub {
undef $t;
send_with_backoff($req, $cb);
};
return;
}
$cb->($res, $err);
});
}
send_with_backoff({ '@type' => 'getMe' }, sub {
my ($user, $err) = @_;
warn "getMe failed: $err->{message}\n" if $err;
});
You rarely need to write this yourself: send and call take a retry option (and new() takes a default for it) that does exactly this, with a cap on the attempts and a growing wait. It is off unless you ask for it, because a retry policy you did not choose hides the 429 signal and can make limiting worse. The recipe is for when you want the policy in your own hands -- and then it should also cap the attempts and lengthen the wait on repeated 429s. And remember that TDLib already paces many operations internally, so a 429 you never see is normal operation, not a failure.
A flood wait on a message send arrives through updateMessageSendFailed rather than as a reply. With send_message in the default sent wait mode the send callback receives that update's error, a 429 whose message carries the delay, so retry_after($err) works there too. The update also carries the delay as a structured field -- its message's sending_state is a messageSendingStateFailed with a numeric retry_after in seconds, and can_retry -- the only place on the send path that offers it that way; read it through on_update:
$td->on_update(sub {
my ($obj) = @_;
return unless ($obj->{'@type'} // '') eq 'updateMessageSendFailed';
my $state = $obj->{message}{sending_state} // {};
my $wait = $state->{retry_after};
warn "message $obj->{old_message_id} failed, retry after $wait\n"
if defined $wait;
});
Running several clients in one process
Problem: run a user session and a bot session side by side.
Just construct two clients. All clients share one reader thread and one default loop; each still needs its own database_directory.
my $user = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID}, api_hash => $ENV{TD_API_HASH},
phone_number => $ENV{TD_PHONE},
database_directory => 'tdlib-user-db',
on_code => sub {
my ($info, $submit) = @_;
print "code: ";
chomp(my $code = <STDIN>);
$submit->($code);
},
);
my $bot = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID}, api_hash => $ENV{TD_API_HASH},
bot_token => $ENV{TD_BOT_TOKEN},
database_directory => 'tdlib-bot-db',
);
my $ready = 0;
$_->login(sub {
my (undef, $err) = @_;
die "login failed: $err->{message}\n" if $err;
print "both ready\n" if ++$ready == 2;
}) for $user, $bot;
EV::run; # one loop drives both
Shutting down cleanly, inside an application that already runs EV
Problem: integrate a client into an app that already owns the loop, and shut down without corrupting the session database.
Do not call EV::run yourself; the app runs it. Do call close: TDLib requires every client to be closed before exit, and dropping your last Perl reference does not close anything. An open client holds the default loop alive; keepalive(0) releases that if the client must not keep the process running on its own.
my $td = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID}, api_hash => $ENV{TD_API_HASH},
bot_token => $ENV{TD_BOT_TOKEN},
database_directory => 'tdlib-bot-db',
on_error => sub { warn "tdlib: $_[0]\n" },
);
$td->login(sub {
my (undef, $err) = @_;
warn "login failed: $err->{message}\n" if $err;
});
my $sigint = EV::signal 'INT', sub {
print "shutting down\n";
$td->close(sub { EV::break });
};
On close, in-flight requests, sends and downloads are failed with a synthetic client closed error, so no callback is left hanging. If you forget close entirely, an END block closes leftover clients and pumps the loop for a bounded interval -- a safety net, not a substitute.
Forking: why not, and what to do instead
Problem: you want a worker process doing Telegram work.
Never fork once a client has existed, open or closed. TDLib itself is not fork-safe, and in the child every method that would reach TDLib croaks (cannot be used after fork) -- though the cache readers still answer, from a copy that no longer changes. Fork first, create the client in the child:
my $pid = fork();
die "fork: $!" unless defined $pid;
if ($pid == 0) {
# child: this process never made a client, so the pump it
# inherited was never used and creating one here is allowed
my $td = EV::Telegram::TDLib->new(
api_id => $ENV{TD_API_ID}, api_hash => $ENV{TD_API_HASH},
bot_token => $ENV{TD_BOT_TOKEN},
database_directory => 'tdlib-worker-db', # per process
on_error => sub { warn "tdlib: $_[0]\n" },
);
$td->login(sub { ... });
EV::run;
exit 0;
}
# parent: create its own client here, or none
Each process needs its own database directory: TDLib locks the session database, and two processes sharing one directory is as broken as two clients sharing one.
Writing a command bot
Problem: dispatch /start, /help and friends without matching text by hand.
$td->on_command(start => sub {
my ($msg, $args) = @_;
$td->send_message($msg->{chat_id}, 'hello');
});
$td->on_command(echo => sub {
my ($msg, $args) = @_;
$td->send_message($msg->{chat_id}, length $args ? $args : 'echo what?');
});
$args is the rest of the line, already stripped of the command and any leading space, and the empty string when there is nothing. Split it yourself when you want fields:
my ($first, @rest) = split ' ', $args;
Three behaviours are worth knowing. A command addressed to another bot in a group, /start@otherbot, does not fire yours. A message that merely contains a slash later on does not fire anything, because the match uses the bot-command entity at offset 0. And your own outgoing messages never fire your handlers, so a help text that begins with /start cannot make the bot answer itself.
Handlers do not consume the message: on_message still sees it.
Asking a question and waiting for the answer
Problem: a command needs a value the user has not given yet.
$td->on_command(rename => sub {
my ($msg) = @_;
my $chat = $msg->{chat_id};
my $user = $msg->{sender_id}{user_id};
$td->ask($chat, $user, 'What should I call you?', sub {
my ($reply, $err) = @_;
return if $err; # timed out, cancelled or superseded
my $name = $reply->{content}{text}{text};
$td->send_message($chat, "noted, $name");
});
});
Nesting another ask inside the callback is how a multi-step flow is built, and works as you would expect.
The one thing to plan for: while an ask is pending it shadows the command router for that user in that chat, so their /cancel would be swallowed as the answer. Give them a way out and take it before asking:
$td->on_command(cancel => sub {
my ($msg) = @_;
my $chat = $msg->{chat_id};
$td->cancel_ask($chat, $msg->{sender_id}{user_id})
and $td->send_message($chat, 'cancelled');
});
That handler only ever runs when nothing is pending, which is exactly when there is nothing to cancel -- so the useful version handles /cancel inside the ask callback instead, where it arrives as the answer:
$td->ask($chat, $user, 'What should I call you?', sub {
my ($reply, $err) = @_;
return if $err;
my $text = $reply->{content}{text}{text} // '';
return $td->send_message($chat, 'cancelled') if $text eq '/cancel';
...
});
Asks time out after five minutes by default, which is the backstop that stops a forgotten prompt disabling someone's commands for good.
Routing inline button presses
Problem: one handler per button, rather than one if chain.
$td->on_callback_data(qr/^vote:(\w+)$/ => sub {
my ($query, $choice) = @_;
$td->answer_callback_query($query->{id}, text => "you picked $choice");
});
Regex captures are passed after the query. A plain string pattern is an exact match, so use a regex for the usual prefix:value layout. Every matching route fires, in the order you registered them.
The data is decoded before matching, so write patterns against the text your keyboard actually set, not its wire encoding.
Reacting with a custom emoji
Problem: react with a Premium custom emoji rather than a standard one.
$td->react($chat, $message_id, "\x{1F44D}", sub { ... }); # normal
$td->react($chat, $message_id,
{ custom_emoji_id => '5312536423851630001' }, sub { ... });
The id is int64 and is stringified for you; passing it as a number would lose precision above 2^53.
react is for a user account. A bot sets its reactions with set_reactions instead, which takes the whole list at once and is for bots only. Paid reactions are separate: they are staged and then committed, because TDLib rejects them in the ordinary call.
$td->add_paid_reaction($chat, $message_id, 10, sub {
$td->commit_paid_reactions($chat, $message_id, sub { ... });
});
Posting a story and getting its real id
Problem: post a story, then delete it later.
TDLib answers postStory at once with a provisional story whose id changes when the upload finishes, so deleting by the id you were handed first will not work. The default wait mode already handles this:
$td->post_story($chat, 'sunset.jpg', privacy => 'contacts', sub {
my ($story, $err) = @_;
die "post failed: $err->{message}\n" if $err;
# $story->{id} is the final id, from updateStoryPostSucceeded
$td->delete_story($chat, $story->{id}, sub { ... });
});
Pass wait => 'accepted' only when you genuinely want the provisional story back immediately and will track the update yourself.
A video story needs a duration and a cover frame timestamp, which cannot be guessed from the file:
$td->post_story($chat,
{ video => 'clip.mp4', duration => 12, cover_frame_timestamp => 1.5 },
sub { ... });
Creating a sticker set
Problem: build a set from local image files.
A set is created from uploaded files, not from paths, so each sticker is uploaded first and the returned file is what goes into the set. Check the name before doing any of the work, since it is globally unique and a bot-owned set must end in _by_<bot username>:
my $name = 'my_pack_by_mybot';
$td->check_sticker_set_name($name, sub {
my ($res, $err) = @_;
# the answer is a union, not a boolean: an occupied name comes back
# as a successful reply, so $err alone would not catch it
return warn "check failed: $err->{message}\n" if $err;
my $verdict = $res->{'@type'} // '';
return warn "name is $verdict\n"
unless $verdict eq 'checkStickerSetNameResultOk';
$td->upload_sticker_file($user_id, 'cat.webp', sub {
my ($file, $err) = @_;
return warn "upload failed: $err->{message}\n" if $err;
$td->create_sticker_set($user_id, 'My Pack', $name, [
{ file => { '@type' => 'inputFileId', id => $file->{id} },
emojis => "\x{1F431}", keywords => ['cat'] },
], sub {
my ($set, $err) = @_;
return warn "create failed: $err->{message}\n" if $err;
print "created $set->{name}\n";
});
});
});
file and emojis are required for every sticker. format defaults to webp; use 'tgs' for animated and 'webm' for video stickers.
Sending a gift
Problem: send a Telegram Stars gift to a user.
$td->available_gifts(sub {
my ($gifts, $err) = @_;
die "cannot list gifts: $err->{message}\n" if $err;
# each element is an availableGift, which wraps the gift itself
my $gift = $gifts->{gifts}[0]{gift} or return;
$td->send_gift($gift->{id}, $user_id, text => 'congratulations', sub {
my (undef, $err) = @_;
warn "gift failed: $err->{message}\n" if $err;
});
});
The owner argument takes a bare user id, or a negative chat id to gift a channel; it is turned into the right message sender for you. Gift ids are int64 and cross as strings.
Acting on a connected business account
Problem: a bot connected to someone's Telegram Business account needs to answer messages arriving there.
A business connection id arrives only through an update. There is no call that returns one out of nowhere, so the handler is not optional:
my %connections;
$td->on_business_connection(sub {
my ($conn) = @_;
$connections{ $conn->{id} } = $conn;
});
$td->on_business_message(sub {
my ($ev) = @_;
my $msg = $ev->{message}{message} or return;
return if $msg->{is_outgoing}; # the owner's own messages, and ours
$td->send_business_message($ev->{connection_id}, $msg->{chat_id},
'thanks, we will be in touch', sub { ... });
});
Note that send_business_message takes no schedule, no wait and no topic: TDLib gives it flat notification fields rather than the message-send options the ordinary send uses.
Letting the module handle a rate limit
Problem: the hand-written back-off above is the right shape, but you would rather not write it in every program.
Pass retry. It is off unless asked for, so nothing changes for code that does not:
$td->send({ '@type' => 'getMe' }, sub {
my ($me, $err) = @_;
warn "failed: $err->{message}\n" if $err;
}, retry => 1);
# or per client
my $td = EV::Telegram::TDLib->new(..., retry => { attempts => 5 });
max_wait (300 by default) is a give-up threshold rather than a clamp: if Telegram asks for longer, the 429 comes back to you unchanged instead of being retried sooner than it asked. That is the whole point -- retrying early is what escalates a limit.
Two limits worth knowing. A retry gets a fresh @extra, so the value send() returned identifies only the first attempt. And this cannot help a message send: a flood wait there is not a 429 reply at all but an updateMessageSendFailed, as described above.
SEE ALSO
EV::Telegram::TDLib (the reference manual), Alien::TDLib, EV, the scripts in eg/, https://core.telegram.org/tdlib.
AUTHOR
vividsnow
LICENSE
This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.