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

View file

@ -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)
}
return welcome + "\n" + body
s = welcome + "\n" + body
}
// Append the shared daily Message of the Day from profullstack.com, if loaded.
if m := motd.Current(); m != "" {
s += "\n\n" + m
}
return s
}
// teaHandler builds the hub model for guests, members, and agents.

37
deploy/ergo/refresh-motd.sh Executable file
View file

@ -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

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()
}
}
}()
}

View file

@ -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 <<UNIT
[Unit]
Description=Refresh Ergo MOTD from profullstack.com for ${IRC_DOMAIN}
[Service]
Type=oneshot
Environment=ERGO_CONF_DIR=/etc/ergo
ExecStart=/usr/local/bin/ergo-refresh-motd
UNIT
cat > /etc/systemd/system/ergo-motd.timer <<UNIT
[Unit]
Description=Periodic Ergo MOTD refresh from profullstack.com
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
@ -918,12 +947,13 @@ UNIT
systemctl enable ergo >/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}) --------------