diff --git a/bin/mnemosyne-bot b/bin/mnemosyne-bot index c5d08db..f74672a 100755 --- a/bin/mnemosyne-bot +++ b/bin/mnemosyne-bot @@ -4,10 +4,75 @@ use warnings; use FindBin qw($RealBin); use lib "$RealBin/../lib"; -# TODO: Parse --config flag (default: $RealBin/../config/mnemosyne.conf) -# TODO: Load Mnemosyne::Config, Mnemosyne::DB -# TODO: Build Mojolicious::Lite app: -# POST / => validate secret token, route to Mnemosyne::Webhook -# TODO: Start app on 127.0.0.1: (never bind 0.0.0.0) +# Webhook receiver — Mojolicious::Lite app. +# Binds to 127.0.0.1 only; nginx terminates TLS and proxies inbound updates. +# +# Startup: +# mnemosyne-bot --config /path/to/mnemosyne.conf +# mnemosyne-bot --config /path/to/mnemosyne.conf daemon (same, explicit) +# +# In production (systemd), the unit sets: +# ExecStart=/usr/bin/perl /opt/mnemosyne/bin/mnemosyne-bot --config /etc/mnemosyne/mnemosyne.conf -print "mnemosyne-bot: not yet implemented\n"; +use Mnemosyne::Config; +use Mnemosyne::DB; +use Mnemosyne::Telegram; +use Mnemosyne::Webhook; +use Mojolicious::Lite; + +# ---- parse --config before Mojolicious sees ARGV ---------------------- +my $config_path = "$RealBin/../config/mnemosyne.conf"; +my @passthrough; +while (@ARGV) { + my $arg = shift @ARGV; + if ($arg eq '--config' && @ARGV) { + $config_path = shift @ARGV; + } else { + push @passthrough, $arg; + } +} +@ARGV = @passthrough; + +# ---- load config & bootstrap ------------------------------------------ +die "Config file not found: $config_path\n" unless -f $config_path; +my $config = Mnemosyne::Config->new($config_path); +my $db = Mnemosyne::DB->new($config->get('db', 'path')); +my $token = $config->get('bot', 'token') or die "bot.token missing in config\n"; +my $secret = $config->get('bot', 'webhook_secret') or die "bot.webhook_secret missing in config\n"; +my $port = $config->get('bot', 'listen_port') // 8443; +my $wh_url = $config->get('bot', 'webhook_url') or die "bot.webhook_url missing in config\n"; + +my $telegram = Mnemosyne::Telegram->new($token); + +# Extract the path component from the webhook URL +# e.g. https://example.com/tghook/abc123 → /tghook/abc123 +(my $wh_path = $wh_url) =~ s{^https?://[^/]+}{}; +$wh_path ||= '/webhook'; + +# ---- webhook endpoint ------------------------------------------------- +post $wh_path => sub { + my ($c) = @_; + + # Gate 1: validate Telegram's secret-token header + my $incoming_secret = $c->req->headers->header('X-Telegram-Bot-Api-Secret-Token') // ''; + unless ($incoming_secret eq $secret) { + $c->render(text => 'Forbidden', status => 403); + return; + } + + # Respond 200 immediately — Telegram retries on non-2xx or timeouts + $c->render(text => 'ok', status => 200); + + # Process the update (synchronous; fast enough for single-user load) + my $update = $c->req->json; + eval { + Mnemosyne::Webhook->handle_update($update, $db, $config, $telegram); + }; + warn "Webhook processing error: $@\n" if $@; +}; + +# ---- start ------------------------------------------------------------ +# Default to daemon on localhost; let explicit ARGV override for hypnotoad etc. +push @ARGV, ('daemon', '-l', "http://127.0.0.1:$port") unless @ARGV; + +app->start; diff --git a/bin/mnemosyne-digest b/bin/mnemosyne-digest index e5b886a..2b13971 100755 --- a/bin/mnemosyne-digest +++ b/bin/mnemosyne-digest @@ -4,16 +4,72 @@ use warnings; use FindBin qw($RealBin); use lib "$RealBin/../lib"; -# Invoked by cron every N minutes (e.g. */5 * * * *). -# Exits immediately unless: -# a) the current local time matches digest_time (within the cron granularity window), AND -# b) last_digest_sent (config table) is not today's date. -# On success, records today's date in last_digest_sent to prevent duplicate sends. +# Invoked by cron every 5 minutes (recommended: */5 * * * *). +# Checks whether it's time to send the morning digest; exits silently otherwise. +# Guards against duplicate sends with last_digest_sent in the config table. # -# TODO: Load config + DB -# TODO: Read digest_time and last_digest_sent from config table -# TODO: Compare current local time against digest_time (allow ±(cron_interval/2) window) -# TODO: If it's time and not already sent: call Mnemosyne::Digest::send(...) -# TODO: On success: update last_digest_sent to today's ISO date +# Usage: +# mnemosyne-digest [--config /path/to/mnemosyne.conf] -print "mnemosyne-digest: not yet implemented\n"; +use Mnemosyne::Config; +use Mnemosyne::DB; +use Mnemosyne::Telegram; +use Mnemosyne::Digest; +use DateTime; +use DateTime::TimeZone; + +use constant WINDOW_MINUTES => 5; # half-window each side of digest_time + +my $config_path = "$RealBin/../config/mnemosyne.conf"; +while (@ARGV) { + my $arg = shift @ARGV; + if ($arg eq '--config' && @ARGV) { $config_path = shift @ARGV } + else { die "Unknown argument: $arg\n" } +} + +die "Config file not found: $config_path\n" unless -f $config_path; +my $config = Mnemosyne::Config->new($config_path); +my $db = Mnemosyne::DB->new($config->get('db', 'path')); + +# ---- current local time ----------------------------------------------- +my $tz_name = $db->config_get('timezone') // 'UTC'; +my $now = eval { DateTime->now(time_zone => $tz_name) } + // do { warn "Unknown timezone '$tz_name', falling back to UTC\n"; + DateTime->now(time_zone => 'UTC') }; + +# ---- configured digest time ------------------------------------------- +my $digest_time = $db->config_get('digest_time') // '06:30'; +my ($dh, $dm) = split /:/, $digest_time; +$dh //= 6; $dm //= 30; + +# ---- check window: |now - digest_time| ≤ WINDOW_MINUTES --------------- +my $target = $now->clone->truncate(to => 'day') + ->add(hours => $dh + 0, minutes => $dm + 0); +my $diff = abs(($now->epoch - $target->epoch) / 60); # minutes + +unless ($diff <= WINDOW_MINUTES) { + exit 0; # not time yet (or already past the window) +} + +# ---- guard: already sent today? --------------------------------------- +my $today_str = $now->strftime('%Y-%m-%d'); +my $last_sent = $db->config_get('last_digest_sent') // ''; +if ($last_sent eq $today_str) { + exit 0; # already sent +} + +# ---- send! ------------------------------------------------------------ +my $token = $config->get('bot', 'token') or die "bot.token missing\n"; +my $chat_id = ($config->allowed_chat_ids)[0] or die "No allowed_chat_ids in config\n"; +my $telegram = Mnemosyne::Telegram->new($token); +my $today_dt = $now->clone->truncate(to => 'day'); + +eval { + Mnemosyne::Digest->send($db, $telegram, $chat_id, $today_dt); +}; +if ($@) { + warn "Digest send failed: $@\n"; + exit 1; +} + +exit 0; diff --git a/bin/mnemosyne-webhook b/bin/mnemosyne-webhook index 27bda32..3644fea 100755 --- a/bin/mnemosyne-webhook +++ b/bin/mnemosyne-webhook @@ -4,16 +4,48 @@ use warnings; use FindBin qw($RealBin); use lib "$RealBin/../lib"; -# One-shot helper to register (or remove) the Telegram webhook. +# One-shot helper to register or remove the Telegram webhook. # Run once after install, and again whenever the webhook URL changes. # Only one webhook can be active per bot token at a time. # # Usage: -# mnemosyne-webhook [--config /path/to/mnemosyne.conf] [--delete] -# -# TODO: Load config -# TODO: --delete: call Mnemosyne::Telegram::delete_webhook() -# TODO: default: call Mnemosyne::Telegram::set_webhook(webhook_url, webhook_secret) -# TODO: Print result (registered URL, or confirmation of deletion) +# mnemosyne-webhook [--config /path] [--delete] -print "mnemosyne-webhook: not yet implemented\n"; +use Mnemosyne::Config; +use Mnemosyne::Telegram; + +my $config_path = "$RealBin/../config/mnemosyne.conf"; +my $do_delete = 0; + +while (@ARGV) { + my $arg = shift @ARGV; + if ($arg eq '--config' && @ARGV) { $config_path = shift @ARGV } + elsif ($arg eq '--delete') { $do_delete = 1 } + else { die "Unknown argument: $arg\n" } +} + +die "Config file not found: $config_path\n" unless -f $config_path; +my $config = Mnemosyne::Config->new($config_path); +my $token = $config->get('bot', 'token') or die "bot.token missing\n"; +my $secret = $config->get('bot', 'webhook_secret') or die "bot.webhook_secret missing\n"; +my $wh_url = $config->get('bot', 'webhook_url') or die "bot.webhook_url missing\n"; + +my $tg = Mnemosyne::Telegram->new($token); + +if ($do_delete) { + my $res = $tg->delete_webhook; + if ($res->{ok}) { + print "Webhook deleted.\n"; + } else { + print "Error: $res->{description}\n"; + exit 1; + } +} else { + my $res = $tg->set_webhook($wh_url, $secret); + if ($res->{ok}) { + print "Webhook registered: $wh_url\n"; + } else { + print "Error: $res->{description}\n"; + exit 1; + } +} diff --git a/lib/Mnemosyne/Digest.pm b/lib/Mnemosyne/Digest.pm index 12707fb..bad7d62 100644 --- a/lib/Mnemosyne/Digest.pm +++ b/lib/Mnemosyne/Digest.pm @@ -3,6 +3,7 @@ use strict; use warnings; use DateTime; use Mnemosyne::Schedule; +use Mnemosyne::Telegram; use Carp qw(croak); use constant TELEGRAM_MAX_LEN => 4096; @@ -174,12 +175,12 @@ sub render_telegram { my ($class, $digest) = @_; my @parts; - my ($cur_text, @cur_ids) = (''); + my ($cur_text, @cur_tasks) = (''); my $flush = sub { return unless length $cur_text; - push @parts, { text => $cur_text, task_ids => [@cur_ids] }; - ($cur_text, @cur_ids) = (''); + push @parts, { text => $cur_text, tasks => [@cur_tasks] }; + ($cur_text, @cur_tasks) = (''); }; if ($digest->{all_clear}) { @@ -190,12 +191,12 @@ sub render_telegram { for my $sec (@{ $digest->{sections} }) { my $block = "$sec->{header}\n\n"; - my @block_ids; + my @block_tasks; for my $task (@{ $sec->{tasks} }) { - my ($line, $is_actionable) = _render_task_html($task, $sec->{key}); + my $line = _render_task_html($task, $sec->{key}); $block .= $line; - push @block_ids, $task->{id} if $is_actionable; + push @block_tasks, { id => $task->{id}, title => $task->{title} }; } $block .= "\n"; @@ -204,7 +205,7 @@ sub render_telegram { $flush->(); } $cur_text .= $block; - push @cur_ids, @block_ids; + push @cur_tasks, @block_tasks; } $flush->(); @@ -213,8 +214,8 @@ sub render_telegram { sub _render_task_html { my ($task, $section_key) = @_; - my $title = _html_esc($task->{title}); - my $detail = ''; + my $title = _html_esc($task->{title}); + my $detail = ''; if ($section_key eq 'overdue') { my $days = abs($task->{days_until} // 0); @@ -230,8 +231,7 @@ sub _render_task_html { $detail = $stars{ $task->{priority} // '' } // ''; } - my $line = "\x{2022} $title" . ($detail ? " $detail" : '') . "\n"; - return ($line, 1); # 1 = actionable (all digest tasks get a Mark Done button) + return "\x{2022} $title" . ($detail ? " $detail" : '') . "\n"; } # ----------------------------------------------------------------------- @@ -248,12 +248,14 @@ sub send { my $parts = $class->render_telegram($digest); for my $part (@$parts) { + my $kb = @{ $part->{tasks} } + ? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks}) + : undef; $telegram->send_message( $chat_id, $part->{text}, parse_mode => 'HTML', - # reply_markup built by Telegram.pm once that module exists: - # reply_markup => Mnemosyne::Telegram->mark_done_keyboard($part->{task_ids}), + defined $kb ? (reply_markup => $kb) : (), ); } diff --git a/lib/Mnemosyne/Task.pm b/lib/Mnemosyne/Task.pm index 50ef18e..095ea4d 100644 --- a/lib/Mnemosyne/Task.pm +++ b/lib/Mnemosyne/Task.pm @@ -1,17 +1,220 @@ package Mnemosyne::Task; use strict; use warnings; +use Carp qw(croak); -# CRUD operations for the tasks table, plus class-specific field validation. -# -# TODO: create($dbh, \%fields) — insert a new task, validate class + required fields -# TODO: get($dbh, $id) — fetch one task by id (returns hashref or undef) -# TODO: list($dbh, %filters) — list tasks; filters: class, active, priority -# TODO: update($dbh, $id, \%fields) — partial update; sets updated_at -# TODO: delete($dbh, $id) — hard delete (completions cascade) -# TODO: disable($dbh, $id) — set active=0 -# TODO: complete($dbh, $id) — insert into completions; for floating, also sets active=0 -# TODO: undo_completion($dbh, $completion_id) — delete a completion row (misclick undo) -# TODO: last_completion($dbh, $id) — returns the most recent completions row for a task +# Class-level CRUD for the tasks table. +# All methods take $dbh (a DBI handle) as their first non-class argument so +# they can be called from any context without needing a full DB object. + +# ----------------------------------------------------------------------- +# Validation tables +# ----------------------------------------------------------------------- + +my %REQUIRED_FIELDS = ( + monthly_date => [qw(day_of_month)], + monthly_weekday => [qw(weekday ordinal)], + every_n_period => [qw(interval_n period_unit anchor_date)], + interval => [qw(interval_days)], + floating => [qw(priority)], +); + +my %VALID_PRIORITY = map { $_ => 1 } qw(high medium low); +my %VALID_UNIT = map { $_ => 1 } qw(day week month); + +# ----------------------------------------------------------------------- +# Public interface +# ----------------------------------------------------------------------- + +# create($class, $dbh, \%fields) → task hashref or dies +sub create { + my ($class, $dbh, $f) = @_; + my $tc = $f->{class} or croak "class is required"; + my $title = $f->{title} // ''; + $title =~ s/^\s+|\s+$//g; + croak "title is required" unless length $title; + croak "Unknown task class: $tc" unless exists $REQUIRED_FIELDS{$tc}; + + for my $field (@{ $REQUIRED_FIELDS{$tc} }) { + croak "Field '$field' required for $tc" + unless defined $f->{$field} && length $f->{$field}; + } + + _validate_class_fields($tc, $f); + + $dbh->do(q{ + INSERT INTO tasks + (title, notes, class, active, + day_of_month, weekday, ordinal, + interval_n, period_unit, anchor_date, + interval_days, priority) + VALUES (?,?,?,?, ?,?,?, ?,?,?, ?,?) + }, undef, + $title, + $f->{notes} // '', + $tc, + exists $f->{active} ? ($f->{active} ? 1 : 0) : 1, + $f->{day_of_month}, $f->{weekday}, $f->{ordinal}, + $f->{interval_n}, $f->{period_unit}, $f->{anchor_date}, + $f->{interval_days}, $f->{priority}, + ); + return get($class, $dbh, $dbh->last_insert_id(undef, undef, 'tasks', undef)); +} + +# get($class, $dbh, $id) → hashref or undef +sub get { + my ($class, $dbh, $id) = @_; + return $dbh->selectrow_hashref('SELECT * FROM tasks WHERE id = ?', undef, $id); +} + +# list($class, $dbh, %filters) → arrayref of hashrefs +# Filters: active (default 1), class, priority +sub list { + my ($class, $dbh, %f) = @_; + my (@where, @bind); + + my $active = exists $f{active} ? ($f{active} ? 1 : 0) : 1; + push @where, 'active = ?'; push @bind, $active; + + if (defined $f{class}) { push @where, 'class = ?'; push @bind, $f{class} } + if (defined $f{priority}) { push @where, 'priority = ?'; push @bind, $f{priority} } + + my $sql = 'SELECT * FROM tasks WHERE ' . join(' AND ', @where) . ' ORDER BY title'; + return $dbh->selectall_arrayref($sql, { Slice => {} }, @bind); +} + +# update($class, $dbh, $id, \%fields) → updated task hashref or undef if not found +# Only columns present in %fields are changed; updated_at is always refreshed. +sub update { + my ($class, $dbh, $id, $f) = @_; + my @cols = grep { exists $f->{$_} } qw( + title notes active + day_of_month weekday ordinal + interval_n period_unit anchor_date + interval_days priority + ); + return unless @cols; + + if (exists $f->{title}) { + $f->{title} =~ s/^\s+|\s+$//g; + croak "title cannot be empty" unless length $f->{title}; + } + + my $set = join(', ', map { "$_ = ?" } @cols) + . ", updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')"; + $dbh->do("UPDATE tasks SET $set WHERE id = ?", undef, @{$f}{@cols}, $id); + return get($class, $dbh, $id); +} + +# delete($class, $dbh, $id) — hard delete; completions cascade +sub delete { ## no critic (Subroutines::ProhibitBuiltinHomonyms) + my ($class, $dbh, $id) = @_; + $dbh->do('DELETE FROM tasks WHERE id = ?', undef, $id); +} + +# disable($class, $dbh, $id) — set active=0 +sub disable { + my ($class, $dbh, $id) = @_; + $dbh->do( + "UPDATE tasks SET active=0, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?", + undef, $id + ); +} + +# complete($class, $dbh, $id) → completion_id or undef +# For floating tasks, also sets active=0 (archives the task). +sub complete { + my ($class, $dbh, $id) = @_; + my $task = get($class, $dbh, $id); + return undef unless $task; + + $dbh->do('INSERT INTO completions (task_id) VALUES (?)', undef, $id); + my $cid = $dbh->last_insert_id(undef, undef, 'completions', undef); + + disable($class, $dbh, $id) if $task->{class} eq 'floating'; + + return $cid; +} + +# undo_completion($class, $dbh, $completion_id) +# Removes one completion row. If it was a floating task's only completion +# (which archived it), re-activates the task. +sub undo_completion { + my ($class, $dbh, $cid) = @_; + + my ($task_id) = $dbh->selectrow_array( + 'SELECT task_id FROM completions WHERE id = ?', undef, $cid + ); + return unless $task_id; + + $dbh->do('DELETE FROM completions WHERE id = ?', undef, $cid); + + # Re-activate an archived floating task if it now has no completions + my $task = get($class, $dbh, $task_id); + if ($task && $task->{class} eq 'floating' && !$task->{active}) { + my ($remaining) = $dbh->selectrow_array( + 'SELECT COUNT(*) FROM completions WHERE task_id = ?', undef, $task_id + ); + if ($remaining == 0) { + $dbh->do( + "UPDATE tasks SET active=1, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?", + undef, $task_id + ); + } + } +} + +# last_completion($class, $dbh, $task_id) → hashref (id, task_id, completed_at) or undef +sub last_completion { + my ($class, $dbh, $id) = @_; + return $dbh->selectrow_hashref( + 'SELECT * FROM completions WHERE task_id = ? ORDER BY completed_at DESC LIMIT 1', + undef, $id + ); +} + +# search($class, $dbh, $query) → arrayref of active task hashrefs whose title +# contains $query (case-insensitive). +sub search { + my ($class, $dbh, $q) = @_; + return $dbh->selectall_arrayref( + "SELECT * FROM tasks WHERE active=1 AND LOWER(title) LIKE ? ORDER BY title", + { Slice => {} }, '%' . lc($q) . '%' + ); +} + +# ----------------------------------------------------------------------- +# Internal validation +# ----------------------------------------------------------------------- + +sub _validate_class_fields { + my ($tc, $f) = @_; + + if ($tc eq 'monthly_date') { + my $d = $f->{day_of_month}; + croak "day_of_month must be 1–31" unless $d =~ /^\d+$/ && $d >= 1 && $d <= 31; + + } elsif ($tc eq 'monthly_weekday') { + my ($wd, $ord) = ($f->{weekday}, $f->{ordinal}); + croak "weekday must be 1–7" unless $wd =~ /^\d+$/ && $wd >= 1 && $wd <= 7; + croak "ordinal must be 1–4 or -1" unless $ord =~ /^-?\d+$/ && ($ord == -1 || ($ord >= 1 && $ord <= 4)); + + } elsif ($tc eq 'every_n_period') { + croak "interval_n must be a positive integer" + unless $f->{interval_n} =~ /^\d+$/ && $f->{interval_n} > 0; + croak "period_unit must be day/week/month" + unless $VALID_UNIT{ $f->{period_unit} }; + croak "anchor_date must be YYYY-MM-DD" + unless $f->{anchor_date} =~ /^\d{4}-\d{2}-\d{2}$/; + + } elsif ($tc eq 'interval') { + croak "interval_days must be a positive integer" + unless $f->{interval_days} =~ /^\d+$/ && $f->{interval_days} > 0; + + } elsif ($tc eq 'floating') { + croak "priority must be high/medium/low" + unless $VALID_PRIORITY{ $f->{priority} }; + } +} 1; diff --git a/lib/Mnemosyne/Telegram.pm b/lib/Mnemosyne/Telegram.pm index 9cda042..52c0845 100644 --- a/lib/Mnemosyne/Telegram.pm +++ b/lib/Mnemosyne/Telegram.pm @@ -1,35 +1,223 @@ package Mnemosyne::Telegram; use strict; use warnings; +use Mojo::UserAgent; +use Mojo::JSON qw(encode_json); +use Carp qw(croak); -# Bot API client. All outbound calls use Mojo::UserAgent. -# Never use Telegram::Bot or other heavyweight frameworks. -# -# Constructor: -# TODO: new($token) — stores token; lazily creates Mojo::UserAgent -# -# Sending messages: -# TODO: send_message($chat_id, $text, %opts) -# — opts: parse_mode (default 'HTML'), reply_markup (inline keyboard hashref) -# — returns the sent message's message_id (needed for later edits) -# -# TODO: edit_message($chat_id, $message_id, $text, %opts) -# — wraps editMessageText; used to replace "pending" with "✓ Done — [Undo]" -# -# TODO: edit_reply_markup($chat_id, $message_id, $reply_markup) -# — wraps editMessageReplyMarkup; strips buttons after undo window expires -# -# TODO: answer_callback_query($callback_query_id, %opts) -# — must be called promptly to stop the client spinner; opts: text, show_alert -# -# Keyboard helpers: -# TODO: mark_done_keyboard($task_id) — returns inline_keyboard hashref with one -# "Mark Done" button; callback_data encodes action + task_id -# -# TODO: undo_keyboard($completion_id) — "Undo" button after a mark-done action -# -# Setup: -# TODO: set_webhook($url, $secret_token) — calls setWebhook; idempotent -# TODO: delete_webhook() — calls deleteWebhook +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 = $tx->result; + 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; diff --git a/lib/Mnemosyne/Webhook.pm b/lib/Mnemosyne/Webhook.pm index 17b1270..2933f76 100644 --- a/lib/Mnemosyne/Webhook.pm +++ b/lib/Mnemosyne/Webhook.pm @@ -1,37 +1,665 @@ package Mnemosyne::Webhook; use strict; use warnings; +use DateTime; +use Mnemosyne::Task; +use Mnemosyne::Digest; +use Mnemosyne::Schedule; +use Mnemosyne::Telegram; +use Carp qw(croak); -# Mojolicious controller / update router for inbound Telegram webhook POSTs. -# -# Security gates (must run in this order before any processing): -# 1. Validate X-Telegram-Bot-Api-Secret-Token header matches config. -# Reject with 403 (not 200) — this is not a Telegram client, it's a rogue POST. -# 2. Validate chat_id is in the allowed_chat_ids whitelist. -# Respond 200 (so Telegram stops retrying) but do not process the update. -# -# Update types handled: -# TODO: handle_message($update, $db, $config, $telegram) -# — routes slash commands: /today /glance /list /add /done /edit /disable /delete -# /settime /help; plus free-text during multi-step flows (e.g. /add wizard) -# -# TODO: handle_callback_query($update, $db, $config, $telegram) -# — handles Mark Done and Undo button taps; calls answerCallbackQuery immediately -# then does DB work + message edit; idempotent (tolerate Telegram redelivery) -# -# Command handlers (each returns a Telegram reply or edits the original message): -# TODO: cmd_today($chat_id, $db, $config, $telegram) -# TODO: cmd_list($chat_id, $args, $db, $config, $telegram) -# TODO: cmd_add($chat_id, $args, $db, $config, $telegram) — starts guided flow -# TODO: cmd_done($chat_id, $args, $db, $config, $telegram) -# TODO: cmd_edit($chat_id, $args, $db, $config, $telegram) -# TODO: cmd_disable($chat_id, $args, $db, $config, $telegram) -# TODO: cmd_delete($chat_id, $args, $db, $config, $telegram) — confirmation required -# TODO: cmd_settime($chat_id, $args, $db, $config, $telegram) -# TODO: cmd_help($chat_id, $telegram) -# -# Conversation state for multi-step flows (e.g. /add wizard): -# TODO: decide and document storage mechanism (in-memory hash keyed by chat_id, -# or a small 'sessions' table in SQLite for persistence across restarts) +# In-memory wizard sessions, keyed by chat_id. +# Lost on restart — documented trade-off; the wizard is short-lived. +# Structure: { step => '...', class => '...', data => {}, wizard_msg_id => $id } +my %SESSIONS; + +# ----------------------------------------------------------------------- +# Main entry point (called by mnemosyne-bot for every inbound update) +# ----------------------------------------------------------------------- + +# handle_update($class, $update, $db, $config, $telegram) → nothing +# Caller has already validated the secret-token header. +sub handle_update { + my ($class, $update, $db, $config, $telegram) = @_; + + my $chat_id = _chat_id($update) or return; + + # Gate 2: chat whitelist + unless (_allowed($chat_id, $config)) { + # Respond 200 to Telegram so it stops retrying, but do nothing. + return; + } + + if (my $msg = $update->{message}) { + _handle_message($msg, $chat_id, $db, $config, $telegram); + } elsif (my $cbq = $update->{callback_query}) { + _handle_callback($cbq, $chat_id, $db, $config, $telegram); + } +} + +# ----------------------------------------------------------------------- +# Message routing +# ----------------------------------------------------------------------- + +sub _handle_message { + my ($msg, $chat_id, $db, $config, $telegram) = @_; + my $text = $msg->{text} // ''; + + # Wizard in progress — text input feeds into the current step + if (exists $SESSIONS{$chat_id} && $SESSIONS{$chat_id}{step} ne 'done') { + _wizard_text($text, $chat_id, $msg, $db, $config, $telegram); + return; + } + + # Strip bot username suffix from commands (e.g. /help@MnemosyneBot) + $text =~ s{^(/\w+)@\w+}{$1}; + + my ($cmd, $args) = $text =~ /^(\/\w+)\s*(.*)/s ? ($1, $2) : ('', $text); + + if ($cmd eq '/today' || $cmd eq '/glance') { _cmd_today ($chat_id, $db, $config, $telegram) } + elsif ($cmd eq '/list') { _cmd_list ($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/add') { _cmd_add ($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/done') { _cmd_done ($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/edit') { _cmd_edit ($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/disable') { _cmd_disable($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/delete') { _cmd_delete ($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/settime') { _cmd_settime($chat_id, $args, $db, $config, $telegram) } + elsif ($cmd eq '/cancel') { _cmd_cancel ($chat_id, $telegram) } + elsif ($cmd eq '/help') { _cmd_help ($chat_id, $telegram) } + elsif ($cmd) { $telegram->send_message($chat_id, "Unknown command. /help for the list.") } +} + +# ----------------------------------------------------------------------- +# Callback routing +# ----------------------------------------------------------------------- + +sub _handle_callback { + my ($cbq, $chat_id, $db, $config, $telegram) = @_; + my $data = $cbq->{data} // ''; + my $cbq_id = $cbq->{id}; + my $msg = $cbq->{message} // {}; + my $msg_id = $msg->{message_id}; + + if ($data =~ /^done:(\d+)$/) { + _cb_mark_done($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram); + + } elsif ($data =~ /^undo:(\d+)$/) { + _cb_undo($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram); + + } elsif ($data =~ /^confirm_delete:(\d+)$/) { + _cb_confirm_delete($1, $cbq_id, $chat_id, $msg_id, $db, $telegram); + + } elsif ($data eq 'cancel') { + $telegram->answer_callback_query($cbq_id); + $telegram->edit_reply_markup($chat_id, $msg_id, undef); + + } elsif ($data =~ /^add_/) { + _wizard_callback($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram); + + } else { + $telegram->answer_callback_query($cbq_id, text => 'Unknown action'); + } +} + +# ----------------------------------------------------------------------- +# Command handlers +# ----------------------------------------------------------------------- + +sub _cmd_today { + my ($chat_id, $db, $config, $telegram) = @_; + my $today = _today($db); + my $digest = Mnemosyne::Digest->build($db, $today); + my $parts = Mnemosyne::Digest->render_telegram($digest); + + for my $part (@$parts) { + my $kb = @{ $part->{tasks} } + ? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks}) + : undef; + $telegram->send_message($chat_id, $part->{text}, + reply_markup => $kb); + } +} + +sub _cmd_list { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + + my %filters; + if ($args =~ /^(monthly_date|monthly_weekday|every_n_period|interval|floating)$/i) { + $filters{class} = lc $args; + } elsif ($args =~ /^(high|medium|low)$/i) { + $filters{priority} = lc $args; + } elsif ($args =~ /^(inactive|all)$/i) { + $filters{active} = 0; + } + + my $dbh = $db->dbh; + my $tasks = Mnemosyne::Task->list($dbh, %filters); + + unless (@$tasks) { + $telegram->send_message($chat_id, "No tasks found."); + return; + } + + my $today = _today($db); + my $text = "Tasks" . ($args ? " ($args)" : '') . "\n\n"; + my @actionable; + + for my $task (@$tasks) { + my $r = Mnemosyne::Schedule->resolve($task, $today); + my $st = $r->{status}; + my $icon = $st eq 'overdue' ? "\x{1F534}" + : $st eq 'due' ? "\x{1F4C5}" + : $st eq 'upcoming' ? "\x{1F440}" + : "\x{2022}"; + $text .= "$icon " . _esc($task->{title}) . ""; + $text .= " (id $task->{id})\n"; + push @actionable, { id => $task->{id}, title => $task->{title} } + if $task->{active} && $st ne 'not_relevant' && $st ne 'inactive'; + } + + my $kb = @actionable ? Mnemosyne::Telegram->mark_done_keyboard(\@actionable) : undef; + $telegram->send_message($chat_id, $text, reply_markup => $kb); +} + +sub _cmd_add { + my ($chat_id, $args, $db, $config, $telegram) = @_; + # Clear any stale session + delete $SESSIONS{$chat_id}; + + $SESSIONS{$chat_id} = { step => 'await_title', data => {} }; + + my $res = $telegram->send_message($chat_id, + "New task — what's the title?", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); + $SESSIONS{$chat_id}{wizard_msg_id} = $res->{result}{message_id}; +} + +sub _cmd_done { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + my $dbh = $db->dbh; + + my $task; + if ($args =~ /^\d+$/) { + $task = Mnemosyne::Task->get($dbh, $args); + } else { + my $results = Mnemosyne::Task->search($dbh, $args); + if (@$results == 1) { + $task = $results->[0]; + } elsif (@$results > 1) { + my $list = join("\n", map { "• " . _esc($_->{title}) . " (id $_->{id})" } @$results); + $telegram->send_message($chat_id, + "Multiple matches — use /done :\n\n$list"); + return; + } + } + + unless ($task) { + $telegram->send_message($chat_id, "Task not found: $args"); + return; + } + + my $cid = Mnemosyne::Task->complete($dbh, $task->{id}); + $telegram->send_message($chat_id, + "\x{2714} Done: " . _esc($task->{title}) . "", + reply_markup => Mnemosyne::Telegram->undo_keyboard($cid)); +} + +sub _cmd_edit { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + + unless ($args =~ /^(\d+)\s+(\w+)\s+(.+)$/s) { + $telegram->send_message($chat_id, + "Usage: /edit \n" + . "Fields: title, notes, day_of_month, weekday, ordinal, " + . "interval_n, period_unit, anchor_date, interval_days, priority"); + return; + } + my ($id, $field, $value) = ($1, $2, $3); + $value =~ s/^\s+|\s+$//g; + + my $dbh = $db->dbh; + my $updated = eval { Mnemosyne::Task->update($dbh, $id, { $field => $value }) }; + if ($@) { + $telegram->send_message($chat_id, "Error: " . _esc($@)); + return; + } + unless ($updated) { + $telegram->send_message($chat_id, "Task $id not found."); + return; + } + $telegram->send_message($chat_id, + "\x{2714} Updated " . _esc($updated->{title}) . ""); +} + +sub _cmd_disable { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + my $dbh = $db->dbh; + my $task = _resolve_task($dbh, $args); + unless ($task) { + $telegram->send_message($chat_id, "Task not found: $args"); + return; + } + Mnemosyne::Task->disable($dbh, $task->{id}); + $telegram->send_message($chat_id, + "\x{1F6AB} Disabled: " . _esc($task->{title}) . ""); +} + +sub _cmd_delete { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + my $dbh = $db->dbh; + my $task = _resolve_task($dbh, $args); + unless ($task) { + $telegram->send_message($chat_id, "Task not found: $args"); + return; + } + $telegram->send_message($chat_id, + "Delete " . _esc($task->{title}) . "?\nThis cannot be undone.", + reply_markup => Mnemosyne::Telegram->confirm_delete_keyboard($task->{id})); +} + +sub _cmd_settime { + my ($chat_id, $args, $db, $config, $telegram) = @_; + $args =~ s/^\s+|\s+$//g; + unless ($args =~ /^([01]\d|2[0-3]):([0-5]\d)$/) { + $telegram->send_message($chat_id, "Usage: /settime HH:MM (24-hour)"); + return; + } + $db->config_set('digest_time', $args); + $telegram->send_message($chat_id, "\x{23F0} Digest time set to $args."); +} + +sub _cmd_cancel { + my ($chat_id, $telegram) = @_; + if (delete $SESSIONS{$chat_id}) { + $telegram->send_message($chat_id, "Cancelled."); + } else { + $telegram->send_message($chat_id, "Nothing in progress."); + } +} + +sub _cmd_help { + my ($chat_id, $telegram) = @_; + $telegram->send_message($chat_id, <<'END'); +Mnemosyne commands + +/today — Day at a Glance digest +/list [class|priority|inactive] — browse tasks +/add — create a new task (guided) +/done <id|title> — mark a task complete +/edit <id> <field> <value> — update a field +/disable <id|title> — hide without deleting +/delete <id|title> — permanently delete +/settime HH:MM — change digest delivery time +/cancel — cancel in-progress wizard +/help — this message +END +} + +# ----------------------------------------------------------------------- +# Callback handlers +# ----------------------------------------------------------------------- + +sub _cb_mark_done { + my ($task_id, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram) = @_; + + my $task = Mnemosyne::Task->get($db->dbh, $task_id); + unless ($task) { + $telegram->answer_callback_query($cbq_id, text => 'Task not found'); + return; + } + + my $cid = Mnemosyne::Task->complete($db->dbh, $task_id); + $telegram->answer_callback_query($cbq_id, text => "\x{2714} Done"); + + # Replace just the tapped button with an Undo button + my $new_kb = _swap_done_button($msg->{reply_markup}, $task_id, $cid); + $telegram->edit_reply_markup($chat_id, $msg_id, $new_kb); +} + +sub _cb_undo { + my ($cid, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram) = @_; + + Mnemosyne::Task->undo_completion($db->dbh, $cid); + $telegram->answer_callback_query($cbq_id, text => "\x{21A9} Undone"); + + # Restore the original Mark Done button for the task + my ($task_id) = $db->dbh->selectrow_array( + 'SELECT task_id FROM completions WHERE id = ?', undef, $cid + ); + # completion is gone at this point; we reconstruct from the button label + my $new_kb = _swap_undo_button($msg->{reply_markup}, $cid, $task_id); + $telegram->edit_reply_markup($chat_id, $msg_id, $new_kb); +} + +sub _cb_confirm_delete { + my ($task_id, $cbq_id, $chat_id, $msg_id, $db, $telegram) = @_; + + my $task = Mnemosyne::Task->get($db->dbh, $task_id); + Mnemosyne::Task->delete($db->dbh, $task_id); + $telegram->answer_callback_query($cbq_id, text => 'Deleted'); + + my $title = $task ? _esc($task->{title}) : "task $task_id"; + $telegram->edit_message($chat_id, $msg_id, + "\x{1F5D1} Deleted: $title", + reply_markup => undef); +} + +# ----------------------------------------------------------------------- +# /add wizard — text input steps +# ----------------------------------------------------------------------- + +sub _wizard_text { + my ($text, $chat_id, $msg, $db, $config, $telegram) = @_; + my $sess = $SESSIONS{$chat_id} or return; + my $step = $sess->{step}; + my $wmid = $sess->{wizard_msg_id}; + + if ($step eq 'await_title') { + $text =~ s/^\s+|\s+$//g; + unless (length $text) { + $telegram->send_message($chat_id, "Title can't be empty. Try again."); + return; + } + $sess->{data}{title} = $text; + $sess->{step} = 'await_class'; + $telegram->edit_message($chat_id, $wmid, + "Task: " . _esc($text) . "\nChoose a class:", + reply_markup => Mnemosyne::Telegram->class_keyboard()); + + } elsif ($step eq 'await_dom') { + # day_of_month text input + $text =~ s/^\s+|\s+$//g; + unless ($text =~ /^\d+$/ && $text >= 1 && $text <= 31) { + $telegram->send_message($chat_id, "Enter a number 1–31."); + return; + } + $sess->{data}{day_of_month} = $text + 0; + _wizard_ask_notes($chat_id, $wmid, $sess, $telegram); + + } elsif ($step eq 'await_interval_n') { + $text =~ s/^\s+|\s+$//g; + unless ($text =~ /^\d+$/ && $text > 0) { + $telegram->send_message($chat_id, "Enter a positive integer."); + return; + } + $sess->{data}{interval_n} = $text + 0; + $sess->{step} = 'await_unit'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nChoose a period unit:", + reply_markup => Mnemosyne::Telegram->period_unit_keyboard()); + + } elsif ($step eq 'await_anchor') { + $text =~ s/^\s+|\s+$//g; + unless ($text =~ /^\d{4}-\d{2}-\d{2}$/) { + $telegram->send_message($chat_id, "Use YYYY-MM-DD format."); + return; + } + $sess->{data}{anchor_date} = $text; + _wizard_ask_notes($chat_id, $wmid, $sess, $telegram); + + } elsif ($step eq 'await_interval_days') { + $text =~ s/^\s+|\s+$//g; + unless ($text =~ /^\d+$/ && $text > 0) { + $telegram->send_message($chat_id, "Enter a positive integer (days)."); + return; + } + $sess->{data}{interval_days} = $text + 0; + _wizard_ask_notes($chat_id, $wmid, $sess, $telegram); + + } elsif ($step eq 'await_notes') { + $text =~ s/^\s+|\s+$//g; + $sess->{data}{notes} = $text if length $text; + _wizard_confirm($chat_id, $wmid, $sess, $telegram); + + } else { + $telegram->send_message($chat_id, "Unexpected input. /cancel to abort."); + } +} + +# ----------------------------------------------------------------------- +# /add wizard — callback steps +# ----------------------------------------------------------------------- + +sub _wizard_callback { + my ($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram) = @_; + my $sess = $SESSIONS{$chat_id}; + + # If no session but wizard callback arrives, ignore gracefully + unless ($sess) { + $telegram->answer_callback_query($cbq_id, text => 'Session expired. Use /add to start again.'); + return; + } + + $telegram->answer_callback_query($cbq_id); + my $wmid = $sess->{wizard_msg_id} // $msg_id; + + if ($data =~ /^add_class:(.+)$/) { + my $tc = $1; + $sess->{class} = $tc; + $sess->{data}{class} = $tc; + _wizard_next_after_class($tc, $chat_id, $wmid, $sess, $telegram); + + } elsif ($data =~ /^add_weekday:(\d)$/) { + $sess->{data}{weekday} = $1 + 0; + $sess->{step} = 'await_ordinal'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nWhich occurrence of the month?", + reply_markup => Mnemosyne::Telegram->ordinal_keyboard()); + + } elsif ($data =~ /^add_ordinal:(-?\d+)$/) { + $sess->{data}{ordinal} = $1 + 0; + _wizard_ask_notes($chat_id, $wmid, $sess, $telegram); + + } elsif ($data =~ /^add_unit:(\w+)$/) { + $sess->{data}{period_unit} = $1; + $sess->{step} = 'await_anchor'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nEnter an anchor date (YYYY-MM-DD):", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); + + } elsif ($data =~ /^add_priority:(\w+)$/) { + $sess->{data}{priority} = $1; + _wizard_ask_notes($chat_id, $wmid, $sess, $telegram); + + } elsif ($data eq 'add_skip') { + _wizard_handle_skip($chat_id, $wmid, $sess, $telegram); + + } elsif ($data eq 'add_save') { + _wizard_save($chat_id, $wmid, $sess, $db, $telegram); + + } elsif ($data eq 'add_cancel') { + delete $SESSIONS{$chat_id}; + $telegram->edit_message($chat_id, $wmid, "Cancelled.", reply_markup => undef); + } +} + +sub _wizard_next_after_class { + my ($tc, $chat_id, $wmid, $sess, $telegram) = @_; + + if ($tc eq 'monthly_date') { + $sess->{step} = 'await_dom'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nWhich day of the month? (1–31):", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); + + } elsif ($tc eq 'monthly_weekday') { + $sess->{step} = 'await_weekday'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nWhich weekday?", + reply_markup => Mnemosyne::Telegram->weekday_keyboard()); + + } elsif ($tc eq 'every_n_period') { + $sess->{step} = 'await_interval_n'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nEvery how many periods? (e.g. 2):", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); + + } elsif ($tc eq 'interval') { + $sess->{step} = 'await_interval_days'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nRepeat every how many days?", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); + + } elsif ($tc eq 'floating') { + $sess->{step} = 'await_priority'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nChoose a priority:", + reply_markup => Mnemosyne::Telegram->priority_keyboard()); + } +} + +sub _wizard_handle_skip { + my ($chat_id, $wmid, $sess, $telegram) = @_; + my $step = $sess->{step}; + + if ($step eq 'await_title') { + $telegram->send_message($chat_id, "Title is required — can't skip."); + } elsif ($step eq 'await_notes') { + _wizard_confirm($chat_id, $wmid, $sess, $telegram); + } else { + $telegram->send_message($chat_id, "Can't skip this step."); + } +} + +sub _wizard_ask_notes { + my ($chat_id, $wmid, $sess, $telegram) = @_; + $sess->{step} = 'await_notes'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\nAny notes? (optional)", + reply_markup => Mnemosyne::Telegram->skip_keyboard()); +} + +sub _wizard_confirm { + my ($chat_id, $wmid, $sess, $telegram) = @_; + $sess->{step} = 'await_confirm'; + $telegram->edit_message($chat_id, $wmid, + _wizard_summary($sess) . "\n\nSave this task?", + reply_markup => Mnemosyne::Telegram->save_cancel_keyboard()); +} + +sub _wizard_save { + my ($chat_id, $wmid, $sess, $db, $telegram) = @_; + my $data = $sess->{data}; + my $task = eval { Mnemosyne::Task->create($db->dbh, $data) }; + if ($@) { + (my $err = $@) =~ s/ at .*//s; + $telegram->edit_message($chat_id, $wmid, + "\x{274C} Error: " . _esc($err) . "\n\n/add to try again.", + reply_markup => undef); + delete $SESSIONS{$chat_id}; + return; + } + delete $SESSIONS{$chat_id}; + $telegram->edit_message($chat_id, $wmid, + "\x{2714} Created: " . _esc($task->{title}) . " (id $task->{id})", + reply_markup => undef); +} + +# Summary line shown at the top of each wizard edit +sub _wizard_summary { + my ($sess) = @_; + my $d = $sess->{data}; + my $s = "New task\n"; + $s .= "Title: " . _esc($d->{title}) . "\n" if $d->{title}; + $s .= "Class: $d->{class}\n" if $d->{class}; + return $s; +} + +# ----------------------------------------------------------------------- +# Keyboard reconstruction helpers +# ----------------------------------------------------------------------- + +# Replace a done: button with undo: in the existing keyboard +sub _swap_done_button { + my ($markup, $task_id, $cid) = @_; + return undef unless $markup && $markup->{inline_keyboard}; + my @new_rows; + for my $row (@{ $markup->{inline_keyboard} }) { + my @new_row; + for my $btn (@$row) { + if (($btn->{callback_data} // '') eq "done:$task_id") { + push @new_row, { + text => "\x{2714} Done", + callback_data => "undo:$cid", + }; + } else { + push @new_row, $btn; + } + } + push @new_rows, \@new_row; + } + return { inline_keyboard => \@new_rows }; +} + +# Replace an undo: button with done: +sub _swap_undo_button { + my ($markup, $cid, $task_id) = @_; + return undef unless $markup && $markup->{inline_keyboard}; + + # If we don't know the task_id (completion already deleted), remove the button + my @new_rows; + for my $row (@{ $markup->{inline_keyboard} }) { + my @new_row; + for my $btn (@$row) { + if (($btn->{callback_data} // '') eq "undo:$cid") { + next unless defined $task_id; + push @new_row, { + text => "\x{2713} Mark done", + callback_data => "done:$task_id", + }; + } else { + push @new_row, $btn; + } + } + push @new_rows, \@new_row if @new_row; + } + return { inline_keyboard => \@new_rows }; +} + +# ----------------------------------------------------------------------- +# Utilities +# ----------------------------------------------------------------------- + +sub _chat_id { + my ($update) = @_; + return $update->{message}{chat}{id} + // $update->{callback_query}{message}{chat}{id} + // undef; +} + +sub _allowed { + my ($chat_id, $config) = @_; + my %allowed = map { $_ => 1 } $config->allowed_chat_ids; + return $allowed{$chat_id}; +} + +sub _today { + my ($db) = @_; + my $tz = $db->config_get('timezone') // 'UTC'; + my $now = eval { DateTime->now(time_zone => $tz) } // DateTime->now; + return $now->truncate(to => 'day'); +} + +sub _resolve_task { + my ($dbh, $arg) = @_; + if ($arg =~ /^\d+$/) { + return Mnemosyne::Task->get($dbh, $arg); + } + my $results = Mnemosyne::Task->search($dbh, $arg); + return @$results == 1 ? $results->[0] : undef; +} + +sub _esc { + my ($s) = @_; + $s =~ s/&/&/g; + $s =~ s//>/g; + return $s; +} 1;