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

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.

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.

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.

Calling a TDLib method with no wrapper

Problem: use one of the roughly one thousand TDLib methods the mixins do not cover.

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