691 lines
24 KiB
Perl
691 lines
24 KiB
Perl
package Mnemosyne::Webhook;
|
||
use strict;
|
||
use warnings;
|
||
use utf8;
|
||
use DateTime;
|
||
use Mojo::JSON qw(encode_json);
|
||
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;
|
||
}
|
||
|
||
warn "DBG full_update: " . encode_json($update) . "\n";
|
||
|
||
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} // '';
|
||
|
||
# Non-text messages (photos, stickers, voice, accidental empty sends, etc.)
|
||
return unless length $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 = "<b>Tasks" . ($args ? " ($args)" : '') . "</b>\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 <b>" . _esc($task->{title}) . "</b>";
|
||
$text .= " <i>(id $task->{id})</i>\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 <b>title</b>?",
|
||
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 { "• <b>" . _esc($_->{title}) . "</b> (id $_->{id})" } @$results);
|
||
$telegram->send_message($chat_id,
|
||
"Multiple matches — use /done <id>:\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: <b>" . _esc($task->{title}) . "</b>",
|
||
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 <id> <field> <value>\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 <b>" . _esc($updated->{title}) . "</b>");
|
||
}
|
||
|
||
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: <b>" . _esc($task->{title}) . "</b>");
|
||
}
|
||
|
||
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 <b>" . _esc($task->{title}) . "</b>?\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 <b>$args</b>.");
|
||
}
|
||
|
||
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');
|
||
<b>Mnemosyne commands</b>
|
||
|
||
/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: <b>$title</b>",
|
||
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: <b>" . _esc($text) . "</b>\nChoose a <b>class</b>:",
|
||
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 <b>period unit</b>:",
|
||
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 <b>occurrence</b> 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 <b>anchor date</b> (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 <b>day of the month</b>? (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 <b>weekday</b>?",
|
||
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. <code>2</code>):",
|
||
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 <b>days</b>?",
|
||
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 <b>priority</b>:",
|
||
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 <b>notes</b>? (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: <b>" . _esc($task->{title}) . "</b> (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 = "<b>New task</b>\n";
|
||
$s .= "Title: <b>" . _esc($d->{title}) . "</b>\n" if $d->{title};
|
||
$s .= "Class: <code>$d->{class}</code>\n" if $d->{class};
|
||
return $s;
|
||
}
|
||
|
||
# -----------------------------------------------------------------------
|
||
# Keyboard reconstruction helpers
|
||
# -----------------------------------------------------------------------
|
||
|
||
# Replace a done:<task_id> button with undo:<cid> 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:<cid> button with done:<task_id>
|
||
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;
|
||
$s =~ s/>/>/g;
|
||
return $s;
|
||
}
|
||
|
||
1;
|