diff --git a/bin/mnemosyne-bot b/bin/mnemosyne-bot new file mode 100755 index 0000000..c5d08db --- /dev/null +++ b/bin/mnemosyne-bot @@ -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 / => validate secret token, route to Mnemosyne::Webhook +# TODO: Start app on 127.0.0.1: (never bind 0.0.0.0) + +print "mnemosyne-bot: not yet implemented\n"; diff --git a/bin/mnemosyne-digest b/bin/mnemosyne-digest new file mode 100755 index 0000000..e5b886a --- /dev/null +++ b/bin/mnemosyne-digest @@ -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"; diff --git a/bin/mnemosyne-webhook b/bin/mnemosyne-webhook new file mode 100755 index 0000000..27bda32 --- /dev/null +++ b/bin/mnemosyne-webhook @@ -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"; diff --git a/config/mnemosyne.conf.example b/config/mnemosyne.conf.example new file mode 100644 index 0000000..ced612c --- /dev/null +++ b/config/mnemosyne.conf.example @@ -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 diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..6e00b73 --- /dev/null +++ b/cpanfile @@ -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'; +}; diff --git a/lib/Mnemosyne/Config.pm b/lib/Mnemosyne/Config.pm new file mode 100644 index 0000000..74f00af --- /dev/null +++ b/lib/Mnemosyne/Config.pm @@ -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; diff --git a/lib/Mnemosyne/DB.pm b/lib/Mnemosyne/DB.pm new file mode 100644 index 0000000..06de13a --- /dev/null +++ b/lib/Mnemosyne/DB.pm @@ -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; diff --git a/lib/Mnemosyne/Digest.pm b/lib/Mnemosyne/Digest.pm new file mode 100644 index 0000000..51f75e9 --- /dev/null +++ b/lib/Mnemosyne/Digest.pm @@ -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; diff --git a/lib/Mnemosyne/Schedule.pm b/lib/Mnemosyne/Schedule.pm new file mode 100644 index 0000000..9a5cc01 --- /dev/null +++ b/lib/Mnemosyne/Schedule.pm @@ -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; diff --git a/lib/Mnemosyne/Task.pm b/lib/Mnemosyne/Task.pm new file mode 100644 index 0000000..50ef18e --- /dev/null +++ b/lib/Mnemosyne/Task.pm @@ -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; diff --git a/lib/Mnemosyne/Telegram.pm b/lib/Mnemosyne/Telegram.pm new file mode 100644 index 0000000..9cda042 --- /dev/null +++ b/lib/Mnemosyne/Telegram.pm @@ -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; diff --git a/lib/Mnemosyne/Webhook.pm b/lib/Mnemosyne/Webhook.pm new file mode 100644 index 0000000..17b1270 --- /dev/null +++ b/lib/Mnemosyne/Webhook.pm @@ -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; diff --git a/nginx/mnemosyne.conf.example b/nginx/mnemosyne.conf.example new file mode 100644 index 0000000..b13ad3f --- /dev/null +++ b/nginx/mnemosyne.conf.example @@ -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; + } +} diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..bab5b5a --- /dev/null +++ b/scripts/backup.sh @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..84d1051 --- /dev/null +++ b/scripts/install.sh @@ -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" diff --git a/share/schema.sql b/share/schema.sql new file mode 100644 index 0000000..056bfb4 --- /dev/null +++ b/share/schema.sql @@ -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 diff --git a/systemd/mnemosyne-bot.service b/systemd/mnemosyne-bot.service new file mode 100644 index 0000000..996e6c6 --- /dev/null +++ b/systemd/mnemosyne-bot.service @@ -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 diff --git a/t/config.t b/t/config.t new file mode 100644 index 0000000..eae69e6 --- /dev/null +++ b/t/config.t @@ -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; diff --git a/t/db.t b/t/db.t new file mode 100644 index 0000000..ac12a37 --- /dev/null +++ b/t/db.t @@ -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; diff --git a/t/schedule.t b/t/schedule.t new file mode 100644 index 0000000..feba200 --- /dev/null +++ b/t/schedule.t @@ -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;