Initial scaffolding
This commit is contained in:
parent
15a0ff43de
commit
989415ec1f
13
bin/mnemosyne-bot
Executable file
13
bin/mnemosyne-bot
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
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)
|
||||||
|
|
||||||
|
print "mnemosyne-bot: not yet implemented\n";
|
||||||
19
bin/mnemosyne-digest
Executable file
19
bin/mnemosyne-digest
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
print "mnemosyne-digest: not yet implemented\n";
|
||||||
19
bin/mnemosyne-webhook
Executable file
19
bin/mnemosyne-webhook
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use FindBin qw($RealBin);
|
||||||
|
use lib "$RealBin/../lib";
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
print "mnemosyne-webhook: not yet implemented\n";
|
||||||
35
config/mnemosyne.conf.example
Normal file
35
config/mnemosyne.conf.example
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Mnemosyne configuration — copy to mnemosyne.conf and fill in real values.
|
||||||
|
# This file is NOT committed to git. Keep it outside the repo or in config/
|
||||||
|
# (which is .gitignored). Never commit secrets.
|
||||||
|
|
||||||
|
[bot]
|
||||||
|
# Telegram bot token from @BotFather
|
||||||
|
token = YOUR_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Secret token sent by Telegram on every webhook POST (X-Telegram-Bot-Api-Secret-Token).
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
webhook_secret = YOUR_WEBHOOK_SECRET_HERE
|
||||||
|
|
||||||
|
# Full public HTTPS URL Telegram will POST updates to.
|
||||||
|
# Include a hard-to-guess path segment as a second layer of obscurity.
|
||||||
|
webhook_url = https://mnemosyne.example.com/hook/YOUR_RANDOM_PATH_HERE
|
||||||
|
|
||||||
|
# Local port the Mojolicious app listens on (nginx proxies to this).
|
||||||
|
listen_port = 8765
|
||||||
|
|
||||||
|
# Comma-separated list of Telegram chat IDs allowed to use the bot.
|
||||||
|
# Get your chat ID by messaging @userinfobot.
|
||||||
|
allowed_chat_ids = 123456789
|
||||||
|
|
||||||
|
[db]
|
||||||
|
# Absolute path to the SQLite database file.
|
||||||
|
path = /var/lib/mnemosyne/mnemosyne.db
|
||||||
|
|
||||||
|
[app]
|
||||||
|
# IANA timezone name used for all day-granular date calculations.
|
||||||
|
timezone = America/New_York
|
||||||
|
|
||||||
|
# Default morning digest time (HH:MM, 24-hour, local time).
|
||||||
|
# Also stored in the config table and adjustable via /settime — the value here
|
||||||
|
# is written to the DB on first run; subsequent /settime changes update the DB.
|
||||||
|
digest_time = 06:30
|
||||||
12
cpanfile
Normal file
12
cpanfile
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Runtime dependencies
|
||||||
|
requires 'Mojolicious', '>= 9.0';
|
||||||
|
requires 'DBI', '>= 1.643';
|
||||||
|
requires 'DBD::SQLite', '>= 1.70';
|
||||||
|
requires 'DateTime', '>= 1.54';
|
||||||
|
requires 'DateTime::TimeZone', '>= 2.20';
|
||||||
|
|
||||||
|
# Test dependencies
|
||||||
|
on test => sub {
|
||||||
|
requires 'Test::More', '>= 1.302';
|
||||||
|
requires 'Test::Exception', '>= 0.43';
|
||||||
|
};
|
||||||
69
lib/Mnemosyne/Config.pm
Normal file
69
lib/Mnemosyne/Config.pm
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package Mnemosyne::Config;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# Loads mnemosyne.conf (INI-style) and exposes a simple accessor.
|
||||||
|
# Usage:
|
||||||
|
# my $cfg = Mnemosyne::Config->new('/path/to/mnemosyne.conf');
|
||||||
|
# my $token = $cfg->get('bot', 'token');
|
||||||
|
# my $href = $cfg->section('bot'); # all keys in [bot]
|
||||||
|
|
||||||
|
sub new {
|
||||||
|
my ($class, $path) = @_;
|
||||||
|
die "Config file not specified\n" unless defined $path;
|
||||||
|
die "Config file not found: $path\n" unless -f $path;
|
||||||
|
|
||||||
|
my $self = bless { _path => $path, _data => {} }, $class;
|
||||||
|
$self->_parse;
|
||||||
|
return $self;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _parse {
|
||||||
|
my ($self) = @_;
|
||||||
|
open my $fh, '<', $self->{_path}
|
||||||
|
or die "Cannot open config '$self->{_path}': $!\n";
|
||||||
|
|
||||||
|
my $section = '_default';
|
||||||
|
while (my $line = <$fh>) {
|
||||||
|
chomp $line;
|
||||||
|
$line =~ s/\s*#.*$//; # strip inline comments
|
||||||
|
$line =~ s/^\s+|\s+$//g; # trim
|
||||||
|
next unless length $line;
|
||||||
|
|
||||||
|
if ($line =~ /^\[(\w+)\]$/) {
|
||||||
|
$section = lc $1;
|
||||||
|
}
|
||||||
|
elsif ($line =~ /^([\w_]+)\s*=\s*(.*)$/) {
|
||||||
|
my ($key, $val) = (lc $1, $2);
|
||||||
|
$val =~ s/\s+$//;
|
||||||
|
$self->{_data}{$section}{$key} = $val;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
warn "Config: unrecognised line in $self->{_path}: $line\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub get {
|
||||||
|
my ($self, $section, $key) = @_;
|
||||||
|
$section = lc $section;
|
||||||
|
$key = lc $key;
|
||||||
|
return $self->{_data}{$section}{$key};
|
||||||
|
}
|
||||||
|
|
||||||
|
sub section {
|
||||||
|
my ($self, $section) = @_;
|
||||||
|
return { %{ $self->{_data}{lc $section} // {} } };
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the list of allowed chat IDs as an array ref (parsed from the
|
||||||
|
# comma-separated 'allowed_chat_ids' value in [bot]).
|
||||||
|
sub allowed_chat_ids {
|
||||||
|
my ($self) = @_;
|
||||||
|
my $raw = $self->get('bot', 'allowed_chat_ids') // '';
|
||||||
|
my @ids = grep { /^\d+$/ } map { s/^\s+|\s+$//gr } split /,/, $raw;
|
||||||
|
return \@ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
||||||
137
lib/Mnemosyne/DB.pm
Normal file
137
lib/Mnemosyne/DB.pm
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package Mnemosyne::DB;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use DBI;
|
||||||
|
use File::Basename qw(dirname);
|
||||||
|
use File::Spec;
|
||||||
|
use Carp qw(croak);
|
||||||
|
|
||||||
|
# Opens (and initialises if new) the Mnemosyne SQLite database.
|
||||||
|
# Usage:
|
||||||
|
# my $db = Mnemosyne::DB->new('/var/lib/mnemosyne/mnemosyne.db');
|
||||||
|
# my $dbh = $db->dbh;
|
||||||
|
|
||||||
|
sub new {
|
||||||
|
my ($class, $path) = @_;
|
||||||
|
croak "Database path required" unless defined $path;
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
my $dir = dirname($path);
|
||||||
|
unless (-d $dir) {
|
||||||
|
require File::Path;
|
||||||
|
File::Path::make_path($dir) or croak "Cannot create DB directory '$dir': $!";
|
||||||
|
}
|
||||||
|
|
||||||
|
my $dbh = DBI->connect(
|
||||||
|
"dbi:SQLite:dbname=$path", '', '',
|
||||||
|
{
|
||||||
|
RaiseError => 1,
|
||||||
|
AutoCommit => 1,
|
||||||
|
sqlite_unicode => 1,
|
||||||
|
}
|
||||||
|
) or croak "Cannot connect to SQLite '$path': " . $DBI::errstr;
|
||||||
|
|
||||||
|
my $self = bless { _dbh => $dbh, _path => $path }, $class;
|
||||||
|
$self->_configure;
|
||||||
|
$self->_apply_schema;
|
||||||
|
return $self;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub dbh { $_[0]->{_dbh} }
|
||||||
|
|
||||||
|
sub _configure {
|
||||||
|
my ($self) = @_;
|
||||||
|
my $dbh = $self->{_dbh};
|
||||||
|
$dbh->do('PRAGMA foreign_keys = ON');
|
||||||
|
$dbh->do('PRAGMA journal_mode = WAL');
|
||||||
|
$dbh->do('PRAGMA synchronous = NORMAL'); # safe with WAL, faster than FULL
|
||||||
|
}
|
||||||
|
|
||||||
|
sub _apply_schema {
|
||||||
|
my ($self) = @_;
|
||||||
|
|
||||||
|
# Schema DDL is kept here alongside the canonical share/schema.sql.
|
||||||
|
# share/schema.sql is the human-readable reference; this is what runs at startup.
|
||||||
|
# Both must be kept in sync when the schema changes.
|
||||||
|
my @statements = (
|
||||||
|
q{
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
class TEXT NOT NULL
|
||||||
|
CHECK(class IN ('monthly_date','monthly_weekday',
|
||||||
|
'every_n_period','interval','floating')),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
day_of_month INTEGER,
|
||||||
|
weekday INTEGER,
|
||||||
|
ordinal INTEGER,
|
||||||
|
interval_n INTEGER,
|
||||||
|
period_unit TEXT CHECK(period_unit IN ('day','week','month') OR period_unit IS NULL),
|
||||||
|
anchor_date TEXT,
|
||||||
|
interval_days INTEGER,
|
||||||
|
priority TEXT CHECK(priority IN ('high','medium','low') OR priority IS NULL),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
)},
|
||||||
|
q{
|
||||||
|
CREATE TABLE IF NOT EXISTS completions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
completed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
)},
|
||||||
|
q{CREATE INDEX IF NOT EXISTS idx_completions_task_id ON completions(task_id)},
|
||||||
|
q{
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)},
|
||||||
|
);
|
||||||
|
|
||||||
|
my @config_defaults = (
|
||||||
|
[ 'digest_time', '06:30' ],
|
||||||
|
[ 'timezone', 'UTC' ],
|
||||||
|
[ 'last_digest_sent', '' ],
|
||||||
|
[ 'upcoming_horizon', '7' ],
|
||||||
|
[ 'medium_float_days', '3' ],
|
||||||
|
);
|
||||||
|
|
||||||
|
my $dbh = $self->{_dbh};
|
||||||
|
$dbh->begin_work;
|
||||||
|
eval {
|
||||||
|
$dbh->do($_) for @statements;
|
||||||
|
my $ins = $dbh->prepare('INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)');
|
||||||
|
$ins->execute(@$_) for @config_defaults;
|
||||||
|
$dbh->commit;
|
||||||
|
};
|
||||||
|
if ($@) {
|
||||||
|
$dbh->rollback;
|
||||||
|
croak "Schema initialisation failed: $@";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Config table helpers ---
|
||||||
|
|
||||||
|
sub config_get {
|
||||||
|
my ($self, $key) = @_;
|
||||||
|
my ($val) = $self->{_dbh}->selectrow_array(
|
||||||
|
'SELECT value FROM config WHERE key = ?', undef, $key
|
||||||
|
);
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub config_set {
|
||||||
|
my ($self, $key, $value) = @_;
|
||||||
|
$self->{_dbh}->do(
|
||||||
|
'INSERT INTO config (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||||
|
undef, $key, $value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub DESTROY {
|
||||||
|
my ($self) = @_;
|
||||||
|
$self->{_dbh}->disconnect if $self->{_dbh};
|
||||||
|
}
|
||||||
|
|
||||||
|
1;
|
||||||
27
lib/Mnemosyne/Digest.pm
Normal file
27
lib/Mnemosyne/Digest.pm
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package Mnemosyne::Digest;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# Assembles the "Day at a Glance" digest content and sends it via Telegram.
|
||||||
|
# Also used by the /today and /glance commands for on-demand delivery.
|
||||||
|
#
|
||||||
|
# Sections (in order), each omitted when empty:
|
||||||
|
# 1. Overdue — dated tasks past their due date with no completion
|
||||||
|
# 2. Today — tasks due today
|
||||||
|
# 3. Upcoming — tasks due in the next upcoming_horizon days (default 7)
|
||||||
|
# 4. Floating — priority-driven selection per Schedule::_floating_show rules
|
||||||
|
#
|
||||||
|
# Each actionable line gets a Mark Done inline keyboard button.
|
||||||
|
# A friendly "all clear" line is shown when all sections are empty.
|
||||||
|
#
|
||||||
|
# TODO: build($db, $config, $today_dt) — returns an arrayref of message segments
|
||||||
|
# (Telegram has a 4096-char limit; split into multiple messages if needed)
|
||||||
|
#
|
||||||
|
# TODO: send($db, $config, $telegram, $chat_id, $today_dt)
|
||||||
|
# — calls build(), sends via Telegram, records last_digest_sent in config table
|
||||||
|
#
|
||||||
|
# TODO: already_sent_today($db, $today_dt)
|
||||||
|
# — checks last_digest_sent config row; returns true if digest was sent today
|
||||||
|
# — used by mnemosyne-digest script to prevent duplicate sends
|
||||||
|
|
||||||
|
1;
|
||||||
36
lib/Mnemosyne/Schedule.pm
Normal file
36
lib/Mnemosyne/Schedule.pm
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package Mnemosyne::Schedule;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# Resolves whether a task is due/upcoming/overdue/irrelevant for a given date.
|
||||||
|
# This module is the heart of the scheduling logic and must be well unit-tested.
|
||||||
|
#
|
||||||
|
# Public interface (all take a task hashref + a DateTime object for "today"):
|
||||||
|
#
|
||||||
|
# TODO: status($task, $today_dt) — returns one of: 'overdue', 'due', 'upcoming', 'inactive'
|
||||||
|
# Dispatches to the appropriate class handler below.
|
||||||
|
#
|
||||||
|
# TODO: next_due_date($task, $today_dt) — returns a DateTime of the next occurrence,
|
||||||
|
# or undef if the task has no upcoming date (e.g. inactive).
|
||||||
|
#
|
||||||
|
# --- Per-class handlers ---
|
||||||
|
#
|
||||||
|
# TODO: _monthly_date_due($task, $today_dt)
|
||||||
|
# Rule: fires on day_of_month each month.
|
||||||
|
# Short-month rule: if day_of_month > days-in-month, fire on last day of month.
|
||||||
|
#
|
||||||
|
# TODO: _monthly_weekday_due($task, $today_dt)
|
||||||
|
# Rule: fires on the Nth weekday (ordinal 1-4, or -1 for last) each month.
|
||||||
|
#
|
||||||
|
# TODO: _every_n_period_due($task, $today_dt)
|
||||||
|
# Rule: occurrences = anchor_date + k * (interval_n period_units), k=0,1,2,...
|
||||||
|
# Calendar-anchored; NOT reset by completion.
|
||||||
|
#
|
||||||
|
# TODO: _interval_due($task, $today_dt, $last_completion_dt)
|
||||||
|
# Rule: next_due = last_completion_dt + interval_days (or created_at if never done).
|
||||||
|
#
|
||||||
|
# TODO: _floating_show($task, $today_dt, $last_completion_dt)
|
||||||
|
# Rule: high → always; medium → every medium_float_days; low → weekly + randomised.
|
||||||
|
# Returns true/false (floating tasks are never "overdue", just shown or not).
|
||||||
|
|
||||||
|
1;
|
||||||
17
lib/Mnemosyne/Task.pm
Normal file
17
lib/Mnemosyne/Task.pm
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package Mnemosyne::Task;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
1;
|
||||||
35
lib/Mnemosyne/Telegram.pm
Normal file
35
lib/Mnemosyne/Telegram.pm
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package Mnemosyne::Telegram;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
1;
|
||||||
37
lib/Mnemosyne/Webhook.pm
Normal file
37
lib/Mnemosyne/Webhook.pm
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package Mnemosyne::Webhook;
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
1;
|
||||||
37
nginx/mnemosyne.conf.example
Normal file
37
nginx/mnemosyne.conf.example
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Nginx reverse-proxy config for Mnemosyne.
|
||||||
|
# Place in /etc/nginx/conf.d/ (RHEL/AlmaLinux) or /etc/nginx/sites-available/ (Debian/Ubuntu).
|
||||||
|
# Replace mnemosyne.example.com with your actual subdomain.
|
||||||
|
# Assumes Let's Encrypt cert managed by certbot.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name mnemosyne.example.com;
|
||||||
|
# certbot will add a redirect here
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name mnemosyne.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/mnemosyne.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/mnemosyne.example.com/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Only expose the webhook path; deny everything else
|
||||||
|
location /hook/ {
|
||||||
|
proxy_pass http://127.0.0.1:8765;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Forward Telegram's secret-token header
|
||||||
|
proxy_pass_header X-Telegram-Bot-Api-Secret-Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
scripts/backup.sh
Executable file
13
scripts/backup.sh
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Online SQLite backup using the .backup API (safe while the bot is running).
|
||||||
|
# TODO: implement; outline below.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# TODO: read DB_PATH from config or env
|
||||||
|
# TODO: BACKUP_DIR default /var/backups/mnemosyne, override via $MNEMOSYNE_BACKUP_DIR
|
||||||
|
# TODO: filename: mnemosyne-$(date +%Y%m%d-%H%M%S).db
|
||||||
|
# TODO: sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/$FILENAME'"
|
||||||
|
# (uses the SQLite backup API — safe for concurrent access, no locks held)
|
||||||
|
# TODO: optionally prune backups older than N days
|
||||||
|
|
||||||
|
echo "TODO: backup.sh not yet implemented"
|
||||||
44
scripts/install.sh
Executable file
44
scripts/install.sh
Executable file
@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mnemosyne install script — idempotent; safe to re-run.
|
||||||
|
# Does NOT clobber an existing database or config file.
|
||||||
|
# TODO: implement; outline below.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Detect distro family ---
|
||||||
|
# TODO: detect RHEL/AlmaLinux (dnf/yum) vs Debian/Ubuntu (apt) vs other
|
||||||
|
# TODO: on RHEL family: dnf install -y perl perl-App-cpanminus perl-DBI sqlite
|
||||||
|
# TODO: on Debian family: apt-get install -y perl cpanminus libdbi-perl sqlite3
|
||||||
|
# TODO: document: on either family, run `cpanm --installdeps .` after this step
|
||||||
|
|
||||||
|
# --- System user ---
|
||||||
|
# TODO: create 'mnemosyne' system user/group if not present (useradd -r -s /sbin/nologin)
|
||||||
|
|
||||||
|
# --- Directories ---
|
||||||
|
# TODO: /opt/mnemosyne — app files (copy repo here or symlink)
|
||||||
|
# TODO: /var/lib/mnemosyne — database directory, owned by mnemosyne user
|
||||||
|
# TODO: /etc/mnemosyne — config directory (skip if already exists to avoid clobbering)
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
# TODO: if /etc/mnemosyne/mnemosyne.conf does not exist:
|
||||||
|
# copy config/mnemosyne.conf.example → /etc/mnemosyne/mnemosyne.conf
|
||||||
|
# chmod 640, chown root:mnemosyne
|
||||||
|
# print reminder to edit the file before starting the service
|
||||||
|
|
||||||
|
# --- CPAN deps ---
|
||||||
|
# TODO: cd /opt/mnemosyne && cpanm --installdeps .
|
||||||
|
|
||||||
|
# --- Systemd ---
|
||||||
|
# TODO: copy systemd/mnemosyne-bot.service → /etc/systemd/system/
|
||||||
|
# TODO: systemctl daemon-reload
|
||||||
|
# TODO: systemctl enable mnemosyne-bot (but do NOT start — user must configure first)
|
||||||
|
|
||||||
|
# --- Cron for digest ---
|
||||||
|
# TODO: install a cron entry for mnemosyne user:
|
||||||
|
# */5 * * * * /opt/mnemosyne/bin/mnemosyne-digest --config /etc/mnemosyne/mnemosyne.conf
|
||||||
|
# This fires every 5 minutes; the script itself checks against digest_time and exits early.
|
||||||
|
|
||||||
|
# --- Nginx ---
|
||||||
|
# TODO: remind user to copy nginx/mnemosyne.conf.example and adjust, then reload nginx
|
||||||
|
|
||||||
|
echo "TODO: install.sh not yet implemented"
|
||||||
68
share/schema.sql
Normal file
68
share/schema.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- Mnemosyne schema
|
||||||
|
-- Apply once; all statements use IF NOT EXISTS so this is safe to re-run.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
class TEXT NOT NULL
|
||||||
|
CHECK(class IN ('monthly_date','monthly_weekday',
|
||||||
|
'every_n_period','interval','floating')),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- monthly_date: fires on this day of every month.
|
||||||
|
-- Short-month rule: if day_of_month > last day of month, fire on last day.
|
||||||
|
day_of_month INTEGER,
|
||||||
|
|
||||||
|
-- monthly_weekday: fires on the Nth weekday of every month.
|
||||||
|
-- weekday: 0=Monday .. 6=Sunday (ISO weekday - 1)
|
||||||
|
-- ordinal: 1-4 for first-fourth; -1 for "last"
|
||||||
|
weekday INTEGER,
|
||||||
|
ordinal INTEGER,
|
||||||
|
|
||||||
|
-- every_n_period: fires every interval_n period_units after anchor_date.
|
||||||
|
-- Occurrences are calendar-anchored (not completion-driven).
|
||||||
|
interval_n INTEGER,
|
||||||
|
period_unit TEXT CHECK(period_unit IN ('day','week','month') OR period_unit IS NULL),
|
||||||
|
anchor_date TEXT, -- ISO date YYYY-MM-DD
|
||||||
|
|
||||||
|
-- interval: next due = last_completed_at + interval_days.
|
||||||
|
-- If never completed, seeds from created_at (so it doesn't scream overdue on day one).
|
||||||
|
interval_days INTEGER,
|
||||||
|
|
||||||
|
-- floating: no fixed date; reminder frequency driven by priority.
|
||||||
|
-- high → appears every day
|
||||||
|
-- medium → appears every medium_float_days (see config table, default 3)
|
||||||
|
-- low → appears roughly weekly; which low items show is randomised/rotated
|
||||||
|
-- Completing a floating task sets active=0 (archives it), since floating items
|
||||||
|
-- are typically one-and-done aspirations.
|
||||||
|
priority TEXT CHECK(priority IN ('high','medium','low') OR priority IS NULL),
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS completions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
completed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_task_id ON completions(task_id);
|
||||||
|
|
||||||
|
-- Single-row key/value store for runtime-tunable settings.
|
||||||
|
-- Populated with defaults below; /settime and future commands update rows here.
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO config (key, value) VALUES
|
||||||
|
('digest_time', '06:30'), -- local HH:MM, adjustable via /settime
|
||||||
|
('timezone', 'UTC'), -- overridden by mnemosyne.conf at startup
|
||||||
|
('last_digest_sent', ''), -- ISO date of last successful digest; guards against dupes
|
||||||
|
('upcoming_horizon', '7'), -- days forward shown in Upcoming section
|
||||||
|
('medium_float_days', '3'); -- how often medium-priority floating items surface
|
||||||
19
systemd/mnemosyne-bot.service
Normal file
19
systemd/mnemosyne-bot.service
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mnemosyne Telegram bot (webhook receiver)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mnemosyne
|
||||||
|
Group=mnemosyne
|
||||||
|
WorkingDirectory=/opt/mnemosyne
|
||||||
|
ExecStart=/usr/bin/perl /opt/mnemosyne/bin/mnemosyne-bot --config /etc/mnemosyne/mnemosyne.conf
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
# Keep logs in the journal
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=mnemosyne-bot
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
40
t/config.t
Normal file
40
t/config.t
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use Test::More;
|
||||||
|
use File::Temp qw(tempfile);
|
||||||
|
use FindBin qw($RealBin);
|
||||||
|
use lib "$RealBin/../lib";
|
||||||
|
|
||||||
|
use_ok('Mnemosyne::Config');
|
||||||
|
|
||||||
|
my (undef, $tmp) = tempfile(SUFFIX => '.conf', UNLINK => 1);
|
||||||
|
open my $fh, '>', $tmp or die $!;
|
||||||
|
print $fh <<'END';
|
||||||
|
[bot]
|
||||||
|
token = abc123
|
||||||
|
webhook_secret = secret456
|
||||||
|
allowed_chat_ids = 111, 222, 333
|
||||||
|
listen_port = 8765
|
||||||
|
|
||||||
|
[db]
|
||||||
|
path = /var/lib/mnemosyne/mnemosyne.db
|
||||||
|
|
||||||
|
[app]
|
||||||
|
timezone = America/Chicago # inline comment
|
||||||
|
digest_time = 07:30
|
||||||
|
END
|
||||||
|
close $fh;
|
||||||
|
|
||||||
|
my $cfg = Mnemosyne::Config->new($tmp);
|
||||||
|
isa_ok($cfg, 'Mnemosyne::Config');
|
||||||
|
|
||||||
|
is($cfg->get('bot', 'token'), 'abc123', 'bot.token');
|
||||||
|
is($cfg->get('bot', 'listen_port'), '8765', 'bot.listen_port');
|
||||||
|
is($cfg->get('app', 'digest_time'), '07:30', 'app.digest_time');
|
||||||
|
is($cfg->get('app', 'timezone'), 'America/Chicago', 'inline comment stripped');
|
||||||
|
is($cfg->get('db', 'path'), '/var/lib/mnemosyne/mnemosyne.db', 'db.path');
|
||||||
|
is_deeply($cfg->allowed_chat_ids, [111, 222, 333], 'allowed_chat_ids parsed');
|
||||||
|
is($cfg->get('bot', 'nonexistent'), undef, 'missing key returns undef');
|
||||||
|
|
||||||
|
done_testing;
|
||||||
30
t/db.t
Normal file
30
t/db.t
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use Test::More;
|
||||||
|
use File::Temp qw(tempfile);
|
||||||
|
use FindBin qw($RealBin);
|
||||||
|
use lib "$RealBin/../lib";
|
||||||
|
|
||||||
|
use_ok('Mnemosyne::DB');
|
||||||
|
|
||||||
|
my (undef, $tmp) = tempfile(SUFFIX => '.db', UNLINK => 1);
|
||||||
|
|
||||||
|
my $db = Mnemosyne::DB->new($tmp);
|
||||||
|
isa_ok($db, 'Mnemosyne::DB');
|
||||||
|
ok($db->dbh, 'dbh returns a handle');
|
||||||
|
|
||||||
|
# Schema applied
|
||||||
|
my @tables = map { $_->[0] }
|
||||||
|
@{ $db->dbh->selectall_arrayref("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") };
|
||||||
|
is_deeply(\@tables, [qw(completions config tasks)], 'expected tables created');
|
||||||
|
|
||||||
|
# Config defaults
|
||||||
|
is($db->config_get('digest_time'), '06:30', 'default digest_time');
|
||||||
|
is($db->config_get('upcoming_horizon'), '7', 'default upcoming_horizon');
|
||||||
|
|
||||||
|
# config_set round-trip
|
||||||
|
$db->config_set('digest_time', '07:00');
|
||||||
|
is($db->config_get('digest_time'), '07:00', 'config_set persists');
|
||||||
|
|
||||||
|
done_testing;
|
||||||
36
t/schedule.t
Normal file
36
t/schedule.t
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env perl
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use Test::More;
|
||||||
|
|
||||||
|
# Schedule.pm unit tests — date math across all five task classes.
|
||||||
|
# This file is a placeholder; tests will be added as Schedule.pm is implemented.
|
||||||
|
#
|
||||||
|
# Priority test cases to cover:
|
||||||
|
#
|
||||||
|
# monthly_date:
|
||||||
|
# - Normal case: day_of_month=15, today=2026-06-15 → due
|
||||||
|
# - Short-month clamp: day_of_month=31, today=2026-06-30 → due (last day of June)
|
||||||
|
# - Short-month clamp: day_of_month=31, today=2026-06-15 → upcoming
|
||||||
|
# - Overdue: day_of_month=1, today=2026-06-05, no completion → overdue
|
||||||
|
#
|
||||||
|
# monthly_weekday:
|
||||||
|
# - 4th Monday: 2026-06 → 2026-06-22
|
||||||
|
# - last Monday: 2026-06 → 2026-06-29
|
||||||
|
# - ordinal=1, weekday=0 (Mon): 2026-06 → 2026-06-01
|
||||||
|
#
|
||||||
|
# every_n_period:
|
||||||
|
# - every 2 weeks from 2026-01-01: check correct occurrence dates
|
||||||
|
# - every 3 months from 2026-01-15: Feb has 28 days — verify month arithmetic
|
||||||
|
#
|
||||||
|
# interval:
|
||||||
|
# - never completed: due = created_at + interval_days
|
||||||
|
# - completed yesterday: interval=30 → not due yet
|
||||||
|
# - completed 31 days ago: interval=30 → overdue
|
||||||
|
#
|
||||||
|
# floating:
|
||||||
|
# - high priority → always shown
|
||||||
|
# - medium: shown at day 0 and day 3, not at day 1 or 2
|
||||||
|
|
||||||
|
plan skip_all => 'Schedule.pm not yet implemented';
|
||||||
|
done_testing;
|
||||||
Loading…
Reference in New Issue
Block a user