Skip to content

Commit 6beeb91

Browse files
AlexgodorojaAlex Godoroja
andauthored
feat(motd): source banners from pilot-changelog feed-motd.json (#285)
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). Co-authored-by: Alex Godoroja <alex@vulturelabs.io>
1 parent 9bd9e09 commit 6beeb91

4 files changed

Lines changed: 84 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ Reliable P2P data transfer across NAT. Tag intentionally held for review.
2626
- `send-file` reports `transport`, `sha256`, and `throughput_mbps`; adds
2727
`--timeout`.
2828

29+
### Changed
30+
- **Message of the day now rides the pilot-changelog pipeline.** The daemon's
31+
default MOTD source moved from the bespoke `pilot-motd` repo to
32+
`pilot-changelog`'s `feed-motd.json` (the `scope: motd` per-scope output of
33+
the existing changelog render pipeline). A MOTD is now authored as a
34+
`scope: motd` changelog entry whose `date` is the UTC day it is active and
35+
whose `title` is the banner text; motd entries are isolated from the human
36+
changelog feeds (feed.json/RSS/site). No behavior change for users — the
37+
banner, `important_update` field, and `motd` in `info` work exactly as
38+
before; only the source feed and its shape changed. Override with
39+
`--motd-feed-url` / `$PILOT_MOTD_URL` as before. (motd)
40+
2941
### Fixed
3042
- **NAT traversal now actually establishes (and holds) a direct path.** The
3143
relay→direct upgrade sent a one-way probe that a stateful NAT/firewall

docs/motd.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ So the work is split:
2929
- The **daemon** is the only component that touches the network. A background
3030
loop (`motdPollLoop`) fetches the feed every `--motd-interval` (default
3131
15m), selects the entry dated for the current UTC day, holds it in memory,
32-
and **mirrors** it to `~/.pilot/motd.json`.
32+
and **mirrors** it to `~/.pilot/motd.json`. The feed is the Pilot Protocol
33+
changelog's message-of-the-day output (`feed-motd.json`): a `scope: motd`
34+
per-scope feed where each entry's `date` is the active UTC day and its
35+
`title` is the banner text. Banners are isolated from the human changelog
36+
feeds, so they never appear as changelog news.
3337
- **`pilotctl`** reads only that local mirror — one file read — and
3438
re-validates the UTC day on read, so a stale mirror (e.g. the daemon was
3539
offline across midnight) never shows yesterday's message.

internal/motd/motd.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ import (
3535

3636
const (
3737
// DefaultFeedURL is the canonical message-of-the-day source: the raw
38-
// contents of motd.json on the pilot-motd repo's default branch. A
39-
// commit there propagates to every daemon on its next poll (subject to
40-
// GitHub's raw CDN cache, typically a few minutes).
41-
DefaultFeedURL = "https://raw.githubusercontent.com/pilot-protocol/pilot-motd/main/motd.json"
38+
// contents of feed-motd.json on the pilot-changelog repo's default
39+
// branch. That feed is the `scope: motd` per-scope output of the
40+
// changelog render pipeline — each entry's `date` is the UTC day the
41+
// banner is active and its `title` is the banner text. Publishing or
42+
// clearing a motd entry there propagates to every daemon on its next
43+
// poll (subject to GitHub's raw CDN cache, typically a few minutes).
44+
DefaultFeedURL = "https://raw.githubusercontent.com/TeoSlayer/pilot-changelog/main/feed-motd.json"
4245

4346
// DefaultInterval is how often the daemon re-fetches the feed when no
4447
// interval is configured.
@@ -54,17 +57,21 @@ const (
5457
maxFeedBytes = 64 * 1024
5558
)
5659

57-
// Message is a single dated message-of-the-day entry.
60+
// Message is a single dated message-of-the-day entry. It maps a
61+
// pilot-changelog feed entry: the entry `date` is the UTC day the banner is
62+
// active, and the entry `title` is the banner text.
5863
type Message struct {
5964
Date string `json:"date"` // UTC calendar day, "YYYY-MM-DD"
60-
Text string `json:"text"`
65+
Text string `json:"title"`
6166
ID string `json:"id,omitempty"`
6267
}
6368

64-
// Feed is the on-the-wire shape served at the feed URL.
69+
// Feed is the on-the-wire shape served at the feed URL — the pilot-changelog
70+
// per-scope feed (feed-motd.json). Only the fields the daemon needs are
71+
// decoded; everything else (scope, visibility, body, excerpt, …) is ignored.
6572
type Feed struct {
6673
SchemaVersion int `json:"schema_version"`
67-
Messages []Message `json:"messages"`
74+
Entries []Message `json:"entries"`
6875
}
6976

7077
// Mirror is the local materialized "variable" the CLI reads. It holds at
@@ -129,7 +136,7 @@ func Parse(body []byte) (Feed, error) {
129136
// non-blank one wins — operators are expected to keep one per day.
130137
func SelectForToday(f Feed, now time.Time) (Message, bool) {
131138
today := DayKey(now)
132-
for _, m := range f.Messages {
139+
for _, m := range f.Entries {
133140
if strings.TrimSpace(m.Date) == today && strings.TrimSpace(m.Text) != "" {
134141
return m, true
135142
}

internal/motd/motd_test.go

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,26 @@ func TestParse(t *testing.T) {
4040
if err != nil {
4141
t.Fatalf("err: %v", err)
4242
}
43-
if len(f.Messages) != 0 {
44-
t.Fatalf("want 0 messages, got %d", len(f.Messages))
43+
if len(f.Entries) != 0 {
44+
t.Fatalf("want 0 entries, got %d", len(f.Entries))
4545
}
4646
})
4747
t.Run("good feed", func(t *testing.T) {
48-
f, err := Parse([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"hi"}]}`))
48+
f, err := Parse([]byte(`{"schema_version":1,"entries":[{"date":"2026-06-15","title":"hi"}]}`))
4949
if err != nil {
5050
t.Fatalf("err: %v", err)
5151
}
52-
if len(f.Messages) != 1 || f.Messages[0].Text != "hi" {
52+
if len(f.Entries) != 1 || f.Entries[0].Text != "hi" {
5353
t.Fatalf("unexpected feed: %+v", f)
5454
}
5555
})
5656
t.Run("unknown schema version rejected", func(t *testing.T) {
57-
if _, err := Parse([]byte(`{"schema_version":99,"messages":[]}`)); err == nil {
57+
if _, err := Parse([]byte(`{"schema_version":99,"entries":[]}`)); err == nil {
5858
t.Fatal("want error for schema_version 99")
5959
}
6060
})
6161
t.Run("missing schema version tolerated", func(t *testing.T) {
62-
if _, err := Parse([]byte(`{"messages":[]}`)); err != nil {
62+
if _, err := Parse([]byte(`{"entries":[]}`)); err != nil {
6363
t.Fatalf("unexpected err: %v", err)
6464
}
6565
})
@@ -70,9 +70,50 @@ func TestParse(t *testing.T) {
7070
})
7171
}
7272

73+
func TestParsePilotChangelogFeedShape(t *testing.T) {
74+
// The real feed-motd.json from pilot-changelog carries the full changelog
75+
// entry shape. The parser must pick up date+title and ignore the rest.
76+
body := []byte(`{
77+
"schema_version": 1,
78+
"latest_entry_date": "2026-06-18",
79+
"window": "scope:motd",
80+
"include_private": false,
81+
"count": 1,
82+
"entries": [
83+
{
84+
"id": "2026-06-18-motd-catalogue",
85+
"date": "2026-06-18",
86+
"scope": "motd",
87+
"visibility": "public",
88+
"title": "To view our service agents catalogue, send a message to list-agents",
89+
"flagged": false,
90+
"links": [],
91+
"ids": [],
92+
"body": "Message-of-the-day banner active on 2026-06-18 (UTC).",
93+
"excerpt": "Message-of-the-day banner active on 2026-06-18 (UTC)."
94+
}
95+
]
96+
}`)
97+
f, err := Parse(body)
98+
if err != nil {
99+
t.Fatalf("parse: %v", err)
100+
}
101+
now := mustTime(t, "2026-06-18T09:00:00Z")
102+
m, ok := SelectForToday(f, now)
103+
if !ok {
104+
t.Fatal("expected an active message")
105+
}
106+
if m.Text != "To view our service agents catalogue, send a message to list-agents" {
107+
t.Fatalf("text = %q (should come from the entry title)", m.Text)
108+
}
109+
if m.Date != "2026-06-18" || m.ID != "2026-06-18-motd-catalogue" {
110+
t.Fatalf("date/id not parsed: %+v", m)
111+
}
112+
}
113+
73114
func TestSelectForToday(t *testing.T) {
74115
now := mustTime(t, "2026-06-15T12:00:00Z")
75-
feed := Feed{SchemaVersion: 1, Messages: []Message{
116+
feed := Feed{SchemaVersion: 1, Entries: []Message{
76117
{Date: "2026-06-14", Text: "yesterday"},
77118
{Date: "2026-06-15", Text: "today wins"},
78119
{Date: "2026-06-15", Text: "second today, ignored"},
@@ -84,13 +125,13 @@ func TestSelectForToday(t *testing.T) {
84125
}
85126

86127
t.Run("no entry today", func(t *testing.T) {
87-
_, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-14", Text: "x"}}}, now)
128+
_, ok := SelectForToday(Feed{Entries: []Message{{Date: "2026-06-14", Text: "x"}}}, now)
88129
if ok {
89130
t.Fatal("want no active message")
90131
}
91132
})
92133
t.Run("blank text skipped", func(t *testing.T) {
93-
_, ok := SelectForToday(Feed{Messages: []Message{{Date: "2026-06-15", Text: " "}}}, now)
134+
_, ok := SelectForToday(Feed{Entries: []Message{{Date: "2026-06-15", Text: " "}}}, now)
94135
if ok {
95136
t.Fatal("blank text should not be active")
96137
}
@@ -170,7 +211,7 @@ func TestFetch(t *testing.T) {
170211

171212
t.Run("serves and selects today", func(t *testing.T) {
172213
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173-
w.Write([]byte(`{"schema_version":1,"messages":[{"date":"2026-06-15","text":"served"}]}`))
214+
w.Write([]byte(`{"schema_version":1,"entries":[{"date":"2026-06-15","title":"served"}]}`))
174215
}))
175216
defer srv.Close()
176217
feed, err := Fetch(context.Background(), srv.Client(), srv.URL)

0 commit comments

Comments
 (0)