Finished Webhooks and Telegram bindings
This commit is contained in:
parent
c2d300d2f8
commit
74ae914886
@ -4,10 +4,75 @@ use warnings;
|
||||
use FindBin qw($RealBin);
|
||||
use lib "$RealBin/../lib";
|
||||
|
||||
# TODO: Parse --config flag (default: $RealBin/../config/mnemosyne.conf)
|
||||
# TODO: Load Mnemosyne::Config, Mnemosyne::DB
|
||||
# TODO: Build Mojolicious::Lite app:
|
||||
# POST /<webhook_path> => validate secret token, route to Mnemosyne::Webhook
|
||||
# TODO: Start app on 127.0.0.1:<listen_port> (never bind 0.0.0.0)
|
||||
# Webhook receiver — Mojolicious::Lite app.
|
||||
# Binds to 127.0.0.1 only; nginx terminates TLS and proxies inbound updates.
|
||||
#
|
||||
# Startup:
|
||||
# mnemosyne-bot --config /path/to/mnemosyne.conf
|
||||
# mnemosyne-bot --config /path/to/mnemosyne.conf daemon (same, explicit)
|
||||
#
|
||||
# In production (systemd), the unit sets:
|
||||
# ExecStart=/usr/bin/perl /opt/mnemosyne/bin/mnemosyne-bot --config /etc/mnemosyne/mnemosyne.conf
|
||||
|
||||
print "mnemosyne-bot: not yet implemented\n";
|
||||
use Mnemosyne::Config;
|
||||
use Mnemosyne::DB;
|
||||
use Mnemosyne::Telegram;
|
||||
use Mnemosyne::Webhook;
|
||||
use Mojolicious::Lite;
|
||||
|
||||
# ---- parse --config before Mojolicious sees ARGV ----------------------
|
||||
my $config_path = "$RealBin/../config/mnemosyne.conf";
|
||||
my @passthrough;
|
||||
while (@ARGV) {
|
||||
my $arg = shift @ARGV;
|
||||
if ($arg eq '--config' && @ARGV) {
|
||||
$config_path = shift @ARGV;
|
||||
} else {
|
||||
push @passthrough, $arg;
|
||||
}
|
||||
}
|
||||
@ARGV = @passthrough;
|
||||
|
||||
# ---- load config & bootstrap ------------------------------------------
|
||||
die "Config file not found: $config_path\n" unless -f $config_path;
|
||||
my $config = Mnemosyne::Config->new($config_path);
|
||||
my $db = Mnemosyne::DB->new($config->get('db', 'path'));
|
||||
my $token = $config->get('bot', 'token') or die "bot.token missing in config\n";
|
||||
my $secret = $config->get('bot', 'webhook_secret') or die "bot.webhook_secret missing in config\n";
|
||||
my $port = $config->get('bot', 'listen_port') // 8443;
|
||||
my $wh_url = $config->get('bot', 'webhook_url') or die "bot.webhook_url missing in config\n";
|
||||
|
||||
my $telegram = Mnemosyne::Telegram->new($token);
|
||||
|
||||
# Extract the path component from the webhook URL
|
||||
# e.g. https://example.com/tghook/abc123 → /tghook/abc123
|
||||
(my $wh_path = $wh_url) =~ s{^https?://[^/]+}{};
|
||||
$wh_path ||= '/webhook';
|
||||
|
||||
# ---- webhook endpoint -------------------------------------------------
|
||||
post $wh_path => sub {
|
||||
my ($c) = @_;
|
||||
|
||||
# Gate 1: validate Telegram's secret-token header
|
||||
my $incoming_secret = $c->req->headers->header('X-Telegram-Bot-Api-Secret-Token') // '';
|
||||
unless ($incoming_secret eq $secret) {
|
||||
$c->render(text => 'Forbidden', status => 403);
|
||||
return;
|
||||
}
|
||||
|
||||
# Respond 200 immediately — Telegram retries on non-2xx or timeouts
|
||||
$c->render(text => 'ok', status => 200);
|
||||
|
||||
# Process the update (synchronous; fast enough for single-user load)
|
||||
my $update = $c->req->json;
|
||||
eval {
|
||||
Mnemosyne::Webhook->handle_update($update, $db, $config, $telegram);
|
||||
};
|
||||
warn "Webhook processing error: $@\n" if $@;
|
||||
};
|
||||
|
||||
# ---- start ------------------------------------------------------------
|
||||
# Default to daemon on localhost; let explicit ARGV override for hypnotoad etc.
|
||||
push @ARGV, ('daemon', '-l', "http://127.0.0.1:$port") unless @ARGV;
|
||||
|
||||
app->start;
|
||||
|
||||
@ -4,16 +4,72 @@ use warnings;
|
||||
use FindBin qw($RealBin);
|
||||
use lib "$RealBin/../lib";
|
||||
|
||||
# Invoked by cron every N minutes (e.g. */5 * * * *).
|
||||
# Exits immediately unless:
|
||||
# a) the current local time matches digest_time (within the cron granularity window), AND
|
||||
# b) last_digest_sent (config table) is not today's date.
|
||||
# On success, records today's date in last_digest_sent to prevent duplicate sends.
|
||||
# Invoked by cron every 5 minutes (recommended: */5 * * * *).
|
||||
# Checks whether it's time to send the morning digest; exits silently otherwise.
|
||||
# Guards against duplicate sends with last_digest_sent in the config table.
|
||||
#
|
||||
# TODO: Load config + DB
|
||||
# TODO: Read digest_time and last_digest_sent from config table
|
||||
# TODO: Compare current local time against digest_time (allow ±(cron_interval/2) window)
|
||||
# TODO: If it's time and not already sent: call Mnemosyne::Digest::send(...)
|
||||
# TODO: On success: update last_digest_sent to today's ISO date
|
||||
# Usage:
|
||||
# mnemosyne-digest [--config /path/to/mnemosyne.conf]
|
||||
|
||||
print "mnemosyne-digest: not yet implemented\n";
|
||||
use Mnemosyne::Config;
|
||||
use Mnemosyne::DB;
|
||||
use Mnemosyne::Telegram;
|
||||
use Mnemosyne::Digest;
|
||||
use DateTime;
|
||||
use DateTime::TimeZone;
|
||||
|
||||
use constant WINDOW_MINUTES => 5; # half-window each side of digest_time
|
||||
|
||||
my $config_path = "$RealBin/../config/mnemosyne.conf";
|
||||
while (@ARGV) {
|
||||
my $arg = shift @ARGV;
|
||||
if ($arg eq '--config' && @ARGV) { $config_path = shift @ARGV }
|
||||
else { die "Unknown argument: $arg\n" }
|
||||
}
|
||||
|
||||
die "Config file not found: $config_path\n" unless -f $config_path;
|
||||
my $config = Mnemosyne::Config->new($config_path);
|
||||
my $db = Mnemosyne::DB->new($config->get('db', 'path'));
|
||||
|
||||
# ---- current local time -----------------------------------------------
|
||||
my $tz_name = $db->config_get('timezone') // 'UTC';
|
||||
my $now = eval { DateTime->now(time_zone => $tz_name) }
|
||||
// do { warn "Unknown timezone '$tz_name', falling back to UTC\n";
|
||||
DateTime->now(time_zone => 'UTC') };
|
||||
|
||||
# ---- configured digest time -------------------------------------------
|
||||
my $digest_time = $db->config_get('digest_time') // '06:30';
|
||||
my ($dh, $dm) = split /:/, $digest_time;
|
||||
$dh //= 6; $dm //= 30;
|
||||
|
||||
# ---- check window: |now - digest_time| ≤ WINDOW_MINUTES ---------------
|
||||
my $target = $now->clone->truncate(to => 'day')
|
||||
->add(hours => $dh + 0, minutes => $dm + 0);
|
||||
my $diff = abs(($now->epoch - $target->epoch) / 60); # minutes
|
||||
|
||||
unless ($diff <= WINDOW_MINUTES) {
|
||||
exit 0; # not time yet (or already past the window)
|
||||
}
|
||||
|
||||
# ---- guard: already sent today? ---------------------------------------
|
||||
my $today_str = $now->strftime('%Y-%m-%d');
|
||||
my $last_sent = $db->config_get('last_digest_sent') // '';
|
||||
if ($last_sent eq $today_str) {
|
||||
exit 0; # already sent
|
||||
}
|
||||
|
||||
# ---- send! ------------------------------------------------------------
|
||||
my $token = $config->get('bot', 'token') or die "bot.token missing\n";
|
||||
my $chat_id = ($config->allowed_chat_ids)[0] or die "No allowed_chat_ids in config\n";
|
||||
my $telegram = Mnemosyne::Telegram->new($token);
|
||||
my $today_dt = $now->clone->truncate(to => 'day');
|
||||
|
||||
eval {
|
||||
Mnemosyne::Digest->send($db, $telegram, $chat_id, $today_dt);
|
||||
};
|
||||
if ($@) {
|
||||
warn "Digest send failed: $@\n";
|
||||
exit 1;
|
||||
}
|
||||
|
||||
exit 0;
|
||||
|
||||
@ -4,16 +4,48 @@ use warnings;
|
||||
use FindBin qw($RealBin);
|
||||
use lib "$RealBin/../lib";
|
||||
|
||||
# One-shot helper to register (or remove) the Telegram webhook.
|
||||
# One-shot helper to register or remove the Telegram webhook.
|
||||
# Run once after install, and again whenever the webhook URL changes.
|
||||
# Only one webhook can be active per bot token at a time.
|
||||
#
|
||||
# Usage:
|
||||
# mnemosyne-webhook [--config /path/to/mnemosyne.conf] [--delete]
|
||||
#
|
||||
# TODO: Load config
|
||||
# TODO: --delete: call Mnemosyne::Telegram::delete_webhook()
|
||||
# TODO: default: call Mnemosyne::Telegram::set_webhook(webhook_url, webhook_secret)
|
||||
# TODO: Print result (registered URL, or confirmation of deletion)
|
||||
# mnemosyne-webhook [--config /path] [--delete]
|
||||
|
||||
print "mnemosyne-webhook: not yet implemented\n";
|
||||
use Mnemosyne::Config;
|
||||
use Mnemosyne::Telegram;
|
||||
|
||||
my $config_path = "$RealBin/../config/mnemosyne.conf";
|
||||
my $do_delete = 0;
|
||||
|
||||
while (@ARGV) {
|
||||
my $arg = shift @ARGV;
|
||||
if ($arg eq '--config' && @ARGV) { $config_path = shift @ARGV }
|
||||
elsif ($arg eq '--delete') { $do_delete = 1 }
|
||||
else { die "Unknown argument: $arg\n" }
|
||||
}
|
||||
|
||||
die "Config file not found: $config_path\n" unless -f $config_path;
|
||||
my $config = Mnemosyne::Config->new($config_path);
|
||||
my $token = $config->get('bot', 'token') or die "bot.token missing\n";
|
||||
my $secret = $config->get('bot', 'webhook_secret') or die "bot.webhook_secret missing\n";
|
||||
my $wh_url = $config->get('bot', 'webhook_url') or die "bot.webhook_url missing\n";
|
||||
|
||||
my $tg = Mnemosyne::Telegram->new($token);
|
||||
|
||||
if ($do_delete) {
|
||||
my $res = $tg->delete_webhook;
|
||||
if ($res->{ok}) {
|
||||
print "Webhook deleted.\n";
|
||||
} else {
|
||||
print "Error: $res->{description}\n";
|
||||
exit 1;
|
||||
}
|
||||
} else {
|
||||
my $res = $tg->set_webhook($wh_url, $secret);
|
||||
if ($res->{ok}) {
|
||||
print "Webhook registered: $wh_url\n";
|
||||
} else {
|
||||
print "Error: $res->{description}\n";
|
||||
exit 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ use strict;
|
||||
use warnings;
|
||||
use DateTime;
|
||||
use Mnemosyne::Schedule;
|
||||
use Mnemosyne::Telegram;
|
||||
use Carp qw(croak);
|
||||
|
||||
use constant TELEGRAM_MAX_LEN => 4096;
|
||||
@ -174,12 +175,12 @@ sub render_telegram {
|
||||
my ($class, $digest) = @_;
|
||||
|
||||
my @parts;
|
||||
my ($cur_text, @cur_ids) = ('');
|
||||
my ($cur_text, @cur_tasks) = ('');
|
||||
|
||||
my $flush = sub {
|
||||
return unless length $cur_text;
|
||||
push @parts, { text => $cur_text, task_ids => [@cur_ids] };
|
||||
($cur_text, @cur_ids) = ('');
|
||||
push @parts, { text => $cur_text, tasks => [@cur_tasks] };
|
||||
($cur_text, @cur_tasks) = ('');
|
||||
};
|
||||
|
||||
if ($digest->{all_clear}) {
|
||||
@ -190,12 +191,12 @@ sub render_telegram {
|
||||
|
||||
for my $sec (@{ $digest->{sections} }) {
|
||||
my $block = "<b>$sec->{header}</b>\n\n";
|
||||
my @block_ids;
|
||||
my @block_tasks;
|
||||
|
||||
for my $task (@{ $sec->{tasks} }) {
|
||||
my ($line, $is_actionable) = _render_task_html($task, $sec->{key});
|
||||
my $line = _render_task_html($task, $sec->{key});
|
||||
$block .= $line;
|
||||
push @block_ids, $task->{id} if $is_actionable;
|
||||
push @block_tasks, { id => $task->{id}, title => $task->{title} };
|
||||
}
|
||||
$block .= "\n";
|
||||
|
||||
@ -204,7 +205,7 @@ sub render_telegram {
|
||||
$flush->();
|
||||
}
|
||||
$cur_text .= $block;
|
||||
push @cur_ids, @block_ids;
|
||||
push @cur_tasks, @block_tasks;
|
||||
}
|
||||
|
||||
$flush->();
|
||||
@ -230,8 +231,7 @@ sub _render_task_html {
|
||||
$detail = $stars{ $task->{priority} // '' } // '';
|
||||
}
|
||||
|
||||
my $line = "\x{2022} $title" . ($detail ? " $detail" : '') . "\n";
|
||||
return ($line, 1); # 1 = actionable (all digest tasks get a Mark Done button)
|
||||
return "\x{2022} $title" . ($detail ? " $detail" : '') . "\n";
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
@ -248,12 +248,14 @@ sub send {
|
||||
my $parts = $class->render_telegram($digest);
|
||||
|
||||
for my $part (@$parts) {
|
||||
my $kb = @{ $part->{tasks} }
|
||||
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
|
||||
: undef;
|
||||
$telegram->send_message(
|
||||
$chat_id,
|
||||
$part->{text},
|
||||
parse_mode => 'HTML',
|
||||
# reply_markup built by Telegram.pm once that module exists:
|
||||
# reply_markup => Mnemosyne::Telegram->mark_done_keyboard($part->{task_ids}),
|
||||
defined $kb ? (reply_markup => $kb) : (),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,220 @@
|
||||
package Mnemosyne::Task;
|
||||
use strict;
|
||||
use warnings;
|
||||
use Carp qw(croak);
|
||||
|
||||
# CRUD operations for the tasks table, plus class-specific field validation.
|
||||
#
|
||||
# TODO: create($dbh, \%fields) — insert a new task, validate class + required fields
|
||||
# TODO: get($dbh, $id) — fetch one task by id (returns hashref or undef)
|
||||
# TODO: list($dbh, %filters) — list tasks; filters: class, active, priority
|
||||
# TODO: update($dbh, $id, \%fields) — partial update; sets updated_at
|
||||
# TODO: delete($dbh, $id) — hard delete (completions cascade)
|
||||
# TODO: disable($dbh, $id) — set active=0
|
||||
# TODO: complete($dbh, $id) — insert into completions; for floating, also sets active=0
|
||||
# TODO: undo_completion($dbh, $completion_id) — delete a completion row (misclick undo)
|
||||
# TODO: last_completion($dbh, $id) — returns the most recent completions row for a task
|
||||
# Class-level CRUD for the tasks table.
|
||||
# All methods take $dbh (a DBI handle) as their first non-class argument so
|
||||
# they can be called from any context without needing a full DB object.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Validation tables
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
my %REQUIRED_FIELDS = (
|
||||
monthly_date => [qw(day_of_month)],
|
||||
monthly_weekday => [qw(weekday ordinal)],
|
||||
every_n_period => [qw(interval_n period_unit anchor_date)],
|
||||
interval => [qw(interval_days)],
|
||||
floating => [qw(priority)],
|
||||
);
|
||||
|
||||
my %VALID_PRIORITY = map { $_ => 1 } qw(high medium low);
|
||||
my %VALID_UNIT = map { $_ => 1 } qw(day week month);
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public interface
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# create($class, $dbh, \%fields) → task hashref or dies
|
||||
sub create {
|
||||
my ($class, $dbh, $f) = @_;
|
||||
my $tc = $f->{class} or croak "class is required";
|
||||
my $title = $f->{title} // '';
|
||||
$title =~ s/^\s+|\s+$//g;
|
||||
croak "title is required" unless length $title;
|
||||
croak "Unknown task class: $tc" unless exists $REQUIRED_FIELDS{$tc};
|
||||
|
||||
for my $field (@{ $REQUIRED_FIELDS{$tc} }) {
|
||||
croak "Field '$field' required for $tc"
|
||||
unless defined $f->{$field} && length $f->{$field};
|
||||
}
|
||||
|
||||
_validate_class_fields($tc, $f);
|
||||
|
||||
$dbh->do(q{
|
||||
INSERT INTO tasks
|
||||
(title, notes, class, active,
|
||||
day_of_month, weekday, ordinal,
|
||||
interval_n, period_unit, anchor_date,
|
||||
interval_days, priority)
|
||||
VALUES (?,?,?,?, ?,?,?, ?,?,?, ?,?)
|
||||
}, undef,
|
||||
$title,
|
||||
$f->{notes} // '',
|
||||
$tc,
|
||||
exists $f->{active} ? ($f->{active} ? 1 : 0) : 1,
|
||||
$f->{day_of_month}, $f->{weekday}, $f->{ordinal},
|
||||
$f->{interval_n}, $f->{period_unit}, $f->{anchor_date},
|
||||
$f->{interval_days}, $f->{priority},
|
||||
);
|
||||
return get($class, $dbh, $dbh->last_insert_id(undef, undef, 'tasks', undef));
|
||||
}
|
||||
|
||||
# get($class, $dbh, $id) → hashref or undef
|
||||
sub get {
|
||||
my ($class, $dbh, $id) = @_;
|
||||
return $dbh->selectrow_hashref('SELECT * FROM tasks WHERE id = ?', undef, $id);
|
||||
}
|
||||
|
||||
# list($class, $dbh, %filters) → arrayref of hashrefs
|
||||
# Filters: active (default 1), class, priority
|
||||
sub list {
|
||||
my ($class, $dbh, %f) = @_;
|
||||
my (@where, @bind);
|
||||
|
||||
my $active = exists $f{active} ? ($f{active} ? 1 : 0) : 1;
|
||||
push @where, 'active = ?'; push @bind, $active;
|
||||
|
||||
if (defined $f{class}) { push @where, 'class = ?'; push @bind, $f{class} }
|
||||
if (defined $f{priority}) { push @where, 'priority = ?'; push @bind, $f{priority} }
|
||||
|
||||
my $sql = 'SELECT * FROM tasks WHERE ' . join(' AND ', @where) . ' ORDER BY title';
|
||||
return $dbh->selectall_arrayref($sql, { Slice => {} }, @bind);
|
||||
}
|
||||
|
||||
# update($class, $dbh, $id, \%fields) → updated task hashref or undef if not found
|
||||
# Only columns present in %fields are changed; updated_at is always refreshed.
|
||||
sub update {
|
||||
my ($class, $dbh, $id, $f) = @_;
|
||||
my @cols = grep { exists $f->{$_} } qw(
|
||||
title notes active
|
||||
day_of_month weekday ordinal
|
||||
interval_n period_unit anchor_date
|
||||
interval_days priority
|
||||
);
|
||||
return unless @cols;
|
||||
|
||||
if (exists $f->{title}) {
|
||||
$f->{title} =~ s/^\s+|\s+$//g;
|
||||
croak "title cannot be empty" unless length $f->{title};
|
||||
}
|
||||
|
||||
my $set = join(', ', map { "$_ = ?" } @cols)
|
||||
. ", updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')";
|
||||
$dbh->do("UPDATE tasks SET $set WHERE id = ?", undef, @{$f}{@cols}, $id);
|
||||
return get($class, $dbh, $id);
|
||||
}
|
||||
|
||||
# delete($class, $dbh, $id) — hard delete; completions cascade
|
||||
sub delete { ## no critic (Subroutines::ProhibitBuiltinHomonyms)
|
||||
my ($class, $dbh, $id) = @_;
|
||||
$dbh->do('DELETE FROM tasks WHERE id = ?', undef, $id);
|
||||
}
|
||||
|
||||
# disable($class, $dbh, $id) — set active=0
|
||||
sub disable {
|
||||
my ($class, $dbh, $id) = @_;
|
||||
$dbh->do(
|
||||
"UPDATE tasks SET active=0, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?",
|
||||
undef, $id
|
||||
);
|
||||
}
|
||||
|
||||
# complete($class, $dbh, $id) → completion_id or undef
|
||||
# For floating tasks, also sets active=0 (archives the task).
|
||||
sub complete {
|
||||
my ($class, $dbh, $id) = @_;
|
||||
my $task = get($class, $dbh, $id);
|
||||
return undef unless $task;
|
||||
|
||||
$dbh->do('INSERT INTO completions (task_id) VALUES (?)', undef, $id);
|
||||
my $cid = $dbh->last_insert_id(undef, undef, 'completions', undef);
|
||||
|
||||
disable($class, $dbh, $id) if $task->{class} eq 'floating';
|
||||
|
||||
return $cid;
|
||||
}
|
||||
|
||||
# undo_completion($class, $dbh, $completion_id)
|
||||
# Removes one completion row. If it was a floating task's only completion
|
||||
# (which archived it), re-activates the task.
|
||||
sub undo_completion {
|
||||
my ($class, $dbh, $cid) = @_;
|
||||
|
||||
my ($task_id) = $dbh->selectrow_array(
|
||||
'SELECT task_id FROM completions WHERE id = ?', undef, $cid
|
||||
);
|
||||
return unless $task_id;
|
||||
|
||||
$dbh->do('DELETE FROM completions WHERE id = ?', undef, $cid);
|
||||
|
||||
# Re-activate an archived floating task if it now has no completions
|
||||
my $task = get($class, $dbh, $task_id);
|
||||
if ($task && $task->{class} eq 'floating' && !$task->{active}) {
|
||||
my ($remaining) = $dbh->selectrow_array(
|
||||
'SELECT COUNT(*) FROM completions WHERE task_id = ?', undef, $task_id
|
||||
);
|
||||
if ($remaining == 0) {
|
||||
$dbh->do(
|
||||
"UPDATE tasks SET active=1, updated_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?",
|
||||
undef, $task_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# last_completion($class, $dbh, $task_id) → hashref (id, task_id, completed_at) or undef
|
||||
sub last_completion {
|
||||
my ($class, $dbh, $id) = @_;
|
||||
return $dbh->selectrow_hashref(
|
||||
'SELECT * FROM completions WHERE task_id = ? ORDER BY completed_at DESC LIMIT 1',
|
||||
undef, $id
|
||||
);
|
||||
}
|
||||
|
||||
# search($class, $dbh, $query) → arrayref of active task hashrefs whose title
|
||||
# contains $query (case-insensitive).
|
||||
sub search {
|
||||
my ($class, $dbh, $q) = @_;
|
||||
return $dbh->selectall_arrayref(
|
||||
"SELECT * FROM tasks WHERE active=1 AND LOWER(title) LIKE ? ORDER BY title",
|
||||
{ Slice => {} }, '%' . lc($q) . '%'
|
||||
);
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Internal validation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
sub _validate_class_fields {
|
||||
my ($tc, $f) = @_;
|
||||
|
||||
if ($tc eq 'monthly_date') {
|
||||
my $d = $f->{day_of_month};
|
||||
croak "day_of_month must be 1–31" unless $d =~ /^\d+$/ && $d >= 1 && $d <= 31;
|
||||
|
||||
} elsif ($tc eq 'monthly_weekday') {
|
||||
my ($wd, $ord) = ($f->{weekday}, $f->{ordinal});
|
||||
croak "weekday must be 1–7" unless $wd =~ /^\d+$/ && $wd >= 1 && $wd <= 7;
|
||||
croak "ordinal must be 1–4 or -1" unless $ord =~ /^-?\d+$/ && ($ord == -1 || ($ord >= 1 && $ord <= 4));
|
||||
|
||||
} elsif ($tc eq 'every_n_period') {
|
||||
croak "interval_n must be a positive integer"
|
||||
unless $f->{interval_n} =~ /^\d+$/ && $f->{interval_n} > 0;
|
||||
croak "period_unit must be day/week/month"
|
||||
unless $VALID_UNIT{ $f->{period_unit} };
|
||||
croak "anchor_date must be YYYY-MM-DD"
|
||||
unless $f->{anchor_date} =~ /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
} elsif ($tc eq 'interval') {
|
||||
croak "interval_days must be a positive integer"
|
||||
unless $f->{interval_days} =~ /^\d+$/ && $f->{interval_days} > 0;
|
||||
|
||||
} elsif ($tc eq 'floating') {
|
||||
croak "priority must be high/medium/low"
|
||||
unless $VALID_PRIORITY{ $f->{priority} };
|
||||
}
|
||||
}
|
||||
|
||||
1;
|
||||
|
||||
@ -1,35 +1,223 @@
|
||||
package Mnemosyne::Telegram;
|
||||
use strict;
|
||||
use warnings;
|
||||
use Mojo::UserAgent;
|
||||
use Mojo::JSON qw(encode_json);
|
||||
use Carp qw(croak);
|
||||
|
||||
# Bot API client. All outbound calls use Mojo::UserAgent.
|
||||
# Never use Telegram::Bot or other heavyweight frameworks.
|
||||
#
|
||||
# Constructor:
|
||||
# TODO: new($token) — stores token; lazily creates Mojo::UserAgent
|
||||
#
|
||||
# Sending messages:
|
||||
# TODO: send_message($chat_id, $text, %opts)
|
||||
# — opts: parse_mode (default 'HTML'), reply_markup (inline keyboard hashref)
|
||||
# — returns the sent message's message_id (needed for later edits)
|
||||
#
|
||||
# TODO: edit_message($chat_id, $message_id, $text, %opts)
|
||||
# — wraps editMessageText; used to replace "pending" with "✓ Done — [Undo]"
|
||||
#
|
||||
# TODO: edit_reply_markup($chat_id, $message_id, $reply_markup)
|
||||
# — wraps editMessageReplyMarkup; strips buttons after undo window expires
|
||||
#
|
||||
# TODO: answer_callback_query($callback_query_id, %opts)
|
||||
# — must be called promptly to stop the client spinner; opts: text, show_alert
|
||||
#
|
||||
# Keyboard helpers:
|
||||
# TODO: mark_done_keyboard($task_id) — returns inline_keyboard hashref with one
|
||||
# "Mark Done" button; callback_data encodes action + task_id
|
||||
#
|
||||
# TODO: undo_keyboard($completion_id) — "Undo" button after a mark-done action
|
||||
#
|
||||
# Setup:
|
||||
# TODO: set_webhook($url, $secret_token) — calls setWebhook; idempotent
|
||||
# TODO: delete_webhook() — calls deleteWebhook
|
||||
use constant API_BASE => 'https://api.telegram.org/bot';
|
||||
use constant TITLE_TRUNCATE => 24;
|
||||
|
||||
# new($class, $token) — create client
|
||||
sub new {
|
||||
my ($class, $token) = @_;
|
||||
croak "token required" unless $token;
|
||||
return bless {
|
||||
_token => $token,
|
||||
_ua => Mojo::UserAgent->new,
|
||||
}, $class;
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Core API calls
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# send_message($self, $chat_id, $text, %opts) → response hashref
|
||||
# opts: parse_mode, reply_markup, disable_notification
|
||||
sub send_message {
|
||||
my ($self, $chat_id, $text, %opts) = @_;
|
||||
return $self->_post('sendMessage', {
|
||||
chat_id => $chat_id,
|
||||
text => $text,
|
||||
parse_mode => $opts{parse_mode} // 'HTML',
|
||||
defined $opts{reply_markup} ? (reply_markup => $opts{reply_markup}) : (),
|
||||
defined $opts{disable_notification} ? (disable_notification => $opts{disable_notification}) : (),
|
||||
});
|
||||
}
|
||||
|
||||
# edit_message($self, $chat_id, $message_id, $text, %opts) → response hashref
|
||||
sub edit_message {
|
||||
my ($self, $chat_id, $message_id, $text, %opts) = @_;
|
||||
return $self->_post('editMessageText', {
|
||||
chat_id => $chat_id,
|
||||
message_id => $message_id,
|
||||
text => $text,
|
||||
parse_mode => $opts{parse_mode} // 'HTML',
|
||||
defined $opts{reply_markup} ? (reply_markup => $opts{reply_markup}) : (),
|
||||
});
|
||||
}
|
||||
|
||||
# edit_reply_markup($self, $chat_id, $message_id, $reply_markup) → response hashref
|
||||
# Pass undef to remove the keyboard entirely.
|
||||
sub edit_reply_markup {
|
||||
my ($self, $chat_id, $message_id, $reply_markup) = @_;
|
||||
return $self->_post('editMessageReplyMarkup', {
|
||||
chat_id => $chat_id,
|
||||
message_id => $message_id,
|
||||
reply_markup => $reply_markup // {},
|
||||
});
|
||||
}
|
||||
|
||||
# answer_callback_query($self, $callback_query_id, %opts)
|
||||
# opts: text (notification text), show_alert (bool)
|
||||
sub answer_callback_query {
|
||||
my ($self, $cbq_id, %opts) = @_;
|
||||
return $self->_post('answerCallbackQuery', {
|
||||
callback_query_id => $cbq_id,
|
||||
defined $opts{text} ? (text => $opts{text}) : (),
|
||||
defined $opts{show_alert} ? (show_alert => $opts{show_alert} ? \1 : \0) : (),
|
||||
});
|
||||
}
|
||||
|
||||
# set_webhook($self, $url, $secret) → response hashref
|
||||
sub set_webhook {
|
||||
my ($self, $url, $secret) = @_;
|
||||
croak "url required" unless $url;
|
||||
croak "secret required" unless $secret;
|
||||
return $self->_post('setWebhook', {
|
||||
url => $url,
|
||||
secret_token => $secret,
|
||||
allowed_updates => ['message', 'callback_query'],
|
||||
});
|
||||
}
|
||||
|
||||
# delete_webhook($self) → response hashref
|
||||
sub delete_webhook {
|
||||
my ($self) = @_;
|
||||
return $self->_post('deleteWebhook', { drop_pending_updates => \1 });
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Keyboard factories — all return inline_keyboard hashrefs
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# mark_done_keyboard(\@tasks) → reply_markup hashref
|
||||
# Each $task: { id => ..., title => ... }
|
||||
# One button per task: "✓ <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,37 +1,665 @@
|
||||
package Mnemosyne::Webhook;
|
||||
use strict;
|
||||
use warnings;
|
||||
use DateTime;
|
||||
use Mnemosyne::Task;
|
||||
use Mnemosyne::Digest;
|
||||
use Mnemosyne::Schedule;
|
||||
use Mnemosyne::Telegram;
|
||||
use Carp qw(croak);
|
||||
|
||||
# Mojolicious controller / update router for inbound Telegram webhook POSTs.
|
||||
#
|
||||
# Security gates (must run in this order before any processing):
|
||||
# 1. Validate X-Telegram-Bot-Api-Secret-Token header matches config.
|
||||
# Reject with 403 (not 200) — this is not a Telegram client, it's a rogue POST.
|
||||
# 2. Validate chat_id is in the allowed_chat_ids whitelist.
|
||||
# Respond 200 (so Telegram stops retrying) but do not process the update.
|
||||
#
|
||||
# Update types handled:
|
||||
# TODO: handle_message($update, $db, $config, $telegram)
|
||||
# — routes slash commands: /today /glance /list /add /done /edit /disable /delete
|
||||
# /settime /help; plus free-text during multi-step flows (e.g. /add wizard)
|
||||
#
|
||||
# TODO: handle_callback_query($update, $db, $config, $telegram)
|
||||
# — handles Mark Done and Undo button taps; calls answerCallbackQuery immediately
|
||||
# then does DB work + message edit; idempotent (tolerate Telegram redelivery)
|
||||
#
|
||||
# Command handlers (each returns a Telegram reply or edits the original message):
|
||||
# TODO: cmd_today($chat_id, $db, $config, $telegram)
|
||||
# TODO: cmd_list($chat_id, $args, $db, $config, $telegram)
|
||||
# TODO: cmd_add($chat_id, $args, $db, $config, $telegram) — starts guided flow
|
||||
# TODO: cmd_done($chat_id, $args, $db, $config, $telegram)
|
||||
# TODO: cmd_edit($chat_id, $args, $db, $config, $telegram)
|
||||
# TODO: cmd_disable($chat_id, $args, $db, $config, $telegram)
|
||||
# TODO: cmd_delete($chat_id, $args, $db, $config, $telegram) — confirmation required
|
||||
# TODO: cmd_settime($chat_id, $args, $db, $config, $telegram)
|
||||
# TODO: cmd_help($chat_id, $telegram)
|
||||
#
|
||||
# Conversation state for multi-step flows (e.g. /add wizard):
|
||||
# TODO: decide and document storage mechanism (in-memory hash keyed by chat_id,
|
||||
# or a small 'sessions' table in SQLite for persistence across restarts)
|
||||
# In-memory wizard sessions, keyed by chat_id.
|
||||
# Lost on restart — documented trade-off; the wizard is short-lived.
|
||||
# Structure: { step => '...', class => '...', data => {}, wizard_msg_id => $id }
|
||||
my %SESSIONS;
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Main entry point (called by mnemosyne-bot for every inbound update)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# handle_update($class, $update, $db, $config, $telegram) → nothing
|
||||
# Caller has already validated the secret-token header.
|
||||
sub handle_update {
|
||||
my ($class, $update, $db, $config, $telegram) = @_;
|
||||
|
||||
my $chat_id = _chat_id($update) or return;
|
||||
|
||||
# Gate 2: chat whitelist
|
||||
unless (_allowed($chat_id, $config)) {
|
||||
# Respond 200 to Telegram so it stops retrying, but do nothing.
|
||||
return;
|
||||
}
|
||||
|
||||
if (my $msg = $update->{message}) {
|
||||
_handle_message($msg, $chat_id, $db, $config, $telegram);
|
||||
} elsif (my $cbq = $update->{callback_query}) {
|
||||
_handle_callback($cbq, $chat_id, $db, $config, $telegram);
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Message routing
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
sub _handle_message {
|
||||
my ($msg, $chat_id, $db, $config, $telegram) = @_;
|
||||
my $text = $msg->{text} // '';
|
||||
|
||||
# Wizard in progress — text input feeds into the current step
|
||||
if (exists $SESSIONS{$chat_id} && $SESSIONS{$chat_id}{step} ne 'done') {
|
||||
_wizard_text($text, $chat_id, $msg, $db, $config, $telegram);
|
||||
return;
|
||||
}
|
||||
|
||||
# Strip bot username suffix from commands (e.g. /help@MnemosyneBot)
|
||||
$text =~ s{^(/\w+)@\w+}{$1};
|
||||
|
||||
my ($cmd, $args) = $text =~ /^(\/\w+)\s*(.*)/s ? ($1, $2) : ('', $text);
|
||||
|
||||
if ($cmd eq '/today' || $cmd eq '/glance') { _cmd_today ($chat_id, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/list') { _cmd_list ($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/add') { _cmd_add ($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/done') { _cmd_done ($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/edit') { _cmd_edit ($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/disable') { _cmd_disable($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/delete') { _cmd_delete ($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/settime') { _cmd_settime($chat_id, $args, $db, $config, $telegram) }
|
||||
elsif ($cmd eq '/cancel') { _cmd_cancel ($chat_id, $telegram) }
|
||||
elsif ($cmd eq '/help') { _cmd_help ($chat_id, $telegram) }
|
||||
elsif ($cmd) { $telegram->send_message($chat_id, "Unknown command. /help for the list.") }
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Callback routing
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
sub _handle_callback {
|
||||
my ($cbq, $chat_id, $db, $config, $telegram) = @_;
|
||||
my $data = $cbq->{data} // '';
|
||||
my $cbq_id = $cbq->{id};
|
||||
my $msg = $cbq->{message} // {};
|
||||
my $msg_id = $msg->{message_id};
|
||||
|
||||
if ($data =~ /^done:(\d+)$/) {
|
||||
_cb_mark_done($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
|
||||
|
||||
} elsif ($data =~ /^undo:(\d+)$/) {
|
||||
_cb_undo($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
|
||||
|
||||
} elsif ($data =~ /^confirm_delete:(\d+)$/) {
|
||||
_cb_confirm_delete($1, $cbq_id, $chat_id, $msg_id, $db, $telegram);
|
||||
|
||||
} elsif ($data eq 'cancel') {
|
||||
$telegram->answer_callback_query($cbq_id);
|
||||
$telegram->edit_reply_markup($chat_id, $msg_id, undef);
|
||||
|
||||
} elsif ($data =~ /^add_/) {
|
||||
_wizard_callback($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram);
|
||||
|
||||
} else {
|
||||
$telegram->answer_callback_query($cbq_id, text => 'Unknown action');
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
sub _cmd_today {
|
||||
my ($chat_id, $db, $config, $telegram) = @_;
|
||||
my $today = _today($db);
|
||||
my $digest = Mnemosyne::Digest->build($db, $today);
|
||||
my $parts = Mnemosyne::Digest->render_telegram($digest);
|
||||
|
||||
for my $part (@$parts) {
|
||||
my $kb = @{ $part->{tasks} }
|
||||
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
|
||||
: undef;
|
||||
$telegram->send_message($chat_id, $part->{text},
|
||||
reply_markup => $kb);
|
||||
}
|
||||
}
|
||||
|
||||
sub _cmd_list {
|
||||
my ($chat_id, $args, $db, $config, $telegram) = @_;
|
||||
$args =~ s/^\s+|\s+$//g;
|
||||
|
||||
my %filters;
|
||||
if ($args =~ /^(monthly_date|monthly_weekday|every_n_period|interval|floating)$/i) {
|
||||
$filters{class} = lc $args;
|
||||
} elsif ($args =~ /^(high|medium|low)$/i) {
|
||||
$filters{priority} = lc $args;
|
||||
} elsif ($args =~ /^(inactive|all)$/i) {
|
||||
$filters{active} = 0;
|
||||
}
|
||||
|
||||
my $dbh = $db->dbh;
|
||||
my $tasks = Mnemosyne::Task->list($dbh, %filters);
|
||||
|
||||
unless (@$tasks) {
|
||||
$telegram->send_message($chat_id, "No tasks found.");
|
||||
return;
|
||||
}
|
||||
|
||||
my $today = _today($db);
|
||||
my $text = "<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 {
|
||||
$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>? (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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user