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