Wire shared MOTD into BBS hub and IRC

Use profullstack.com/motd as the shared Message of the Day across both
the SSH BBS hub and the Ergo IRC server.

- internal/motd: fetch + in-memory cache with background refresh (stdlib
  only); Current() never blocks session start, keeps last value on error.
  Source overridable via AGENTBBS_MOTD_URL.
- hub: append the daily MOTD below the existing welcome/onboarding text.
- IRC: deploy/ergo/refresh-motd.sh pulls /motd into Ergo's ergo.motd and
  rehashes; setup.sh installs it + an ergo-motd.timer (hourly) mirroring
  the ergo-certs timer, with a seeded fallback if the source is offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-25 11:51:06 +00:00
parent 63548bcbbe
commit fb7722eacc
4 changed files with 177 additions and 6 deletions

90
internal/motd/motd.go Normal file
View file

@ -0,0 +1,90 @@
// Package motd fetches the shared Message of the Day from profullstack.com and
// caches it in memory, refreshing in the background. The hub MOTD reads the
// cached value (never blocking session start); a failed fetch keeps the last
// known value. Set AGENTBBS_MOTD_URL to override the source (empty disables).
package motd
import (
"context"
"io"
"net/http"
"strings"
"sync"
"time"
)
// DefaultURL is the canonical shared MOTD endpoint (plain text, CORS-open).
const DefaultURL = "https://profullstack.com/motd"
var (
mu sync.RWMutex
cached string
)
// Current returns the most recently fetched MOTD, or "" if none has been
// fetched yet (or fetching is disabled/failing).
func Current() string {
mu.RLock()
defer mu.RUnlock()
return cached
}
func set(s string) {
mu.Lock()
cached = s
mu.Unlock()
}
func fetch(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", &httpError{resp.StatusCode}
}
// Cap the read; the MOTD is a short text blob.
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
type httpError struct{ code int }
func (e *httpError) Error() string { return "motd: unexpected status " + http.StatusText(e.code) }
// Start fetches the MOTD once (blocking, with a short timeout so a slow source
// can't stall boot for long) then refreshes every `every` until ctx is done.
// A url of "" disables fetching entirely.
func Start(ctx context.Context, url string, every time.Duration) {
if url == "" {
return
}
refresh := func() {
c, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if s, err := fetch(c, url); err == nil && s != "" {
set(s)
}
}
refresh()
go func() {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
refresh()
}
}
}()
}