package Mnemosyne::Webhook; use strict; use warnings; use utf8; use DateTime; use Mnemosyne::Task; use Mnemosyne::Digest; use Mnemosyne::Schedule; use Mnemosyne::Telegram; use Carp qw(croak); # 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; } my $upd_type = exists $update->{message} ? 'message' : exists $update->{callback_query} ? 'callback_query' : 'unknown'; warn "DBG update type=$upd_type chat=$chat_id\n"; 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} // ''; # Strip bot username suffix from commands (e.g. /help@MnemosyneBot) $text =~ s{^(/\w+)@\w+}{$1}; # Wizard in progress — /cancel always escapes; other commands are blocked if (exists $SESSIONS{$chat_id} && $SESSIONS{$chat_id}{step} ne 'done') { warn "DBG handle_message_in_session: step=$SESSIONS{$chat_id}{step} text=" . substr($text, 0, 60) . "\n"; if ($text =~ /^\/cancel/i) { _cmd_cancel($chat_id, $telegram); } elsif ($text =~ /^\//) { $telegram->send_message($chat_id, "A task wizard is in progress. Use /cancel to abort it first."); } else { _wizard_text($text, $chat_id, $msg, $db, $config, $telegram); } return; } 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}; warn "DBG handle_callback: data=$data\n"; 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 { # Button-only steps (await_class, await_weekday, etc.) land here if # the user types instead of tapping. Prompt them back to the buttons. $telegram->send_message($chat_id, "Please use the buttons above to continue, or /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}; warn "DBG wizard_callback: data=$data sess=" . ($sess ? $sess->{step} : 'NONE') . "\n"; # 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;