From 2340404dc44bf25163509ee1a3cbb060a2848202 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Thu, 18 Jun 2026 12:12:22 -0700 Subject: [PATCH] feat(motd): source banners from pilot-changelog feed-motd.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the message-of-the-day source off the bespoke pilot-motd repo and onto pilot-changelog's existing render pipeline. The daemon now polls feed-motd.json — the `scope: motd` per-scope output of the changelog — where each entry's `date` is the active UTC day and `title` is the banner text. - internal/motd: parse the pilot-changelog feed shape (entries[].title -> banner text); default feed URL -> pilot-changelog feed-motd.json. Selection, mirroring, UTC re-validation, and the CLI banner / important_update / info surfaces are all unchanged. - tests: parse the real feed-motd.json entry shape (extra fields ignored); updated fixtures to the new shape. - docs/motd.md, CHANGELOG [Unreleased]: note the source move. No user-visible behavior change; only the source feed and its shape move. Pairs with TeoSlayer/pilot-changelog (adds the motd scope). --- CHANGELOG.md | 12 ++++++++ docs/motd.md | 6 +++- internal/motd/motd.go | 25 ++++++++++------ internal/motd/motd_test.go | 61 +++++++++++++++++++++++++++++++------- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a400d21a..08d75547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ Reliable P2P data transfer across NAT. Tag intentionally held for review. - `send-file` reports `transport`, `sha256`, and `throughput_mbps`; adds `--timeout`. +### Changed +- **Message of the day now rides the pilot-changelog pipeline.** The daemon's + default MOTD source moved from the bespoke `pilot-motd` repo to + `pilot-changelog`'s `feed-motd.json` (the `scope: motd` per-scope output of + the existing changelog render pipeline). A MOTD is now authored as a + `scope: motd` changelog entry whose `date` is the UTC day it is active and + whose `title` is the banner text; motd entries are isolated from the human + changelog feeds (feed.json/RSS/site). No behavior change for users — the + banner, `important_update` field, and `motd` in `info` work exactly as + before; only the source feed and its shape changed. Override with + `--motd-feed-url` / `$PILOT_MOTD_URL` as before. (motd) + ### Fixed - **NAT traversal now actually establishes (and holds) a direct path.** The relay→direct upgrade sent a one-way probe that a stateful NAT/firewall diff --git a/docs/motd.md b/docs/motd.md index 38c2aaf6..024b7c4c 100644 --- a/docs/motd.md +++ b/docs/motd.md @@ -29,7 +29,11 @@ So the work is split: - The **daemon** is the only component that touches the network. A background loop (`motdPollLoop`) fetches the feed every `--motd-interval` (default 15m), selects the entry dated for the current UTC day, holds it in memory, - and **mirrors** it to `~/.pilot/motd.json`. + and **mirrors** it to `~/.pilot/motd.json`. The feed is the Pilot Protocol + changelog's message-of-the-day output (`feed-motd.json`): a `scope: motd` + per-scope feed where each entry's `date` is the active UTC day and its + `title` is the banner text. Banners are isolated from the human changelog + feeds, so they never appear as changelog news. - **`pilotctl`** reads only that local mirror — one file read — and re-validates the UTC day on read, so a stale mirror (e.g. the daemon was offline across midnight) never shows yesterday's message. diff --git a/internal/motd/motd.go b/internal/motd/motd.go index 5637c66c..451ded69 100644 --- a/internal/motd/motd.go +++ b/internal/motd/motd.go @@ -35,10 +35,13 @@ import ( const ( // DefaultFeedURL is the canonical message-of-the-day source: the raw - // contents of motd.json on the pilot-motd repo's default branch. A - // commit there propagates to every daemon on its next poll (subject to - // GitHub's raw CDN cache, typically a few minutes). - DefaultFeedURL = "https://raw.githubusercontent.com/pilot-protocol/pilot-motd/main/motd.json" + // contents of feed-motd.json on the pilot-changelog repo's default + // branch. That feed is the `scope: motd` per-scope output of the + // changelog render pipeline — each entry's `date` is the UTC day the + // banner is active and its `title` is the banner text. Publishing or + // clearing a motd entry there propagates to every daemon on its next + // poll (subject to GitHub's raw CDN cache, typically a few minutes). + DefaultFeedURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-changelog/main/feed-motd.json" // DefaultInterval is how often the daemon re-fetches the feed when no // interval is configured. @@ -54,17 +57,21 @@ const ( maxFeedBytes = 64 * 1024 ) -// Message is a single dated message-of-the-day entry. +// Message is a single dated message-of-the-day entry. It maps a +// pilot-changelog feed entry: the entry `date` is the UTC day the banner is +// active, and the entry `title` is the banner text. type Message struct { Date string `json:"date"` // UTC calendar day, "YYYY-MM-DD" - Text string `json:"text"` + Text string `json:"title"` ID string `json:"id,omitempty"` } -// Feed is the on-the-wire shape served at the feed URL. +// Feed is the on-the-wire shape served at the feed URL — the pilot-changelog +// per-scope feed (feed-motd.json). Only the fields the daemon needs are +// decoded; everything else (scope, visibility, body, excerpt, …) is ignored. type Feed struct { SchemaVersion int `json:"schema_version"` - Messages []Message `json:"messages"` + Entries []Message `json:"entries"` } // Mirror is the local materialized "variable" the CLI reads. It holds at @@ -129,7 +136,7 @@ func Parse(body []byte) (Feed, error) { // non-blank one wins — operators are expected to keep one per day. func SelectForToday(f Feed, now time.Time) (Message, bool) { today := DayKey(now) - for _, m := range f.Messages { + for _, m := range f.Entries { if strings.TrimSpace(m.Date) == today && strings.TrimSpace(m.Text) != "" { return m, true } diff --git a/internal/motd/motd_test.go b/internal/motd/motd_test.go index 8f0d0ec0..24a66bea 100644 --- a/internal/motd/motd_test.go +++ b/internal/motd/motd_test.go @@ -40,26 +40,26 @@ func TestParse(t *testing.T) { if err != nil { t.Fatalf("err: %v", err) } - if len(f.Messages) != 0 { - t.Fatalf("want 0 messages, got %d", len(f.Messages)) + if len(f.Entries) != 0 { + t.Fatalf("want 0 entries, got %d", len(f.Entries)) } }) t.Run("good feed", func(t *testing.T) { - f, err := Parse([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"hi"}]}`)) + f, err := Parse([]byte(`{"schema_version":1,"entries":[{"date":"2026-06-15","title":"hi"}]}`)) if err != nil { t.Fatalf("err: %v", err) } - if len(f.Messages) != 1 || f.Messages[0].Text != "hi" { + if len(f.Entries) != 1 || f.Entries[0].Text != "hi" { t.Fatalf("unexpected feed: %+v", f) } }) t.Run("unknown schema version rejected", func(t *testing.T) { - if _, err := Parse([]byte(`{"schema_version":99,"messages":[]}`)); err == nil { + if _, err := Parse([]byte(`{"schema_version":99,"entries":[]}`)); err == nil { t.Fatal("want error for schema_version 99") } }) t.Run("missing schema version tolerated", func(t *testing.T) { - if _, err := Parse([]byte(`{"messages":[]}`)); err != nil { + if _, err := Parse([]byte(`{"entries":[]}`)); err != nil { t.Fatalf("unexpected err: %v", err) } }) @@ -70,9 +70,50 @@ func TestParse(t *testing.T) { }) } +func TestParsePilotChangelogFeedShape(t *testing.T) { + // The real feed-motd.json from pilot-changelog carries the full changelog + // entry shape. The parser must pick up date+title and ignore the rest. + body := []byte(`{ + "schema_version": 1, + "latest_entry_date": "2026-06-18", + "window": "scope:motd", + "include_private": false, + "count": 1, + "entries": [ + { + "id": "2026-06-18-motd-catalogue", + "date": "2026-06-18", + "scope": "motd", + "visibility": "public", + "title": "To view our service agents catalogue, send a message to list-agents", + "flagged": false, + "links": [], + "ids": [], + "body": "Message-of-the-day banner active on 2026-06-18 (UTC).", + "excerpt": "Message-of-the-day banner active on 2026-06-18 (UTC)." + } + ] + }`) + f, err := Parse(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + now := mustTime(t, "2026-06-18T09:00:00Z") + m, ok := SelectForToday(f, now) + if !ok { + t.Fatal("expected an active message") + } + if m.Text != "To view our service agents catalogue, send a message to list-agents" { + t.Fatalf("text = %q (should come from the entry title)", m.Text) + } + if m.Date != "2026-06-18" || m.ID != "2026-06-18-motd-catalogue" { + t.Fatalf("date/id not parsed: %+v", m) + } +} + func TestSelectForToday(t *testing.T) { now := mustTime(t, "2026-06-15T12:00:00Z") - feed := Feed{SchemaVersion: 1, Messages: []Message{ + feed := Feed{SchemaVersion: 1, Entries: []Message{ {Date: "2026-06-14", Text: "yesterday"}, {Date: "2026-06-15", Text: "today wins"}, {Date: "2026-06-15", Text: "second today, ignored"}, @@ -84,13 +125,13 @@ func TestSelectForToday(t *testing.T) { } t.Run("no entry today", func(t *testing.T) { - _, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-14", Text: "x"}}}, now) + _, ok := SelectForToday(Feed{Entries: []Message{{Date: "2026-06-14", Text: "x"}}}, now) if ok { t.Fatal("want no active message") } }) t.Run("blank text skipped", func(t *testing.T) { - _, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-15", Text: " "}}}, now) + _, ok := SelectForToday(Feed{Entries: []Message{{Date: "2026-06-15", Text: " "}}}, now) if ok { t.Fatal("blank text should not be active") } @@ -170,7 +211,7 @@ func TestFetch(t *testing.T) { t.Run("serves and selects today", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"served"}]}`)) + w.Write([]byte(`{"schema_version":1,"entries":[{"date":"2026-06-15","title":"served"}]}`)) })) defer srv.Close() feed, err := Fetch(context.Background(), srv.Client(), srv.URL)