claude cisteni skillu remind

This commit is contained in:
lachtan
2026-06-10 08:35:31 +02:00
parent addd153e8f
commit edf823fc72
5 changed files with 261 additions and 88 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# dependencies = ["croniter"]
# ///
"""Deterministic CRUD for reminders backed by SQLite.
@@ -15,8 +15,9 @@ import argparse
import json
import os
import sys
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from croniter import croniter
from db import get_db, init_db, log_operation
@@ -25,6 +26,7 @@ from random_times import compute_fire_times
WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent
DEFAULT_DB_PATH = WORKSPACE / "db" / "reminders.sqlite"
DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
PRAGUE = ZoneInfo("Europe/Prague")
def _now() -> str:
@@ -51,7 +53,7 @@ def _build_random(args: argparse.Namespace) -> dict | None:
raise ValueError("random schedule needs --random-times-per-day and --random-window")
cfg = {key: value for key, value in fields.items() if value is not None}
compute_fire_times(__import__("datetime").date(2000, 1, 1), "validation", cfg)
compute_fire_times(date(2000, 1, 1), "validation", cfg)
return cfg
@@ -136,6 +138,44 @@ def _find_by_keyword(conn, keyword: str) -> list[dict]:
return [dict(r) for r in rows]
def _resolve_one(conn, args: argparse.Namespace) -> dict | None:
"""Resolve exactly one active reminder by --id (exact) or --keyword (substring).
Prints a JSON error to stderr and returns None when no/ambiguous match. Ambiguous
matches include each id so the caller can retry with --id.
"""
rid = getattr(args, "id", None)
if rid is not None:
row = conn.execute(
"SELECT id, text, enabled, timezone, created_at, updated_at, deleted_at "
"FROM reminders WHERE id = ? AND deleted_at IS NULL",
(rid,),
).fetchone()
if row is None:
print(json.dumps({"error": "no match", "id": rid}), file=sys.stderr)
return None
return dict(row)
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "provide --id or --keyword"}), file=sys.stderr)
return None
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return None
if len(matches) > 1:
print(
json.dumps(
{"error": "ambiguous", "matches": [{"id": m["id"], "text": m["text"]} for m in matches]},
ensure_ascii=False,
),
file=sys.stderr,
)
return None
return matches[0]
def cmd_list(_args: argparse.Namespace) -> int:
_ensure_db()
conn = get_db(DB_PATH)
@@ -238,30 +278,18 @@ def cmd_add(args: argparse.Namespace) -> int:
def cmd_remove(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
target = _resolve_one(conn, args)
if target is None:
return 1
rid = matches[0]["id"]
rid = target["id"]
conn.execute("BEGIN")
conn.execute("UPDATE reminders SET deleted_at = ?, updated_at = ? WHERE id = ?", (_now(), _now(), rid))
conn.execute("COMMIT")
log_operation("REMOVE", rid, f'text="{matches[0]["text"]}"')
log_operation("REMOVE", rid, f'text="{target["text"]}"')
reminder = _fetch_reminder(conn, rid)
print(json.dumps({"removed": reminder}, ensure_ascii=False))
return 0
@@ -274,26 +302,18 @@ def cmd_remove(args: argparse.Namespace) -> int:
def cmd_edit(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
if args.replace_schedules and not (args.at or args.cron or args.random_times_per_day or args.random_window):
print(json.dumps({"error": "--replace-schedules requires at least one --cron/--at/--random-* option"}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
target = _resolve_one(conn, args)
if target is None:
return 1
rid = matches[0]["id"]
rid = target["id"]
conn.execute("BEGIN")
now = _now()
@@ -326,26 +346,14 @@ def cmd_edit(args: argparse.Namespace) -> int:
def cmd_enable(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
target = _resolve_one(conn, args)
if target is None:
return 1
rid = matches[0]["id"]
rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 1, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("ENABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
@@ -359,26 +367,14 @@ def cmd_enable(args: argparse.Namespace) -> int:
def cmd_disable(args: argparse.Namespace) -> int:
keyword = (args.keyword or "").strip().lower()
if not keyword:
print(json.dumps({"error": "keyword must not be empty"}), file=sys.stderr)
return 1
_ensure_db()
conn = get_db(DB_PATH)
try:
matches = _find_by_keyword(conn, keyword)
if len(matches) == 0:
print(json.dumps({"error": "no match", "keyword": args.keyword}), file=sys.stderr)
return 1
if len(matches) > 1:
print(
json.dumps({"error": "ambiguous", "matches": [{"text": m["text"]} for m in matches]}, ensure_ascii=False),
file=sys.stderr,
)
target = _resolve_one(conn, args)
if target is None:
return 1
rid = matches[0]["id"]
rid = target["id"]
conn.execute("UPDATE reminders SET enabled = 0, updated_at = ? WHERE id = ?", (_now(), rid))
log_operation("DISABLE", rid, None)
reminder = _fetch_reminder(conn, rid)
@@ -391,6 +387,44 @@ def cmd_disable(args: argparse.Namespace) -> int:
conn.close()
def cmd_delivered(args: argparse.Namespace) -> int:
"""List reminders actually delivered to the user, newest last.
Answers 'what reminders arrived today?'. fire_time/delivered_at are stored in
Prague local time, so no conversion is needed. Defaults to today (Prague).
"""
_ensure_db()
conn = get_db(DB_PATH)
try:
since = (args.since or "").strip()
if since:
try:
date.fromisoformat(since)
except ValueError as exc:
print(json.dumps({"error": f"invalid --since date: {exc}"}), file=sys.stderr)
return 1
where, params = "f.fire_time >= ?", (since,)
else:
today = datetime.now(PRAGUE).date().isoformat()
where, params = "substr(f.fire_time, 1, 10) = ?", (today,)
rows = conn.execute(
f"""
SELECT f.delivered_at, r.text
FROM reminder_fires f
JOIN reminders r ON r.id = f.reminder_id
WHERE f.status = 'delivered' AND {where}
ORDER BY f.delivered_at
""",
params,
).fetchall()
for row in rows:
print(f"{row['delivered_at']} {row['text']}")
return 0
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(description="CRUD for reminders (SQLite backed)")
sub = parser.add_subparsers(dest="command", required=True)
@@ -407,11 +441,13 @@ def main() -> None:
add_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD", help="Random schedule: start date")
add_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD", help="Random schedule: end date")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword (soft delete)")
remove_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
remove_p = sub.add_parser("remove", help="Remove a reminder by keyword or id (soft delete)")
remove_p.add_argument("--keyword", help="Substring to match against reminder text")
remove_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword")
edit_p.add_argument("--keyword", required=True, help="Substring to match against reminder text")
edit_p = sub.add_parser("edit", help="Edit a reminder by keyword or id")
edit_p.add_argument("--keyword", help="Substring to match against reminder text")
edit_p.add_argument("--id", type=int, help="Exact reminder id (disambiguates duplicate texts)")
edit_p.add_argument("--text", help="New reminder text")
edit_p.add_argument("--replace-schedules", action="store_true", help="Replace all schedules with new ones")
edit_p.add_argument("--cron", action="append", metavar="EXPR", help="Cron expression (repeatable)")
@@ -422,11 +458,16 @@ def main() -> None:
edit_p.add_argument("--random-from", dest="random_from", metavar="YYYY-MM-DD")
edit_p.add_argument("--random-until", dest="random_until", metavar="YYYY-MM-DD")
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword")
enable_p.add_argument("--keyword", required=True)
enable_p = sub.add_parser("enable", help="Enable a reminder by keyword or id")
enable_p.add_argument("--keyword")
enable_p.add_argument("--id", type=int, help="Exact reminder id")
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword")
disable_p.add_argument("--keyword", required=True)
disable_p = sub.add_parser("disable", help="Disable a reminder by keyword or id")
disable_p.add_argument("--keyword")
disable_p.add_argument("--id", type=int, help="Exact reminder id")
delivered_p = sub.add_parser("delivered", help="List reminders delivered to the user (default: today)")
delivered_p.add_argument("--since", metavar="YYYY-MM-DD", help="List deliveries on/after this date instead of today")
args = parser.parse_args()
dispatch = {
@@ -436,6 +477,7 @@ def main() -> None:
"edit": cmd_edit,
"enable": cmd_enable,
"disable": cmd_disable,
"delivered": cmd_delivered,
}
return dispatch[args.command](args)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["croniter", "pyyaml"]
# dependencies = ["croniter"]
# ///
"""Deterministic reminder sender backed by SQLite.
@@ -21,7 +21,6 @@ from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from croniter import croniter
from db import get_db, init_db, log_operation
from random_times import compute_fire_times
@@ -32,19 +31,23 @@ DB_PATH = Path(os.environ.get("REMIND_DB", str(DEFAULT_DB_PATH)))
CONFIG = Path.home() / ".nanobot" / "config.json"
TZ = ZoneInfo("Europe/Prague")
CHAT_ID = "8826147089"
TOLERANCE_SECONDS = 60
FALLBACK_CHAT_ID = "8826147089"
def _telegram_token() -> str:
def _telegram_config() -> tuple[str, str]:
"""Return (bot token, chat id). Chat id reads channels.telegram.allowFrom[0], with a constant fallback."""
data = json.loads(CONFIG.read_text(encoding="utf-8"))
return data["channels"]["telegram"]["token"]
telegram = data["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id
def _send_telegram(text: str) -> None:
token = _telegram_token()
token, chat_id = _telegram_config()
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text}).encode()
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
resp.read()
@@ -75,7 +78,7 @@ def _due_at(conn, now: datetime) -> list[dict]:
""",
(since, until),
).fetchall()
return [dict(r) for r in rows]
return [{**dict(r), "schedule_type": "at"} for r in rows]
def _due_cron(conn, now: datetime) -> list[dict]:
@@ -107,6 +110,7 @@ def _due_cron(conn, now: datetime) -> list[dict]:
"text": row["text"],
"schedule_id": row["schedule_id"],
"fire_time": fire_iso,
"schedule_type": "cron",
})
return due
@@ -156,6 +160,7 @@ def _due_random(conn, now: datetime) -> list[dict]:
"text": row["text"],
"schedule_id": row["schedule_id"],
"fire_time": fire_iso,
"schedule_type": "random",
})
return due
@@ -189,14 +194,7 @@ def main() -> None:
rid = fire["id"]
sid = fire["schedule_id"]
ft = fire["fire_time"]
# Determine schedule_type from which query produced it
# We can infer: if 'schedule_id' came from schedule_at, it's 'at'
# But we don't have that info here. Let's look it up.
st = conn.execute(
"SELECT 'at' FROM schedule_at WHERE id = ? UNION ALL SELECT 'cron' FROM schedule_cron WHERE id = ? UNION ALL SELECT 'random' FROM schedule_random WHERE id = ?",
(sid, sid, sid),
).fetchone()
schedule_type = st[0] if st else "unknown"
schedule_type = fire["schedule_type"]
try:
_send_telegram(f"⏰ Reminder: {text}")