mnemosyne/lib/Mnemosyne/Webhook.pm

691 lines
24 KiB
Perl
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package Mnemosyne::Webhook;
use strict;
use warnings;
use utf8;
use DateTime;
use Mojo::JSON qw(encode_json);
use Mnemosyne::Task;
use Mnemosyne::Digest;
use Mnemosyne::Schedule;
use Mnemosyne::Telegram;
use Carp qw(croak);
# In-memory wizard sessions, keyed by chat_id.
# Lost on restart — documented trade-off; the wizard is short-lived.
# Structure: { step => '...', class => '...', data => {}, wizard_msg_id => $id }
my %SESSIONS;
# -----------------------------------------------------------------------
# Main entry point (called by mnemosyne-bot for every inbound update)
# -----------------------------------------------------------------------
# handle_update($class, $update, $db, $config, $telegram) → nothing
# Caller has already validated the secret-token header.
sub handle_update {
my ($class, $update, $db, $config, $telegram) = @_;
my $chat_id = _chat_id($update) or return;
# Gate 2: chat whitelist
unless (_allowed($chat_id, $config)) {
# Respond 200 to Telegram so it stops retrying, but do nothing.
return;
}
warn "DBG full_update: " . encode_json($update) . "\n";
my $upd_type = exists $update->{message} ? 'message'
: exists $update->{callback_query} ? 'callback_query'
: 'unknown';
warn "DBG update type=$upd_type chat=$chat_id\n";
if (my $msg = $update->{message}) {
_handle_message($msg, $chat_id, $db, $config, $telegram);
} elsif (my $cbq = $update->{callback_query}) {
_handle_callback($cbq, $chat_id, $db, $config, $telegram);
}
}
# -----------------------------------------------------------------------
# Message routing
# -----------------------------------------------------------------------
sub _handle_message {
my ($msg, $chat_id, $db, $config, $telegram) = @_;
my $text = $msg->{text} // '';
# Non-text messages (photos, stickers, voice, accidental empty sends, etc.)
return unless length $text;
# Strip bot username suffix from commands (e.g. /help@MnemosyneBot)
$text =~ s{^(/\w+)@\w+}{$1};
# Wizard in progress — /cancel always escapes; other commands are blocked
if (exists $SESSIONS{$chat_id} && $SESSIONS{$chat_id}{step} ne 'done') {
warn "DBG handle_message_in_session: step=$SESSIONS{$chat_id}{step} text=" . substr($text, 0, 60) . "\n";
if ($text =~ /^\/cancel/i) {
_cmd_cancel($chat_id, $telegram);
} elsif ($text =~ /^\//) {
$telegram->send_message($chat_id,
"A task wizard is in progress. Use /cancel to abort it first.");
} else {
_wizard_text($text, $chat_id, $msg, $db, $config, $telegram);
}
return;
}
my ($cmd, $args) = $text =~ /^(\/\w+)\s*(.*)/s ? ($1, $2) : ('', $text);
if ($cmd eq '/today' || $cmd eq '/glance') { _cmd_today ($chat_id, $db, $config, $telegram) }
elsif ($cmd eq '/list') { _cmd_list ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/add') { _cmd_add ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/done') { _cmd_done ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/edit') { _cmd_edit ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/disable') { _cmd_disable($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/delete') { _cmd_delete ($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/settime') { _cmd_settime($chat_id, $args, $db, $config, $telegram) }
elsif ($cmd eq '/cancel') { _cmd_cancel ($chat_id, $telegram) }
elsif ($cmd eq '/help') { _cmd_help ($chat_id, $telegram) }
elsif ($cmd) { $telegram->send_message($chat_id, "Unknown command. /help for the list.") }
}
# -----------------------------------------------------------------------
# Callback routing
# -----------------------------------------------------------------------
sub _handle_callback {
my ($cbq, $chat_id, $db, $config, $telegram) = @_;
my $data = $cbq->{data} // '';
my $cbq_id = $cbq->{id};
warn "DBG handle_callback: data=$data\n";
my $msg = $cbq->{message} // {};
my $msg_id = $msg->{message_id};
if ($data =~ /^done:(\d+)$/) {
_cb_mark_done($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
} elsif ($data =~ /^undo:(\d+)$/) {
_cb_undo($1, $cbq_id, $chat_id, $msg_id, $msg, $db, $telegram);
} elsif ($data =~ /^confirm_delete:(\d+)$/) {
_cb_confirm_delete($1, $cbq_id, $chat_id, $msg_id, $db, $telegram);
} elsif ($data eq 'cancel') {
$telegram->answer_callback_query($cbq_id);
$telegram->edit_reply_markup($chat_id, $msg_id, undef);
} elsif ($data =~ /^add_/) {
_wizard_callback($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram);
} else {
$telegram->answer_callback_query($cbq_id, text => 'Unknown action');
}
}
# -----------------------------------------------------------------------
# Command handlers
# -----------------------------------------------------------------------
sub _cmd_today {
my ($chat_id, $db, $config, $telegram) = @_;
my $today = _today($db);
my $digest = Mnemosyne::Digest->build($db, $today);
my $parts = Mnemosyne::Digest->render_telegram($digest);
for my $part (@$parts) {
my $kb = @{ $part->{tasks} }
? Mnemosyne::Telegram->mark_done_keyboard($part->{tasks})
: undef;
$telegram->send_message($chat_id, $part->{text},
reply_markup => $kb);
}
}
sub _cmd_list {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my %filters;
if ($args =~ /^(monthly_date|monthly_weekday|every_n_period|interval|floating)$/i) {
$filters{class} = lc $args;
} elsif ($args =~ /^(high|medium|low)$/i) {
$filters{priority} = lc $args;
} elsif ($args =~ /^(inactive|all)$/i) {
$filters{active} = 0;
}
my $dbh = $db->dbh;
my $tasks = Mnemosyne::Task->list($dbh, %filters);
unless (@$tasks) {
$telegram->send_message($chat_id, "No tasks found.");
return;
}
my $today = _today($db);
my $text = "<b>Tasks" . ($args ? " ($args)" : '') . "</b>\n\n";
my @actionable;
for my $task (@$tasks) {
my $r = Mnemosyne::Schedule->resolve($task, $today);
my $st = $r->{status};
my $icon = $st eq 'overdue' ? "\x{1F534}"
: $st eq 'due' ? "\x{1F4C5}"
: $st eq 'upcoming' ? "\x{1F440}"
: "\x{2022}";
$text .= "$icon <b>" . _esc($task->{title}) . "</b>";
$text .= " <i>(id $task->{id})</i>\n";
push @actionable, { id => $task->{id}, title => $task->{title} }
if $task->{active} && $st ne 'not_relevant' && $st ne 'inactive';
}
my $kb = @actionable ? Mnemosyne::Telegram->mark_done_keyboard(\@actionable) : undef;
$telegram->send_message($chat_id, $text, reply_markup => $kb);
}
sub _cmd_add {
my ($chat_id, $args, $db, $config, $telegram) = @_;
# Clear any stale session
delete $SESSIONS{$chat_id};
$SESSIONS{$chat_id} = { step => 'await_title', data => {} };
my $res = $telegram->send_message($chat_id,
"New task — what's the <b>title</b>?",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
$SESSIONS{$chat_id}{wizard_msg_id} = $res->{result}{message_id};
}
sub _cmd_done {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task;
if ($args =~ /^\d+$/) {
$task = Mnemosyne::Task->get($dbh, $args);
} else {
my $results = Mnemosyne::Task->search($dbh, $args);
if (@$results == 1) {
$task = $results->[0];
} elsif (@$results > 1) {
my $list = join("\n", map { "• <b>" . _esc($_->{title}) . "</b> (id $_->{id})" } @$results);
$telegram->send_message($chat_id,
"Multiple matches — use /done <id>:\n\n$list");
return;
}
}
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
my $cid = Mnemosyne::Task->complete($dbh, $task->{id});
$telegram->send_message($chat_id,
"\x{2714} Done: <b>" . _esc($task->{title}) . "</b>",
reply_markup => Mnemosyne::Telegram->undo_keyboard($cid));
}
sub _cmd_edit {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
unless ($args =~ /^(\d+)\s+(\w+)\s+(.+)$/s) {
$telegram->send_message($chat_id,
"Usage: /edit <id> <field> <value>\n"
. "Fields: title, notes, day_of_month, weekday, ordinal, "
. "interval_n, period_unit, anchor_date, interval_days, priority");
return;
}
my ($id, $field, $value) = ($1, $2, $3);
$value =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $updated = eval { Mnemosyne::Task->update($dbh, $id, { $field => $value }) };
if ($@) {
$telegram->send_message($chat_id, "Error: " . _esc($@));
return;
}
unless ($updated) {
$telegram->send_message($chat_id, "Task $id not found.");
return;
}
$telegram->send_message($chat_id,
"\x{2714} Updated <b>" . _esc($updated->{title}) . "</b>");
}
sub _cmd_disable {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task = _resolve_task($dbh, $args);
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
Mnemosyne::Task->disable($dbh, $task->{id});
$telegram->send_message($chat_id,
"\x{1F6AB} Disabled: <b>" . _esc($task->{title}) . "</b>");
}
sub _cmd_delete {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
my $dbh = $db->dbh;
my $task = _resolve_task($dbh, $args);
unless ($task) {
$telegram->send_message($chat_id, "Task not found: $args");
return;
}
$telegram->send_message($chat_id,
"Delete <b>" . _esc($task->{title}) . "</b>?\nThis cannot be undone.",
reply_markup => Mnemosyne::Telegram->confirm_delete_keyboard($task->{id}));
}
sub _cmd_settime {
my ($chat_id, $args, $db, $config, $telegram) = @_;
$args =~ s/^\s+|\s+$//g;
unless ($args =~ /^([01]\d|2[0-3]):([0-5]\d)$/) {
$telegram->send_message($chat_id, "Usage: /settime HH:MM (24-hour)");
return;
}
$db->config_set('digest_time', $args);
$telegram->send_message($chat_id, "\x{23F0} Digest time set to <b>$args</b>.");
}
sub _cmd_cancel {
my ($chat_id, $telegram) = @_;
if (delete $SESSIONS{$chat_id}) {
$telegram->send_message($chat_id, "Cancelled.");
} else {
$telegram->send_message($chat_id, "Nothing in progress.");
}
}
sub _cmd_help {
my ($chat_id, $telegram) = @_;
$telegram->send_message($chat_id, <<'END');
<b>Mnemosyne commands</b>
/today Day at a Glance digest
/list [class|priority|inactive] browse tasks
/add create a new task (guided)
/done &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 {
# Button-only steps (await_class, await_weekday, etc.) land here if
# the user types instead of tapping. Prompt them back to the buttons.
$telegram->send_message($chat_id,
"Please use the buttons above to continue, or /cancel to abort.");
}
}
# -----------------------------------------------------------------------
# /add wizard — callback steps
# -----------------------------------------------------------------------
sub _wizard_callback {
my ($data, $cbq_id, $chat_id, $msg_id, $db, $config, $telegram) = @_;
my $sess = $SESSIONS{$chat_id};
warn "DBG wizard_callback: data=$data sess=" . ($sess ? $sess->{step} : 'NONE') . "\n";
# If no session but wizard callback arrives, ignore gracefully
unless ($sess) {
$telegram->answer_callback_query($cbq_id, text => 'Session expired. Use /add to start again.');
return;
}
$telegram->answer_callback_query($cbq_id);
my $wmid = $sess->{wizard_msg_id} // $msg_id;
if ($data =~ /^add_class:(.+)$/) {
my $tc = $1;
$sess->{class} = $tc;
$sess->{data}{class} = $tc;
_wizard_next_after_class($tc, $chat_id, $wmid, $sess, $telegram);
} elsif ($data =~ /^add_weekday:(\d)$/) {
$sess->{data}{weekday} = $1 + 0;
$sess->{step} = 'await_ordinal';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nWhich <b>occurrence</b> of the month?",
reply_markup => Mnemosyne::Telegram->ordinal_keyboard());
} elsif ($data =~ /^add_ordinal:(-?\d+)$/) {
$sess->{data}{ordinal} = $1 + 0;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($data =~ /^add_unit:(\w+)$/) {
$sess->{data}{period_unit} = $1;
$sess->{step} = 'await_anchor';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nEnter an <b>anchor date</b> (YYYY-MM-DD):",
reply_markup => Mnemosyne::Telegram->skip_keyboard());
} elsif ($data =~ /^add_priority:(\w+)$/) {
$sess->{data}{priority} = $1;
_wizard_ask_notes($chat_id, $wmid, $sess, $telegram);
} elsif ($data eq 'add_skip') {
_wizard_handle_skip($chat_id, $wmid, $sess, $telegram);
} elsif ($data eq 'add_save') {
_wizard_save($chat_id, $wmid, $sess, $db, $telegram);
} elsif ($data eq 'add_cancel') {
delete $SESSIONS{$chat_id};
$telegram->edit_message($chat_id, $wmid, "Cancelled.", reply_markup => undef);
}
}
sub _wizard_next_after_class {
my ($tc, $chat_id, $wmid, $sess, $telegram) = @_;
if ($tc eq 'monthly_date') {
$sess->{step} = 'await_dom';
$telegram->edit_message($chat_id, $wmid,
_wizard_summary($sess) . "\nWhich <b>day of the month</b>? (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;