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); $err is undef on success. Errors are never thrown.

  • use EV; use EV::Telegram::TDLib; is assumed, as is a constructed $td client. 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.

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) = @_;
        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";
    },
    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. A parse failure is reported synchronously, before the method returns, unlike every other error in the module.

$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 "reply failed: $err->{message}\n" if $err;
});

$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; correctness is not affected, only the first percentage printed.

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 { });
});

$td->on_web_app_data(sub {
    my ($msg, $data, $button_text) = @_;
    my $order = eval { decode_json($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 sit on the bot side. answer_web_app_query answers a page that called Telegram.WebApp.switchInlineQuery() with a single inline result, and web_app_request 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});
    $td->close_web_app($info->{launch_id}, sub { }) if user_closed_it();
});

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 $rows = $msg->{reply_markup}{rows} or return;
    my $button = $rows->[0][0];
    $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":

$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, want_username and want_photo.

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 the inline_message_id string that came with the result. That is a different address from the (chat_id, message_id) pair edit_message_text takes, and the two are not interchangeable:

$td->edit_inline_text($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 });

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 { });

TDLib only honours a read while the chat is open, so mark_read sends openChat and viewMessages as a pair. Without it an automated account accumulates unread counts forever.

$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. Pass language_code to any of them to set a localised value, and bot_user_id to dress a bot from an account that owns it rather than 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. Those four are for user accounts only: a bot session is refused them ("The method is not available to bots", and BOT_FALLBACK_UNSUPPORTED for the photo), which is why the set_bot_* family exists at all.

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.

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 arrives as an ordinary message whose content is a messagePaymentSuccessful, so watch "on_message($cb)" in EV::Telegram::TDLib for it and hand over the goods there. refund_star_payment undoes a charge, and 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. If you set need_shipping => 1 you must also answer on_shipping_query with the options you offer, under the same few-second deadline.

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. Watch for them and decide:

$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 { });
});

join_requests lists whoever is already waiting, which is what you need on startup since requests filed while you were offline do not replay as updates:

$td->join_requests($chat, sub {
    my ($res, $err) = @_;
    $td->process_join_request($chat, $_->{user_id}, 1, sub { })
        for @{ $res->{requests} || [] };
});

process_join_requests 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, pin_topic and delete_topic, and list them with topics. A topic id is the id of the message that opened it, so it is an ordinary 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:

$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. Answers arrive as updateMessageContent for the poll message, carrying the new messagePoll content -- both updatePoll and updatePollAnswer are bots-only, so a user session never sees them. Watch for it 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 every other field as "use the default", which means muting a chat cannot silently reset its sound or preview settings as a side effect.

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 a whole chat list:

$td->search_all('invoice', limit => 20, sub {
    my ($res, $err) = @_;
    printf "%d matches\n", $res->{total_count} unless $err;
});

Both page with offset, and 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 arrive as an updateMessageContent carrying the updated poll, not as a poll-specific 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 one thousand 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";
            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;
});

This is a pattern for the CALLER, not module policy: the module retries nothing on its own, by design -- a wrong retry policy inside a binding hides the 429 signal and can make limiting worse. A real implementation 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 does not arrive as a 429 reply. With send_message in the default sent wait mode it surfaces as updateMessageSendFailed, whose message carries a sending_state of type messageSendingStateFailed with a numeric retry_after field in seconds (and can_retry) -- the one place this TDLib offers the delay as a structured field. The send callback receives only the error object, so watch updateMessageSendFailed through on_update when you want that value:

$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 with an open client. TDLib itself is not fork-safe, and every method of this module croaks after fork (cannot be used after fork). 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.

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.