initial commit

This commit is contained in:
sakrecoer 2026-10-03 12:03:02 +02:00
commit ababe6ae36
8 changed files with 542 additions and 0 deletions

8
.gitignore.txt Normal file
View file

@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
events.db
publish.log
cron.log
build-constraints.txt
config.toml

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# nostr-calendar
Fetch events from an ICS + RSS feed and publish them as NIP-52 calendar
events (kind 31923) to Nostr relays, requesting inclusion in a kind 31924
calendar.
## Install
Clone this repository to `/opt/nostr-calendar`:
sudo git clone <repo-url> /opt/nostr-calendar
sudo chown -R "$USER:$USER" /opt/nostr-calendar
cd /opt/nostr-calendar
./install.sh
The installer creates a virtualenv, installs dependencies, and copies
`config.toml.example` to `config.toml`.
## Configure
Edit `config.toml` and set at minimum:
- `secret_key` — your Nostr secret key (`nsec1...` or hex)
- `relays` — relays to publish to
- `calendar.d`, `calendar.title`, `calendar.description` — your calendar
Then lock down the file:
chmod 600 config.toml
## Test
/opt/nostr-calendar/venv/bin/python /opt/nostr-calendar/publish.py
Check the output and `publish.log`. On the first run, all events are
published; subsequent runs only publish new or changed events (dedupe
is tracked in `events.db`).
## Schedule with cron
crontab -e
Add:
*/30 * * * * /opt/nostr-calendar/run.sh >> /opt/nostr-calendar/cron.log 2>&1
This runs every 30 minutes. Adjust the `*/30` to taste.
## Notes
- `config.toml` contains your secret key and is gitignored. Never commit it.
- The virtualenv is not relocatable — if you move the directory after
installing, recreate the venv by re-running `./install.sh`.
- The only place the absolute install path appears is the crontab entry.

1
build-constraints.txt Normal file
View file

@ -0,0 +1 @@
scikit-build-core<0.10

39
config.toml.example Normal file
View file

@ -0,0 +1,39 @@
# Copy this file to config.toml and fill in your secret key:
# cp config.toml.example config.toml
# chmod 600 config.toml
#
# config.toml is gitignored and must never be committed.
# Nostr secret key (nsec1... or 64-char hex).
secret_key = "nsec1_REPLACE_ME"
# Relays to publish to. Always prefixed with wss://
relays = [
"wss://basspistol.org",
"wss://nos.lol",
]
# Feed sources
ics_url = "https://do.basspistol.org/feed/ics?show_recurrent=true"
rss_url = "https://do.basspistol.org/feed/rss?show_recurrent=true"
# Only publish events that also appear in the RSS feed.
# This tracks the "announcement stream" rather than the full ICS backlog.
rss_only = true
# Local paths (relative to script directory)
db_path = "events.db"
log_path = "publish.log"
# Default end time offset when DTEND is missing (seconds)
default_duration = 3600
# Content template. {title}, {location}, {url} are substituted.
content_template = """{title}
📍 {location}
🔗 {url}"""
[calendar]
d = "basspistol-do"
title = "Basspistol Do 🗓️"
description = "Underground events promoted by the people, for the people. Ad-free and decentralized."

43
install.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
set -euo pipefail
# Resolve the directory this script lives in, regardless of cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Installing from: $SCRIPT_DIR"
# Pin the build backend for coincurve 20.0.0 (fixes Python 3.14 build).
echo 'scikit-build-core<0.10' > build-constraints.txt
# Create venv next to the script.
python3 -m venv venv
./venv/bin/pip install --upgrade pip
# Use the local build constraint. PIP_CONSTRAINT needs an absolute path.
PIP_CONSTRAINT="$SCRIPT_DIR/build-constraints.txt" \
PIP_BUILD_CONSTRAINT="$SCRIPT_DIR/build-constraints.txt" \
./venv/bin/pip install -r requirements.txt
# Seed config.toml from the example if it doesn't exist yet.
if [ ! -f config.toml ]; then
cp config.toml.example config.toml
chmod 600 config.toml
echo
echo "Created config.toml from example — edit it and set your nsec."
fi
chmod +x run.sh publish.py
echo
echo "Installed to $SCRIPT_DIR"
echo
echo "Next steps:"
echo " 1. Edit $SCRIPT_DIR/config.toml and set your nsec."
echo " 2. Test: $SCRIPT_DIR/venv/bin/python $SCRIPT_DIR/publish.py"
echo " 3. Add cron (every 30 min):"
echo
echo " crontab -e"
echo
echo " */30 * * * * $SCRIPT_DIR/run.sh >> $SCRIPT_DIR/cron.log 2>&1"
echo

386
publish.py Normal file
View file

@ -0,0 +1,386 @@
#!/usr/bin/env python3
"""
Fetch ICS + RSS events from do.basspistol.org and publish them as
NIP-52 calendar events (kind 31923) to Nostr relays, requesting
inclusion in a kind 31924 calendar.
Usage:
publish.py # normal run
publish.py --dry-run # log what would be published, no DB writes, no relay calls
"""
import hashlib
import logging
import re
import sqlite3
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import feedparser
import requests
from icalendar import Calendar
from pynostr.event import Event
from pynostr.key import PrivateKey
from pynostr.relay_manager import RelayManager
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_PATH = SCRIPT_DIR / "config.toml"
def load_config():
with open(CONFIG_PATH, "rb") as f:
return tomllib.load(f)
def setup_logging(log_path: Path):
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(log_path, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("nostr-calendar")
# ---------------------------------------------------------------------------
# Feed fetching / parsing
# ---------------------------------------------------------------------------
def fetch_text(url: str) -> str:
resp = requests.get(url, timeout=30, headers={"User-Agent": "nostr-calendar/1.0"})
resp.raise_for_status()
return resp.text
def parse_ics(text: str) -> list[dict]:
cal = Calendar.from_ical(text)
return [dict(c) for c in cal.walk("VEVENT")]
def parse_rss(text: str) -> dict:
feed = feedparser.parse(text)
by_url = {}
for entry in feed.entries:
link = entry.get("link", "")
if not link:
continue
image = ""
enclosures = entry.get("enclosures", [])
if enclosures:
image = enclosures[0].get("href", "")
if not image:
for l in entry.get("links", []):
if l.get("rel") == "enclosure":
image = l.get("href", "")
break
if not image:
m = re.search(r'<img[^>]+src="([^"]+)"', entry.get("description", ""))
if m:
image = m.group(1)
categories = [c.get("term", "") for c in entry.get("tags", []) if c.get("term")]
by_url[link] = {
"image": image,
"categories": categories,
"description": entry.get("description", ""),
}
return by_url
# ---------------------------------------------------------------------------
# ICS field helpers
# ---------------------------------------------------------------------------
def ics_str(component: dict, key: str) -> str:
val = component.get(key)
if val is None:
return ""
if hasattr(val, "to_ical"):
try:
return val.to_ical().decode("utf-8", errors="replace").strip()
except Exception:
pass
return str(val).strip()
def ics_dt(component: dict, key: str) -> int | None:
val = component.get(key)
if val is None:
return None
try:
dt = val.dt
if isinstance(dt, datetime):
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
return int(datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc).timestamp())
except Exception as e:
log.warning("Could not parse %s: %s", key, e)
return None
# ---------------------------------------------------------------------------
# Content / tags
# ---------------------------------------------------------------------------
def normalize_tag(tag: str) -> str:
tag = tag.strip().lower()
tag = re.sub(r"\s+", "-", tag)
tag = re.sub(r"[^\w\-]", "", tag)
return tag.strip("-")
def build_content(template: str, title: str, location: str, url: str) -> str:
return template.format(title=title, location=location, url=url)
def make_event_id(ics_url: str, uid: str) -> str:
m = re.match(r"^(\d+)@", uid)
if m:
return m.group(1)
return hashlib.sha256(uid.encode()).hexdigest()[:16]
def day_timestamps(start: int, end: int | None) -> list[int]:
days = [start // 86400]
if end is not None:
last = end // 86400
if last > days[0]:
days.extend(range(days[0] + 1, last + 1))
return days
# ---------------------------------------------------------------------------
# Dedupe
# ---------------------------------------------------------------------------
def init_db(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS published (
d_tag TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
event_id TEXT,
published_at INTEGER NOT NULL
)
"""
)
conn.commit()
return conn
def content_hash(*parts) -> str:
h = hashlib.sha256()
for p in parts:
h.update(str(p).encode("utf-8"))
h.update(b"\x00")
return h.hexdigest()
def already_published(conn, d_tag: str, chash: str) -> bool:
row = conn.execute(
"SELECT content_hash FROM published WHERE d_tag = ?", (d_tag,)
).fetchone()
return row is not None and row[0] == chash
def record_published(conn, d_tag: str, chash: str, event_id: str):
conn.execute(
"INSERT OR REPLACE INTO published (d_tag, content_hash, event_id, published_at) VALUES (?, ?, ?, ?)",
(d_tag, chash, event_id, int(time.time())),
)
conn.commit()
# ---------------------------------------------------------------------------
# Event construction
# ---------------------------------------------------------------------------
def make_calendar_event(cfg: dict) -> Event:
cal = cfg["calendar"]
return Event(
kind=31924,
content=cal["description"],
tags=[
["d", cal["d"]],
["title", cal["title"]],
],
)
def make_calendar_event_nostr(cfg, pubkey_hex, title, start, end, location, url,
image, tags, d_tag, duration, template) -> Event:
cal_coord = f"31924:{pubkey_hex}:{cfg['calendar']['d']}"
final_end = end if end is not None else start + duration
ev_tags = [
["d", d_tag],
["title", title],
["start", str(start)],
["end", str(final_end)],
["a", cal_coord],
]
if location:
ev_tags.append(["location", location])
if image:
ev_tags.append(["image", image])
for t in tags:
ev_tags.append(["t", t])
for day in day_timestamps(start, final_end):
ev_tags.append(["D", str(day)])
return Event(
kind=31923,
content=build_content(template, title, location, url),
tags=ev_tags,
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
dry_run = "--dry-run" in sys.argv
cfg = load_config()
setup_logging(SCRIPT_DIR / cfg.get("log_path", "publish.log"))
if dry_run:
log.info("=== DRY RUN — no DB writes, no relay publishing ===")
secret = cfg["secret_key"]
relays = cfg["relays"]
ics_url = cfg["ics_url"]
rss_url = cfg["rss_url"]
duration = int(cfg.get("default_duration", 3600))
template = cfg.get("content_template", "{title}\n📍 {location}\n🔗 {url}")
rss_only = bool(cfg.get("rss_only", False))
pk = PrivateKey.from_nsec(secret) if secret.startswith("nsec") else PrivateKey(bytes.fromhex(secret))
pubkey_hex = pk.public_key.hex()
log.info("Using pubkey %s", pubkey_hex)
log.info("Relays: %s", ", ".join(relays))
log.info("rss_only=%s", rss_only)
log.info("Fetching ICS …")
ics_text = fetch_text(ics_url)
log.info("Fetching RSS …")
rss_text = fetch_text(rss_url)
log.info("Parsing ICS …")
vevents = parse_ics(ics_text)
log.info("Parsing RSS …")
rss_map = parse_rss(rss_text)
log.info("Found %d ICS events, %d RSS entries", len(vevents), len(rss_map))
conn = init_db(SCRIPT_DIR / cfg.get("db_path", "events.db"))
to_publish = []
skipped = 0
skipped_no_rss = 0
for vev in vevents:
uid = ics_str(vev, "UID")
if not uid:
continue
title = ics_str(vev, "SUMMARY")
url = ics_str(vev, "URL")
location = ics_str(vev, "LOCATION")
start = ics_dt(vev, "DTSTART")
end = ics_dt(vev, "DTEND")
categories_ics = ics_str(vev, "CATEGORIES")
if start is None:
log.warning("Skipping %s — no DTSTART", uid)
continue
rss = rss_map.get(url, {})
if rss_only and not rss:
skipped_no_rss += 1
continue
image = rss.get("image", "")
rss_cats = rss.get("categories", [])
if categories_ics:
raw_tags = [c.strip() for c in categories_ics.split(",") if c.strip()]
else:
raw_tags = rss_cats
tags = [normalize_tag(t) for t in raw_tags if normalize_tag(t)]
d_tag = make_event_id(ics_url, uid)
chash = content_hash(title, start, end, location, url, image, ",".join(tags))
if dry_run:
log.info("DRY RUN %s: title=%r image=%r tags=%r start=%d end=%s",
d_tag, title, image, tags, start, end)
continue
if already_published(conn, d_tag, chash):
skipped += 1
continue
ev = make_calendar_event_nostr(
cfg, pubkey_hex, title, start, end, location, url,
image, tags, d_tag, duration, template,
)
to_publish.append((d_tag, chash, ev))
if dry_run:
log.info("DRY RUN complete. Would publish %d, skipped_no_rss=%d",
len([x for x in vevents]), skipped_no_rss)
return
log.info("Publishing %d new/changed events (skipped %d unchanged, %d without RSS)",
len(to_publish), skipped, skipped_no_rss)
if not to_publish:
log.info("Nothing to do.")
return
cal_ev = make_calendar_event(cfg)
cal_ev.sign(pk.hex())
relay_manager = RelayManager(timeout=6)
for r in relays:
relay_manager.add_relay(r)
relay_manager.publish_event(cal_ev)
log.info("Published calendar kind 31924 (d=%s)", cfg["calendar"]["d"])
for d_tag, chash, ev in to_publish:
ev.sign(pk.hex())
relay_manager.publish_event(ev)
log.info("Queued event %s — %s", d_tag, ev.content.split("\n", 1)[0])
relay_manager.run_sync()
time.sleep(2)
relay_manager.close_all_relay_connections()
for d_tag, chash, ev in to_publish:
record_published(conn, d_tag, chash, ev.id)
log.info("Done. %d events published.", len(to_publish))
if __name__ == "__main__":
main()

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
coincurve==20.0.0
feedparser>=6.0.0
icalendar>=5.0.0
requests>=2.28.0
pynostr>=0.6.0

6
run.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
# Resolve the directory this script lives in, regardless of cwd.
# Cron starts in $HOME, so the explicit cd matters.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" || exit 1
exec "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/publish.py"