Finished Webhooks and Telegram bindings

This commit is contained in:
Rodger Castle 2026-06-04 15:44:16 -04:00
parent c2d300d2f8
commit 74ae914886
7 changed files with 1283 additions and 109 deletions

View File

@ -4,10 +4,75 @@ use warnings;
use FindBin qw($RealBin); use FindBin qw($RealBin);
use lib "$RealBin/../lib"; use lib "$RealBin/../lib";
# TODO: Parse --config flag (default: $RealBin/../config/mnemosyne.conf) # Webhook receiver — Mojolicious::Lite app.
# TODO: Load Mnemosyne::Config, Mnemosyne::DB # Binds to 127.0.0.1 only; nginx terminates TLS and proxies inbound updates.
# TODO: Build Mojolicious::Lite app: #
# POST /<webhook_path> => validate secret token, route to Mnemosyne::Webhook # Startup:
# TODO: Start app on 127.0.0.1:<listen_port> (never bind 0.0.0.0) # 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;

View File

@ -4,16 +4,72 @@ use warnings;
use FindBin qw($RealBin); use FindBin qw($RealBin);
use lib "$RealBin/../lib"; use lib "$RealBin/../lib";
# Invoked by cron every N minutes (e.g. */5 * * * *). # Invoked by cron every 5 minutes (recommended: */5 * * * *).
# Exits immediately unless: # Checks whether it's time to send the morning digest; exits silently otherwise.
# a) the current local time matches digest_time (within the cron granularity window), AND # Guards against duplicate sends with last_digest_sent in the config table.
# 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.
# #
# TODO: Load config + DB # Usage:
# TODO: Read digest_time and last_digest_sent from config table # mnemosyne-digest [--config /path/to/mnemosyne.conf]
# 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
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;

View File

@ -4,16 +4,48 @@ use warnings;
use FindBin qw($RealBin); use FindBin qw($RealBin);
use lib "$RealBin/../lib"; 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. # Run once after install, and again whenever the webhook URL changes.
# Only one webhook can be active per bot token at a time. # Only one webhook can be active per bot token at a time.
# #
# Usage: # Usage:
# mnemosyne-webhook [--config /path/to/mnemosyne.conf] [--delete] # mnemosyne-webhook [--config /path] [--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)
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;
}
}

View File

@ -3,6 +3,7 @@ use strict;
use warnings; use warnings;
use DateTime; use DateTime;
use Mnemosyne::Schedule; use Mnemosyne::Schedule;
use Mnemosyne::Telegram;
use Carp qw(croak); use Carp qw(croak);
use constant TELEGRAM_MAX_LEN => 4096; use constant TELEGRAM_MAX_LEN => 4096;
@ -174,12 +175,12 @@ sub render_telegram {
my ($class, $digest) = @_; my ($class, $digest) = @_;
my @parts; my @parts;
my ($cur_text, @cur_ids) = (''); my ($cur_text, @cur_tasks) = ('');
my $flush = sub { my $flush = sub {
return unless length $cur_text; return unless length $cur_text;
push @parts, { text => $cur_text, task_ids => [@cur_ids] }; push @parts, { text => $cur_text, tasks => [@cur_tasks] };
($cur_text, @cur_ids) = (''); ($cur_text, @cur_tasks) = ('');
}; };
if ($digest->{all_clear}) { if ($digest->{all_clear}) {
@ -190,12 +191,12 @@ sub render_telegram {
for my $sec (@{ $digest->{sections} }) { for my $sec (@{ $digest->{sections} }) {
my $block = "<b>$sec->{header}</b>\n\n"; my $block = "<b>$sec->{header}</b>\n\n";
my @block_ids; my @block_tasks;
for my $task (@{ $sec->{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; $block .= $line;
push @block_ids, $task->{id} if $is_actionable; push @block_tasks, { id => $task->{id}, title => $task->{title} };
} }
$block .= "\n"; $block .= "\n";
@ -204,7 +205,7 @@ sub render_telegram {
$flush->(); $flush->();
} }
$cur_text .= $block; $cur_text .= $block;
push @cur_ids, @block_ids; push @cur_tasks, @block_tasks;
} }
$flush->(); $flush->();
@ -213,8 +214,8 @@ sub render_telegram {
sub _render_task_html { sub _render_task_html {
my ($task, $section_key) = @_; my ($task, $section_key) = @_;
my $title = _html_esc($task->{title}); my $title = _html_esc($task->{title});
my $detail = ''; my $detail = '';
if ($section_key eq 'overdue') { if ($section_key eq 'overdue') {
my $days = abs($task->{days_until} // 0); my $days = abs($task->{days_until} // 0);
@ -230,8 +231,7 @@ sub _render_task_html {
$detail = $stars{ $task->{priority} // '' } // ''; $detail = $stars{ $task->{priority} // '' } // '';
} }
my $line = "\x{2022} $title" . ($detail ? " $detail" : '') . "\n"; return "\x{2022} $title" . ($detail ? " $detail" : '') . "\n";
return ($line, 1); # 1 = actionable (all digest tasks get a Mark Done button)
} }
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
@ -248,12 +248,14 @@ sub send {
my $parts = $class->render_telegram($digest); my $parts = $class->render_telegram($digest);
for my $part (@$parts) { for my $part (@$parts) {
my $kb = @{ $part->{tasks} }
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
: undef;
$telegram->send_message( $telegram->send_message(
$chat_id, $chat_id,
$part->{text}, $part->{text},
parse_mode => 'HTML', parse_mode => 'HTML',
# reply_markup built by Telegram.pm once that module exists: defined $kb ? (reply_markup => $kb) : (),
# reply_markup => Mnemosyne::Telegram->mark_done_keyboard($part->{task_ids}),
); );
} }

View File

@ -1,17 +1,220 @@
package Mnemosyne::Task; package Mnemosyne::Task;
use strict; use strict;
use warnings; use warnings;
use Carp qw(croak);
# CRUD operations for the tasks table, plus class-specific field validation. # Class-level CRUD for the tasks table.
# # All methods take $dbh (a DBI handle) as their first non-class argument so
# TODO: create($dbh, \%fields) — insert a new task, validate class + required fields # they can be called from any context without needing a full DB object.
# 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 # Validation tables
# 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 my %REQUIRED_FIELDS = (
# TODO: undo_completion($dbh, $completion_id) — delete a completion row (misclick undo) monthly_date => [qw(day_of_month)],
# TODO: last_completion($dbh, $id) — returns the most recent completions row for a task 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 131" unless $d =~ /^\d+$/ && $d >= 1 && $d <= 31;
} elsif ($tc eq 'monthly_weekday') {
my ($wd, $ord) = ($f->{weekday}, $f->{ordinal});
croak "weekday must be 17" unless $wd =~ /^\d+$/ && $wd >= 1 && $wd <= 7;
croak "ordinal must be 14 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; 1;

View File

@ -1,35 +1,223 @@
package Mnemosyne::Telegram; package Mnemosyne::Telegram;
use strict; use strict;
use warnings; use warnings;
use Mojo::UserAgent;
use Mojo::JSON qw(encode_json);
use Carp qw(croak);
# Bot API client. All outbound calls use Mojo::UserAgent. use constant API_BASE => 'https://api.telegram.org/bot';
# Never use Telegram::Bot or other heavyweight frameworks. use constant TITLE_TRUNCATE => 24;
#
# Constructor: # new($class, $token) — create client
# TODO: new($token) — stores token; lazily creates Mojo::UserAgent sub new {
# my ($class, $token) = @_;
# Sending messages: croak "token required" unless $token;
# TODO: send_message($chat_id, $text, %opts) return bless {
# — opts: parse_mode (default 'HTML'), reply_markup (inline keyboard hashref) _token => $token,
# — returns the sent message's message_id (needed for later edits) _ua => Mojo::UserAgent->new,
# }, $class;
# 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) # Core API calls
# — wraps editMessageReplyMarkup; strips buttons after undo window expires # -----------------------------------------------------------------------
#
# TODO: answer_callback_query($callback_query_id, %opts) # send_message($self, $chat_id, $text, %opts) → response hashref
# — must be called promptly to stop the client spinner; opts: text, show_alert # opts: parse_mode, reply_markup, disable_notification
# sub send_message {
# Keyboard helpers: my ($self, $chat_id, $text, %opts) = @_;
# TODO: mark_done_keyboard($task_id) — returns inline_keyboard hashref with one return $self->_post('sendMessage', {
# "Mark Done" button; callback_data encodes action + task_id chat_id => $chat_id,
# text => $text,
# TODO: undo_keyboard($completion_id) — "Undo" button after a mark-done action parse_mode => $opts{parse_mode} // 'HTML',
# defined $opts{reply_markup} ? (reply_markup => $opts{reply_markup}) : (),
# Setup: defined $opts{disable_notification} ? (disable_notification => $opts{disable_notification}) : (),
# TODO: set_webhook($url, $secret_token) — calls setWebhook; idempotent });
# TODO: delete_webhook() — calls deleteWebhook }
# 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: "✓ <truncated title>", callback: done:<id>
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; 1;

View File

@ -1,37 +1,665 @@
package Mnemosyne::Webhook; package Mnemosyne::Webhook;
use strict; use strict;
use warnings; 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. # In-memory wizard sessions, keyed by chat_id.
# # Lost on restart — documented trade-off; the wizard is short-lived.
# Security gates (must run in this order before any processing): # Structure: { step => '...', class => '...', data => {}, wizard_msg_id => $id }
# 1. Validate X-Telegram-Bot-Api-Secret-Token header matches config. my %SESSIONS;
# 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. # Main entry point (called by mnemosyne-bot for every inbound update)
# # -----------------------------------------------------------------------
# Update types handled:
# TODO: handle_message($update, $db, $config, $telegram) # handle_update($class, $update, $db, $config, $telegram) → nothing
# — routes slash commands: /today /glance /list /add /done /edit /disable /delete # Caller has already validated the secret-token header.
# /settime /help; plus free-text during multi-step flows (e.g. /add wizard) sub handle_update {
# my ($class, $update, $db, $config, $telegram) = @_;
# TODO: handle_callback_query($update, $db, $config, $telegram)
# — handles Mark Done and Undo button taps; calls answerCallbackQuery immediately my $chat_id = _chat_id($update) or return;
# then does DB work + message edit; idempotent (tolerate Telegram redelivery)
# # Gate 2: chat whitelist
# Command handlers (each returns a Telegram reply or edits the original message): unless (_allowed($chat_id, $config)) {
# TODO: cmd_today($chat_id, $db, $config, $telegram) # Respond 200 to Telegram so it stops retrying, but do nothing.
# TODO: cmd_list($chat_id, $args, $db, $config, $telegram) return;
# 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) if (my $msg = $update->{message}) {
# TODO: cmd_disable($chat_id, $args, $db, $config, $telegram) _handle_message($msg, $chat_id, $db, $config, $telegram);
# TODO: cmd_delete($chat_id, $args, $db, $config, $telegram) — confirmation required } elsif (my $cbq = $update->{callback_query}) {
# TODO: cmd_settime($chat_id, $args, $db, $config, $telegram) _handle_callback($cbq, $chat_id, $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) # 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 = "<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 &lt;id|title&gt; mark a task complete
/edit &lt;id&gt; &lt;field&gt; &lt;value&gt; update a field
/disable &lt;id|title&gt; hide without deleting
/delete &lt;id|title&gt; 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 131.");
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 {
$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 <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>? (131):",
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/&/&amp;/g;
$s =~ s/</&lt;/g;
$s =~ s/>/&gt;/g;
return $s;
}
1; 1;