package Mnemosyne::Telegram; use strict; use warnings; use Mojo::UserAgent; use Mojo::JSON qw(encode_json); use Carp qw(croak); use constant API_BASE => 'https://api.telegram.org/bot'; use constant TITLE_TRUNCATE => 24; # new($class, $token) — create client sub new { my ($class, $token) = @_; croak "token required" unless $token; return bless { _token => $token, _ua => Mojo::UserAgent->new, }, $class; } # ----------------------------------------------------------------------- # Core API calls # ----------------------------------------------------------------------- # send_message($self, $chat_id, $text, %opts) → response hashref # opts: parse_mode, reply_markup, disable_notification sub send_message { my ($self, $chat_id, $text, %opts) = @_; return $self->_post('sendMessage', { chat_id => $chat_id, text => $text, parse_mode => $opts{parse_mode} // 'HTML', defined $opts{reply_markup} ? (reply_markup => $opts{reply_markup}) : (), defined $opts{disable_notification} ? (disable_notification => $opts{disable_notification}) : (), }); } # edit_message($self, $chat_id, $message_id, $text, %opts) → response hashref sub edit_message { my ($self, $chat_id, $message_id, $text, %opts) = @_; return $self->_post('editMessageText', { chat_id => $chat_id, message_id => $message_id, text => $text, parse_mode => $opts{parse_mode} // 'HTML', defined $opts{reply_markup} ? (reply_markup => $opts{reply_markup}) : (), }); } # edit_reply_markup($self, $chat_id, $message_id, $reply_markup) → response hashref # Pass undef to remove the keyboard entirely. sub edit_reply_markup { my ($self, $chat_id, $message_id, $reply_markup) = @_; return $self->_post('editMessageReplyMarkup', { chat_id => $chat_id, message_id => $message_id, reply_markup => $reply_markup // {}, }); } # answer_callback_query($self, $callback_query_id, %opts) # opts: text (notification text), show_alert (bool) sub answer_callback_query { my ($self, $cbq_id, %opts) = @_; return $self->_post('answerCallbackQuery', { callback_query_id => $cbq_id, defined $opts{text} ? (text => $opts{text}) : (), defined $opts{show_alert} ? (show_alert => $opts{show_alert} ? \1 : \0) : (), }); } # set_webhook($self, $url, $secret) → response hashref sub set_webhook { my ($self, $url, $secret) = @_; croak "url required" unless $url; croak "secret required" unless $secret; return $self->_post('setWebhook', { url => $url, secret_token => $secret, allowed_updates => ['message', 'callback_query'], }); } # delete_webhook($self) → response hashref sub delete_webhook { my ($self) = @_; return $self->_post('deleteWebhook', { drop_pending_updates => \1 }); } # ----------------------------------------------------------------------- # Keyboard factories — all return inline_keyboard hashrefs # ----------------------------------------------------------------------- # mark_done_keyboard(\@tasks) → reply_markup hashref # Each $task: { id => ..., title => ... } # One button per task: "✓ ", callback: done: sub mark_done_keyboard { my ($class, $tasks) = @_; my @rows = map { [{ text => "\x{2713} " . _trunc($_->{title}), callback_data => "done:$_->{id}" }] } @$tasks; return { inline_keyboard => \@rows }; } # undo_keyboard($completion_id) → reply_markup hashref sub undo_keyboard { my ($class, $cid) = @_; return { inline_keyboard => [[ { text => "\x{21A9} Undo", callback_data => "undo:$cid" } ]]}; } # confirm_delete_keyboard($task_id) → reply_markup hashref sub confirm_delete_keyboard { my ($class, $task_id) = @_; return { inline_keyboard => [[ { text => "\x{274C} Delete", callback_data => "confirm_delete:$task_id" }, { text => "\x{2716} Cancel", callback_data => "cancel" }, ]]}; } # class_keyboard() → reply_markup sub class_keyboard { my ($class) = @_; return { inline_keyboard => [ [{ text => 'Monthly date', callback_data => 'add_class:monthly_date' }], [{ text => 'Monthly weekday', callback_data => 'add_class:monthly_weekday' }], [{ text => 'Every N period', callback_data => 'add_class:every_n_period' }], [{ text => 'Interval (reset on done)', callback_data => 'add_class:interval' }], [{ text => 'Floating reminder', callback_data => 'add_class:floating' }], [{ text => "\x{2716} Cancel", callback_data => 'add_cancel' }], ]}; } # weekday_keyboard() → reply_markup sub weekday_keyboard { my ($class) = @_; my @days = (['Mon','1'],['Tue','2'],['Wed','3'],['Thu','4'], ['Fri','5'],['Sat','6'],['Sun','7']); my @row = map { { text => $_->[0], callback_data => "add_weekday:$_->[1]" } } @days; return { inline_keyboard => [ \@row, [{ text => "\x{2716} Cancel", callback_data => 'add_cancel' }], ]}; } # ordinal_keyboard() → reply_markup sub ordinal_keyboard { my ($class) = @_; return { inline_keyboard => [ [map { { text => "${_}.", callback_data => "add_ordinal:$_" } } 1..4], [{ text => 'Last', callback_data => 'add_ordinal:-1' }, { text => "\x{2716} Cancel", callback_data => 'add_cancel' }], ]}; } # period_unit_keyboard() → reply_markup sub period_unit_keyboard { my ($class) = @_; return { inline_keyboard => [[ { text => 'Day', callback_data => 'add_unit:day' }, { text => 'Week', callback_data => 'add_unit:week' }, { text => 'Month', callback_data => 'add_unit:month' }, ],[ { text => "\x{2716} Cancel", callback_data => 'add_cancel' }, ]]}; } # priority_keyboard() → reply_markup sub priority_keyboard { my ($class) = @_; return { inline_keyboard => [[ { text => "\x{2605}\x{2605}\x{2605} High", callback_data => 'add_priority:high' }, { text => "\x{2605}\x{2605} Medium", callback_data => 'add_priority:medium' }, { text => "\x{2605} Low", callback_data => 'add_priority:low' }, ],[ { text => "\x{2716} Cancel", callback_data => 'add_cancel' }, ]]}; } # skip_keyboard() → reply_markup — "Skip" + "Cancel" for optional fields sub skip_keyboard { my ($class) = @_; return { inline_keyboard => [[ { text => 'Skip', callback_data => 'add_skip' }, { text => "\x{2716} Cancel", callback_data => 'add_cancel' }, ]]}; } # save_cancel_keyboard() → reply_markup — final wizard confirmation sub save_cancel_keyboard { my ($class) = @_; return { inline_keyboard => [[ { text => "\x{2714} Save", callback_data => 'add_save' }, { text => "\x{2716} Cancel", callback_data => 'add_cancel' }, ]]}; } # ----------------------------------------------------------------------- # Internal # ----------------------------------------------------------------------- sub _post { my ($self, $method, $body) = @_; my $url = API_BASE . $self->{_token} . '/' . $method; my $tx = $self->{_ua}->post( $url => { 'Content-Type' => 'application/json' } => encode_json($body) ); my $res = eval { $tx->result }; if ($@) { warn "Telegram connection error ($method): $@\n"; return {}; } unless ($res->is_success) { warn "Telegram API error ($method): " . $res->body . "\n"; } return $res->json // {}; } sub _trunc { my ($s) = @_; return length($s) <= TITLE_TRUNCATE ? $s : substr($s, 0, TITLE_TRUNCATE - 1) . "\x{2026}"; } 1;