WordPress-Termine als Nostr-Kalenderevents (kind:31923, NIP-52) veröffentlichen.
Holt Posts aus einer WordPress REST-API, mappt sie auf das NIP-52 Kalenderformat und publiziert sie auf einem Nostr-Relay. Läuft als GitHub Action (Cron alle 6 h) oder lokal mit Deno.
- Vollständige Pagination – holt alle Posts, nicht nur die erste Seite
- Korrekte Zeitzonenkonvertierung – WordPress-Lokalzeit (Europe/Berlin) → UTC-Timestamp
- HTML → Markdown – Content und Excerpt via Turndown
- Addressable Events –
d-Tag = WordPress-Permalink, Relay ersetzt automatisch ältere Versionen - Relay-Deduplizierung –
created_at=modified_gmtaus WordPress → unveränderte Posts werden vom Relay automatisch ignoriert (kein unnötiger Write) - Kein Republish ohne inhaltliche Änderung – vor dem Publish wird der Event-Payload (Tags + Content) mit der Bestandsversion auf jedem Relay verglichen; ein reiner
modified_gmt-Bump (WP-Bulk-Edit, Plugin-Save) löst kein Republish aus - Vergangene Termine werden übersprungen – Termine, deren Start/Ende bereits vorbei ist, werden nicht (erneut) publiziert und landen damit nicht als "frisch" in Client-Timelines
- Single-Connection – alle Events über eine einzige WebSocket-Verbindung (statt pro Event eine neue)
- Dry-Run-Modus – Events anzeigen ohne zu posten
- Inspect-Tool – einzelne Posts debuggen mit Vergleichstabelle
- Cleanup-Tool – alle Events per NIP-09 vom Relay löschen (für sauberen Neuaufbau)
Deno ≥ 2.x installieren:
# macOS
brew install deno
# Linux / Windows
curl -fsSL https://deno.land/install.sh | shdeno task dry-runNOSTR_PRIVATE_KEY=nsec1… deno task startdeno task inspect # erster Post
WP_PAGE=3 deno task inspect # erster Post von Seite 3# Dry-Run – zeigt was gelöscht würde
NOSTR_PRIVATE_KEY=nsec1… DRY_RUN=true deno task cleanup-dry
# Live – löscht alle kind:31923-Events per NIP-09
NOSTR_PRIVATE_KEY=nsec1… deno task cleanupHinweis zu strfry: NIP-09 Delete-Events werden von strfry persistent gespeichert. Nach einem Cleanup lehnt strfry Neu-Publikationen für gelöschte Adressen ab. Für einen sauberen Neuaufbau müssen die Delete-Events (kind:5) direkt auf dem Relay-Server entfernt werden – siehe docs/strfry-cleanup.md.
| Variable | Pflicht | Standard | Beschreibung |
|---|---|---|---|
NOSTR_PRIVATE_KEY |
Live-Modus ✅ | – | nsec1… oder 64-stellige Hex-Zeichenkette |
DRY_RUN |
– | false |
true → nur anzeigen, nicht posten |
FORCE_REPUBLISH |
– | false |
true → created_at = now, ersetzt rückwirkend alle Events einmalig |
SYNC_MODE |
– | calendar |
calendar (kind:31923 Termine) oder article (kind:30023 Long-Form) |
WP_API_URL |
– | https://relilab.org/wp-json/wp/v2/posts |
WordPress REST-API-Endpunkt |
WP_CATEGORY |
– | 176 |
WordPress-Kategorie-ID (Termine: 176; Lernmodule: 6) |
NOSTR_RELAYS |
– | 4 Relays (siehe Workflow) | Komma-separierte Liste von Ziel-Relays. Singular NOSTR_RELAY wird zusätzlich akzeptiert (rückwärtskompatibel). |
EXTRA_HASHTAGS |
– | "" (Workflow: relilab) |
Komma-separierte Hashtag-Liste, wird jedem Event als t-Tag angehängt, falls nicht ohnehin aus WordPress-Tags vorhanden. Case-insensitive Dedup. |
COMMUNITY_NPUBS |
– | "" (Workflow: relilab-npub) |
Komma-separierte Liste von Community-Pubkeys (npub1… oder Hex), die als h-Tag (Communikey-Spec) an jedes Event angehängt werden. |
Beim Start fragt das Script jedes konfigurierte Relay nach dem neuesten
eigenen Event des relevanten Kinds (31923 für Calendar, 30023 für Article)
und nimmt das Minimum der created_at-Werte als Cutoff. Die WordPress-Query
filtert dann mit modified_after, sodass nur seitdem geänderte Posts
abgerufen und veröffentlicht werden.
Wann passiert ein Vollsync?
FORCE_REPUBLISH=trueist gesetzt- Mindestens ein Relay liefert kein eigenes Event (leeres Relay, neu hinzugefügt)
- Mindestens ein Relay-REQ schlägt fehl oder läuft in den Timeout (5 s)
Schutz vor Timeline-Spam (zwei unabhängige Guards):
- Vergangene Termine:
mapPostToCalendarEvent()überspringt Posts, deren Termin (Ende, ersatzweise Start) bereits vorbei ist. WP-Edits an alten Posts (z. B. Saison-Aufräumen) bumpenmodified_gmt— ohne diesen Filter würden längst gelaufene Termine mit frischemcreated_aterneut oben in Client-Timelines auftauchen. Laufende Termine bleiben synchronisiert. - Unveränderter Payload: Vor dem Publish holt das Script pro Relay die
Bestandsversion jedes eigenen Events (d-Tag → neueste Version) und
vergleicht Tags + Content (ohne
created_at). Publiziert wird nur auf Relays, denen das Event fehlt oder deren Version inhaltlich abweicht. Hält ein Relay bereits eine neuere Version mit anderem Payload (z. B. aus einem früherenFORCE_REPUBLISH), wirdcreated_atdarüber angehoben, damit das Replace nicht mit „have newer" abgelehnt wird. BeiFORCE_REPUBLISH=trueist der Vergleich deaktiviert.
Was tun bei Mapping-Änderungen?
Wenn der Mapping-Code (Tags, Content-Format, Hashtags) geändert wird, ändert
sich modified_gmt in WordPress nicht — der Filter würde alle Bestandsposts
ignorieren. Dann einmal mit FORCE_REPUBLISH=true per workflow_dispatch
triggern: das holt alle Posts und ersetzt die Events mit created_at = now.
Jedes Event wird vor dem Publish um zwei optionale Tag-Gruppen ergänzt:
t-Tags (Hashtags) ausEXTRA_HASHTAGS. Bestehende Hashtags aus den WordPress-Tags werden case-insensitive dedupliziert (Relilabaus WP blocktrelilabaus der Konfig).h-Tags (Community-Zuordnung nach Communikey-Spec) ausCOMMUNITY_NPUBS. npubs werden beim Start zu Hex aufgelöst; pro Community einh-Tag.
Beide Defaults leben in .github/workflows/sync.yml (relilab-spezifisch) —
der Code-Default ist leer. Forks setzen entweder die Repo-Variablen
WP_EXTRA_HASHTAGS / WP_COMMUNITY_NPUBS oder editieren den Workflow.
Hinweis zur Sichtbarkeit: Die Communikey-Spec setzt voraus, dass das
Ziel-Relay nicht im enforced-Modus läuft, oder dass der Sync-npub auf der
relevanten Profile-List der Community steht. Bei enforced-Relays ohne
Eintrag werden Events sonst stumm verworfen. Aktuell trifft das auf
relay-rpi.edufeed.org nicht zu.
Altbestand: Bestehende Events erhalten den neuen Tag erst, wenn der
zugehörige WordPress-Post wieder gespeichert wird (modified_gmt steigt).
Für sofortigen Rewrite aller Events cleanup-relay.ts + Re-Sync nutzen.
Es laufen zwei voneinander unabhängige Workflows alle 6 Stunden:
.github/workflows/sync.yml— Termine-Sync (SYNC_MODE=calendar,WP_CATEGORY=176, kind:31923). Cron-Offset::00..github/workflows/sync-articles.yml— Article-Sync (SYNC_MODE=article,WP_CATEGORY=6Lernmodule, kind:30023 Long-Form). Cron-Offset::30.
Beide nutzen denselben Sync-npub (NOSTR_PRIVATE_KEY-Secret), unterscheiden
sich nur durch Env-Variablen.
- Secret anlegen: Repository → Settings → Secrets → Actions →
NOSTR_PRIVATE_KEY - Optional – Variables:
WP_API_URL,WP_CATEGORY(Termine),WP_ARTICLE_CATEGORY(Beiträge),WP_NOSTR_RELAYS,WP_EXTRA_HASHTAGS,WP_COMMUNITY_NPUBSals Repository-Variables setzen, um die Defaults zu überschreiben - Manueller Test: Actions → „WordPress → Nostr Sync" / „WordPress → Nostr Article Sync" → „Run workflow" → Dry Run =
true - Live schalten: Workflow erneut starten mit Dry Run =
false
Beide Cron-Jobs laufen automatisch im Live-Modus (DRY_RUN=false).
Long-Form-Articles erhalten am Anfang des Markdown-Contents einen Header-Block, der WP-Autor*in und Quelle sichtbar macht:
> Erstellt von: [Corinna Ullmann](https://relilab.org/author/colibri/)
> Veröffentlicht auf [relilab.org](https://relilab.org/lernmodul-test/)
(eigentlicher Content folgt …)Solange keine eigenen Autoren-npubs existieren, signiert der Bot-npub. Die Zuschreibung im Text bleibt sichtbar und kann später durch echte pubkey-Zuordnung ersetzt werden.
wp-to-nostr/
├── wp-to-nostr.ts # Haupt-Sync-Script
├── inspect-mapping.ts # Debug: einzelnen Post inspizieren
├── cleanup-relay.ts # NIP-09: alle Events vom Relay löschen
├── deno.json # Tasks & Import-Map
├── .github/workflows/sync.yml # GitHub Actions Workflow
├── docs/
│ └── nostr-kind-31923.md # NIP-52 Mapping-Dokumentation
├── LICENSE
└── README.md
| WordPress | → Nostr kind:31923 |
|---|---|
post.link |
d-Tag + r-Tag |
title.rendered |
title-Tag |
acf.relilab_startdate |
start-Tag (UTC Unix-Timestamp) |
acf.relilab_enddate |
end-Tag (UTC Unix-Timestamp) |
| (fest: Europe/Berlin) | start_tzid / end_tzid |
excerpt.rendered |
summary-Tag (Markdown) |
content.rendered |
content (Markdown) |
acf.relilab_custom_zoom_link |
location-Tag |
featured_image_urls_v2.thumbnail |
image-Tag |
taxonomy_info.post_tag[].label |
t-Tags |
modified_gmt |
created_at (Relay-Deduplizierung) |
created_at wird auf den modified_gmt-Wert aus WordPress gesetzt (Unix-Timestamp).
Da kind:31923 ein adressierbares ersetzbares Event ist (NIP-33), gilt:
- Post unverändert →
modified_gmtgleich →created_atgleich → Relay ignoriert das Event - Post bearbeitet →
modified_gmtsteigt →created_athöher → Relay ersetzt das Event
Für sehr alte Posts (deren modified_gmt vor 2025 liegt) greift ein fester Floor-Wert
(2025-01-01T00:00:00Z), da viele Relays Events mit zu altem created_at ablehnen.
Detaillierte Spezifikation: docs/nostr-kind-31923.md
WP_API_URLauf deinen Endpunkt setzenWP_CATEGORYanpassen (oder Parameter entfernen)- ACF-Feldnamen in
mapPostToNostrEvent()an dein Schema anpassen - Zeitzone in
wpDateToUnix()und den_tzid-Tags ändern falls nötig