Initial scaffolding

This commit is contained in:
Rodger Castle 2026-06-04 13:57:46 -04:00
parent 15a0ff43de
commit 989415ec1f
20 changed files with 743 additions and 0 deletions

13
bin/mnemosyne-bot Executable file
View 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
View 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
View 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";

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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;