diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 5163aa0..f5e2346 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -68,6 +68,7 @@ import ( "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mailbox" "github.com/profullstack/agentbbs/internal/mailu" + "github.com/profullstack/agentbbs/internal/motd" "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -238,6 +239,12 @@ func main() { } log.Info("sandbox", "mode", a.sandbox.Mode()) + // Shared Message of the Day from profullstack.com — shown on the hub (and on + // IRC via Ergo's MOTD). Cached + refreshed in the background so it never + // blocks session start. Override the source with AGENTBBS_MOTD_URL (empty + // disables remote fetch). + motd.Start(context.Background(), env("AGENTBBS_MOTD_URL", motd.DefaultURL), 30*time.Minute) + // Email confirmation endpoint (the link in the join@ verification mail). // Loopback only; Caddy reverse-proxies /verify to it. Separate from the // on-demand-TLS ask server above. @@ -396,15 +403,22 @@ var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11 func (a *app) hubMOTD(u auth.User) string { body := env("AGENTBBS_MOTD", "A terminal BBS for humans & AI agents.\nGames · IRC · News · a Linux pod · your own homepage.") + var s string if u.Kind == auth.Guest { - return "You're browsing as a guest.\n" + body + + s = "You're browsing as a guest.\n" + body + "\nssh join@" + a.host + " to claim a username, a pod & a homepage." + } else { + welcome := "Welcome back, " + u.Name + "." + if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 { + welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n) + } + s = welcome + "\n" + body } - welcome := "Welcome back, " + u.Name + "." - if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 { - welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n) + // Append the shared daily Message of the Day from profullstack.com, if loaded. + if m := motd.Current(); m != "" { + s += "\n\n" + m } - return welcome + "\n" + body + return s } // teaHandler builds the hub model for guests, members, and agents. diff --git a/deploy/ergo/refresh-motd.sh b/deploy/ergo/refresh-motd.sh new file mode 100755 index 0000000..0b1b297 --- /dev/null +++ b/deploy/ergo/refresh-motd.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# +# refresh-motd.sh — pull the shared Message of the Day from profullstack.com +# into Ergo's MOTD file and reload Ergo if it changed. setup.sh installs this to +# /usr/local/bin/ergo-refresh-motd and runs it from the ergo-motd.timer, so the +# IRC server's MOTD (shown on connect / via /MOTD) tracks profullstack.com/motd. +# +# Ergo resolves the `motd:` path in ircd.yaml relative to the config dir, so the +# file lives at $ERGO_CONF_DIR/ergo.motd (default /etc/ergo/ergo.motd). +set -euo pipefail + +MOTD_URL="${MOTD_URL:-https://profullstack.com/motd}" +ERGO_CONF_DIR="${ERGO_CONF_DIR:-/etc/ergo}" +DST="$ERGO_CONF_DIR/ergo.motd" + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +if curl -fsSL --max-time 10 "$MOTD_URL" -o "$tmp" && [ -s "$tmp" ]; then + if ! cmp -s "$tmp" "$DST"; then + install -m 0644 "$tmp" "$DST" + chown ergo:ergo "$DST" 2>/dev/null || true + echo "updated Ergo MOTD from $MOTD_URL" + # Ergo rehashes config (incl. MOTD) on SIGHUP (systemctl reload). + systemctl reload ergo 2>/dev/null || systemctl restart ergo 2>/dev/null || true + else + echo "Ergo MOTD already current" + fi +elif [ ! -s "$DST" ]; then + # First run and the source is unreachable — seed a minimal MOTD so Ergo has + # something to serve; the timer replaces it once profullstack.com is reachable. + printf '%s\n' "Welcome to AgentBBS IRC." > "$DST" + chown ergo:ergo "$DST" 2>/dev/null || true + echo "MOTD source unreachable; seeded placeholder" +else + echo "MOTD source unreachable; keeping existing MOTD" +fi diff --git a/internal/motd/motd.go b/internal/motd/motd.go new file mode 100644 index 0000000..a60c640 --- /dev/null +++ b/internal/motd/motd.go @@ -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() + } + } + }() +} diff --git a/setup.sh b/setup.sh index 7c63e25..2f969e7 100755 --- a/setup.sh +++ b/setup.sh @@ -859,6 +859,12 @@ if [ "$IRC" = "1" ]; then -keyout "$ERGO_DATA/tls/privkey.pem" -out "$ERGO_DATA/tls/fullchain.pem" \ -subj "/CN=${IRC_DOMAIN}" 2>/dev/null fi + # MOTD: pull the shared Message of the Day from profullstack.com into Ergo's + # MOTD file (shown on IRC connect / via /MOTD). The ergo-motd.timer keeps it + # fresh; Ergo reads it on start, so write it before the service comes up. + install -m 0755 "${SRC_DIR}/deploy/ergo/refresh-motd.sh" /usr/local/bin/ergo-refresh-motd + ERGO_CONF_DIR=/etc/ergo /usr/local/bin/ergo-refresh-motd || true + chown -R ergo:ergo "$ERGO_DATA" /etc/ergo # Initialize the datastore once. @@ -910,6 +916,29 @@ OnBootSec=5min OnUnitActiveSec=12h Persistent=true +[Install] +WantedBy=timers.target +UNIT + + # Hourly MOTD refresh from profullstack.com/motd (shared across all properties). + cat > /etc/systemd/system/ergo-motd.service < /etc/systemd/system/ergo-motd.timer </dev/null 2>&1 || true systemctl restart ergo systemctl enable --now ergo-certs.timer >/dev/null 2>&1 || true + systemctl enable --now ergo-motd.timer >/dev/null 2>&1 || true ufw allow 6697/tcp >/dev/null sleep 1 systemctl is-active --quiet ergo \ || warn "ergo failed to start — check: journalctl -u ergo -n50" else - systemctl disable --now ergo ergo-certs.timer >/dev/null 2>&1 || true + systemctl disable --now ergo ergo-certs.timer ergo-motd.timer >/dev/null 2>&1 || true fi # ---- 9c. News (Usenet/NNTP) server (co-located news.${DOMAIN}) --------------