From 3b8e664753c961034c4d413243657e98af31e972 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 14:04:45 +0000 Subject: [PATCH 1/2] fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go linker's peak memory OOM-killed the build — and with it the sshd serving the deploy session — surfacing as "Connection closed by remote host" (exit 255). It was flaky because it tracked momentary memory pressure from the co-resident ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on near-identical code). Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain download when SKIP_BUILD=1. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy.yml | 74 +++++++++++++++++++++++++++++++++--- setup.sh | 5 ++- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6967ea..bf2b90b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,18 @@ name: deploy # Fully autonomous, idempotent deploy. On every push to main (or manual -# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the -# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go -# binaries, and restarts the agentbbs service that answers -# `ssh join@bbs.profullstack.com`. Re-running is always safe. +# dispatch) this builds the Go binaries ON THE RUNNER (which has plenty of +# RAM), ships them to the bbs.profullstack.com droplet, and re-runs the +# idempotent provisioner (setup.sh) with SKIP_BUILD=1 so the tiny droplet +# never has to compile. setup.sh still pulls origin, refreshes config/assets, +# and restarts the agentbbs service that answers `ssh join@bbs.profullstack.com`. +# Re-running is always safe. +# +# Why build on the runner: the droplet is a ~458MB box also running ergo, +# forgejo, tor, podman and the live agentbbs. The Go linker's peak memory was +# OOM-killing the build — and with it the sshd serving the deploy session, +# surfacing as "Connection closed by remote host" (exit 255). Compiling on the +# 16GB runner removes that failure mode entirely. # # Required repo secrets (Settings -> Secrets and variables -> Actions): # DEPLOY_SSH_KEY private key whose public half is in the droplet admin @@ -30,6 +38,8 @@ jobs: deploy: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} @@ -43,7 +53,55 @@ jobs: chmod 600 ~/.ssh/id_deploy ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null - - name: Provision / redeploy (idempotent) + - name: Detect droplet architecture + id: arch + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')" + case "$uname_m" in + x86_64|amd64) goarch=amd64 ;; + aarch64|arm64) goarch=arm64 ;; + *) echo "::error::unsupported droplet arch '$uname_m'"; exit 1 ;; + esac + echo "goarch=$goarch" >> "$GITHUB_OUTPUT" + echo "::notice::droplet arch $uname_m -> GOARCH=$goarch" + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build binaries (on the runner, not the droplet) + env: + GOOS: linux + GOARCH: ${{ steps.arch.outputs.goarch }} + CGO_ENABLED: '0' # pure-Go (modernc sqlite) — static, portable binary + run: | + mkdir -p dist + go build -trimpath -o dist/agentbbs ./cmd/agentbbs + go build -trimpath -o dist/ascii-live ./cmd/ascii-live + file dist/* || true + + - name: Ship binaries to the droplet + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} + run: | + # scp can only name one remote target; copy each binary explicitly. + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs" + scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live" + + - name: Provision / redeploy (idempotent, SKIP_BUILD=1) env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} @@ -76,7 +134,11 @@ jobs: fi git -C "$SRC" fetch --depth 1 origin "$BRANCH" git -C "$SRC" reset --hard "origin/$BRANCH" - exec env BRANCH="$BRANCH" \ + # Install the runner-built binaries, then tell setup.sh not to compile. + install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs + install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live + rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live + exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ diff --git a/setup.sh b/setup.sh index a440d0a..59b497c 100755 --- a/setup.sh +++ b/setup.sh @@ -119,8 +119,11 @@ if ! command -v yt-dlp >/dev/null; then fi # ---- 2. Go toolchain (system go is too old; pin GO_VERSION) ----------------- +# Skipped entirely when SKIP_BUILD=1: the CI deploy builds the binaries on the +# runner and ships them, so the droplet needs no Go toolchain at all. GO_ROOT="/usr/local/go" -if [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then +if [ "$SKIP_BUILD" != "1" ] && \ + [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then log "installing Go ${GO_VERSION}" tmp="$(mktemp -d)" curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \ From 580e85431fba3339b13be0e2a38395255cb2abd9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 14:14:44 +0000 Subject: [PATCH 2/2] feat(members): member directory + store-and-forward messaging A members-only hub plugin (the BBS "who") plus user-to-user messaging: - internal/store: messages table + SendMessage/Inbox/UnreadCount/MarkRead, and OnlineUsers (open sessions) for presence. MarkRead is recipient-scoped so a member can only clear their own mail. - plugins/members: directory with online dots + last-seen, a finger-style profile view, a minimal compose box, and an inbox that marks read on open. - ssh msg@host [text]: scriptable CLI to leave a note (body from args or stdin), mirroring the existing finger route; "msg"/"message" are reserved. - hub: "N unread" badge on login (hubMOTD). plugin.Context gains Host for member homepage URLs. Extends the existing finger@ behavior (ssh @host) rather than replacing it. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 71 ++++- internal/auth/auth.go | 10 +- internal/plugin/plugin.go | 3 + internal/store/store.go | 104 +++++++ internal/store/store_messages_test.go | 82 +++++ plugins/members/members.go | 421 ++++++++++++++++++++++++++ 6 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 internal/store/store_messages_test.go create mode 100644 plugins/members/members.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 6b3506e..3bf9214 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -8,6 +8,8 @@ // emailed code, then offers $99 Founding Lifetime (CoinPay) // ssh pod@host your personal Linux pod — free for verified members // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) +// ssh @host (from another account) prints a finger card for that member +// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin // ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, // agent-vs-agent (also on wss://host/play). See docs/agentgames.md @@ -72,6 +74,7 @@ import ( "github.com/profullstack/agentbbs/plugins/about" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + "github.com/profullstack/agentbbs/plugins/members" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) @@ -171,7 +174,7 @@ func main() { a.mm = games.NewMatchmaker(a.gamesReg, a.st, time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second, time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) - a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}} + a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}} // Custom domains: maintain the symlink farm Caddy serves and answer its // on-demand-TLS "ask" query so certs are only issued for mapped domains. @@ -318,6 +321,8 @@ func (a *app) router() wish.Middleware { a.handleNews(s) case auth.IsMailName(user): a.handleMail(s) + case auth.IsMsgName(user): + a.handleMsg(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -345,7 +350,11 @@ func (a *app) hubMOTD(u auth.User) string { return "You're browsing as a guest.\n" + body + "\nssh join@" + a.host + " to claim a username, a pod & a homepage." } - return "Welcome back, " + u.Name + ".\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) + } + return welcome + "\n" + body } // teaHandler builds the hub model for guests, members, and agents. @@ -394,7 +403,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub") go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }() - ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets} + ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host} if u.Kind != auth.Guest { ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name) _ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755) @@ -1421,6 +1430,62 @@ func (a *app) handleChat(s ssh.Session) { } } +// handleMsg is the member-to-member messaging route: `ssh msg@host [text]` +// leaves a note in 's BBS inbox. The body is the remaining args, or stdin +// when none are given (so `echo hi | ssh msg@host bob` works). Members only; +// the recipient reads it in the hub's Members ▸ inbox. +func (a *app) handleMsg(s ssh.Session) { + fp := auth.Fingerprint(s.PublicKey()) + if fp == "" { + wish.Println(s, "msg@ needs your registered SSH key. New here? ssh join@"+a.host) + _ = s.Exit(1) + return + } + from, found, err := a.st.UserByFingerprint(fp) + if err != nil || !found { + wish.Println(s, "key not registered — run: ssh join@"+a.host) + _ = s.Exit(1) + return + } + args := s.Command() + if len(args) == 0 { + wish.Println(s, "usage: ssh msg@"+a.host+" [message] (or pipe the message on stdin)") + _ = s.Exit(1) + return + } + to := strings.ToLower(args[0]) + recipient, ok, err := a.st.UserByName(to) + if err != nil || !ok { + wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).") + _ = s.Exit(1) + return + } + if recipient.Name == from.Name { + wish.Println(s, "you can't message yourself.") + _ = s.Exit(1) + return + } + body := strings.TrimSpace(strings.Join(args[1:], " ")) + if body == "" { + // No inline text — read the message from stdin (piped, or typed then ^D). + b, _ := io.ReadAll(io.LimitReader(s, 64*1024)) + body = strings.TrimSpace(string(b)) + } + if body == "" { + wish.Println(s, "empty message — nothing sent.") + _ = s.Exit(1) + return + } + if err := a.st.SendMessage(from.Name, recipient.Name, body); err != nil { + wish.Println(s, "could not send: "+err.Error()) + _ = s.Exit(1) + return + } + _, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg") + wish.Println(s, "✓ message left for "+recipient.Name+" — they'll see it in Members ▸ inbox.") + _ = s.Exit(0) +} + // handleFinger prints a classic finger card when someone ssh's to an // existing account name that isn't their own (e.g. ssh anthony@host). // Returns false when the route should fall through to the hub. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ca5c531..026a57b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -103,6 +103,13 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] } // IsMailName reports whether the SSH username requests the AgentMail client. func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } +// MsgNames route a member-to-member message: `ssh msg@host ` leaves a +// note in the recipient's BBS inbox (store-and-forward, see the Members plugin). +var MsgNames = map[string]bool{"msg": true, "message": true} + +// IsMsgName reports whether the SSH username requests the messaging route. +func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] } + // systemReserved are names that don't drive an SSH route but would still // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. @@ -119,7 +126,8 @@ var systemReserved = map[string]bool{ func IsReservedName(name string) bool { n := strings.ToLower(name) if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || - TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] { + TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || + MsgNames[n] || systemReserved[n] { return true } return strings.HasPrefix(n, "video-") // video- call routes diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 4f32b24..7bbe300 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -22,6 +22,9 @@ type Context struct { DataDir string // AssetsDir is the read-only platform assets tree (wads, binaries). AssetsDir string + // Host is the BBS hostname (e.g. bbs.profullstack.com), for building + // member homepage URLs (https://Host/~name) and similar links. + Host string } // Plugin is the only integration point between a feature and the hub. diff --git a/internal/store/store.go b/internal/store/store.go index 5cb797b..bf6b482 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -5,6 +5,7 @@ package store import ( "database/sql" "errors" + "strings" "time" _ "modernc.org/sqlite" @@ -45,6 +46,16 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) { return u, nil } +// Message is one member-to-member note in the store-and-forward inbox. +type Message struct { + ID int64 + From string + To string + Body string + Read bool + At time.Time +} + // Score is one leaderboard entry. type Score struct { User string @@ -100,6 +111,21 @@ type Store interface { AddChat(userID int64, username, role, text string) error RecentChats(username string, n int) ([]ChatMessage, error) + // Member-to-member messaging (store-and-forward inbox). + + // SendMessage leaves a note from→to in the recipient's inbox. + SendMessage(from, to, body string) error + // Inbox returns up to n messages addressed to username, newest first. + Inbox(username string, n int) ([]Message, error) + // UnreadCount reports how many unread messages username has waiting. + UnreadCount(username string) (int, error) + // MarkRead marks the given message ids read (scoped to username so a member + // can only clear their own mail). Empty ids is a no-op. + MarkRead(username string, ids []int64) error + // OnlineUsers reports the set of usernames with an open session (no + // ended_at), for the members directory presence dots. + OnlineUsers() (map[string]bool, error) + // Custom domains mapped to a member's homepage (public_html). // MapDomain binds domain→username, returning ErrDomainTaken if it is // already claimed by someone else (re-binding to the same owner is a no-op). @@ -471,6 +497,15 @@ CREATE TABLE IF NOT EXISTS news_articles ( ); CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + from_user TEXT NOT NULL, + to_user TEXT NOT NULL, + body TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { @@ -686,6 +721,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) return out, rows.Err() } +func (s *sqliteStore) SendMessage(from, to, body string) error { + _, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`, + from, to, body) + return err +} + +func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT id, from_user, to_user, body, read, created_at + FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + var m Message + var read int + var at string + if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil { + return nil, err + } + m.Read = read != 0 + m.At, _ = time.Parse(time.RFC3339, at) + out = append(out, m) + } + return out, rows.Err() +} + +func (s *sqliteStore) UnreadCount(username string) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n) + return n, err +} + +func (s *sqliteStore) MarkRead(username string, ids []int64) error { + if len(ids) == 0 { + return nil + } + q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)` + args := make([]any, 0, len(ids)+1) + args = append(args, username) + for _, id := range ids { + args = append(args, id) + } + _, err := s.db.Exec(q, args...) + return err +} + +func (s *sqliteStore) OnlineUsers() (map[string]bool, error) { + rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + online := map[string]bool{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + online[strings.ToLower(name)] = true + } + return online, rows.Err() +} + func (s *sqliteStore) MapDomain(domain, username string) error { var owner string err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner) diff --git a/internal/store/store_messages_test.go b/internal/store/store_messages_test.go new file mode 100644 index 0000000..b5acac6 --- /dev/null +++ b/internal/store/store_messages_test.go @@ -0,0 +1,82 @@ +package store + +import "testing" + +func TestMessagingRoundtrip(t *testing.T) { + st := openTest(t) + + _, _ = st.EnsureUser("alice", "member", "SHA256:aaa") + _, _ = st.EnsureUser("bob", "member", "SHA256:bbb") + + if n, err := st.UnreadCount("bob"); err != nil || n != 0 { + t.Fatalf("fresh unread: n=%d err=%v", n, err) + } + + if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil { + t.Fatalf("send: %v", err) + } + if err := st.SendMessage("alice", "bob", "second note"); err != nil { + t.Fatalf("send2: %v", err) + } + + n, err := st.UnreadCount("bob") + if err != nil || n != 2 { + t.Fatalf("unread after send: n=%d err=%v", n, err) + } + + inbox, err := st.Inbox("bob", 10) + if err != nil { + t.Fatalf("inbox: %v", err) + } + if len(inbox) != 2 { + t.Fatalf("want 2 messages, got %d", len(inbox)) + } + // Newest first. + if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" { + t.Fatalf("unexpected newest message: %+v", inbox[0]) + } + + // Mark only the first read; the other stays unread. + if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil { + t.Fatalf("markread: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("want 1 unread after partial read, got %d", n) + } + + // MarkRead is scoped to the recipient: alice can't clear bob's mail. + if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil { + t.Fatalf("markread other: %v", err) + } + if n, _ := st.UnreadCount("bob"); n != 1 { + t.Fatalf("cross-user markread leaked: unread=%d", n) + } + + // Empty ids is a no-op. + if err := st.MarkRead("bob", nil); err != nil { + t.Fatalf("markread empty: %v", err) + } +} + +func TestOnlineUsers(t *testing.T) { + st := openTest(t) + + u, _ := st.EnsureUser("carol", "member", "SHA256:ccc") + id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub") + + online, err := st.OnlineUsers() + if err != nil { + t.Fatalf("online: %v", err) + } + if !online["carol"] { + t.Fatal("carol should be online while her session is open") + } + + if err := st.EndSession(id); err != nil { + t.Fatalf("end: %v", err) + } + online, _ = st.OnlineUsers() + if online["carol"] { + t.Fatal("carol should be offline after her session ends") + } +} diff --git a/plugins/members/members.go b/plugins/members/members.go new file mode 100644 index 0000000..13d77e3 --- /dev/null +++ b/plugins/members/members.go @@ -0,0 +1,421 @@ +// Package members is the member directory + messaging plugin (the BBS "who" and +// store-and-forward inbox). Members browse who else has an account, see who is +// online now, finger a profile, leave a message, and read their own inbox. +// +// It is members-only (RequiresAuth) — guests have no identity to send from or +// receive to. Messaging is store-and-forward via the store's messages table; +// the same inbox is fed by the `ssh msg@host ` CLI route. +package members + +import ( + "fmt" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/plugin" + "github.com/profullstack/agentbbs/internal/store" +) + +type Plugin struct{} + +func (Plugin) ID() string { return "members" } +func (Plugin) Title() string { return "Members" } +func (Plugin) Description() string { return "Who's here · finger a profile · leave a message · inbox" } +func (Plugin) RequiresAuth() bool { return true } + +func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { + return &model{user: user, ctx: ctx, state: stList} +} + +// state is which sub-screen is showing. +type state int + +const ( + stList state = iota + stProfile + stCompose + stInbox +) + +// person is one directory row. +type person struct { + name string + kind string + online bool + lastSeen time.Time + seenOK bool +} + +type model struct { + user auth.User + ctx plugin.Context + state state + + people []person + inbox []store.Message + cursor int // list/inbox cursor + target string // who we're fingering/composing to + + draft string // compose buffer + note string // transient status line + err error + + width, height int +} + +// --- loading --------------------------------------------------------------- + +type loadedMsg struct { + people []person + err error +} + +func (m *model) load() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + users, err := st.ListUsers(500) + if err != nil { + return loadedMsg{err: err} + } + online, _ := st.OnlineUsers() + out := make([]person, 0, len(users)) + for _, u := range users { + if u.Name == me { + continue // don't list yourself in the directory + } + p := person{name: u.Name, kind: u.Kind, online: online[strings.ToLower(u.Name)]} + if t, ok, _ := st.LastSeen(u.ID); ok { + p.lastSeen, p.seenOK = t, true + } + out = append(out, p) + } + // Online first, then most-recently-seen, then name. + sort.SliceStable(out, func(i, j int) bool { + if out[i].online != out[j].online { + return out[i].online + } + if out[i].seenOK != out[j].seenOK { + return out[i].seenOK + } + if out[i].seenOK && !out[i].lastSeen.Equal(out[j].lastSeen) { + return out[i].lastSeen.After(out[j].lastSeen) + } + return out[i].name < out[j].name + }) + return loadedMsg{people: out} + } +} + +type inboxMsg struct { + msgs []store.Message + err error +} + +func (m *model) loadInbox() tea.Cmd { + st := m.ctx.Store + me := m.user.Name + return func() tea.Msg { + msgs, err := st.Inbox(me, 100) + if err != nil { + return inboxMsg{err: err} + } + // Opening the inbox marks everything read. + var unread []int64 + for _, mm := range msgs { + if !mm.Read { + unread = append(unread, mm.ID) + } + } + _ = st.MarkRead(me, unread) + return inboxMsg{msgs: msgs} + } +} + +type sentMsg struct{ err error } + +func (m *model) send(to, body string) tea.Cmd { + st := m.ctx.Store + from := m.user.Name + return func() tea.Msg { return sentMsg{err: st.SendMessage(from, to, body)} } +} + +func (m *model) Init() tea.Cmd { return m.load() } + +// --- update ---------------------------------------------------------------- + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case loadedMsg: + m.people, m.err = msg.people, msg.err + if m.cursor >= len(m.people) { + m.cursor = 0 + } + return m, nil + case inboxMsg: + m.inbox, m.err = msg.msgs, msg.err + return m, nil + case sentMsg: + if msg.err != nil { + m.note = "send failed: " + msg.err.Error() + } else { + m.note = "✓ message sent to " + m.target + m.state = stProfile + } + m.draft = "" + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.state == stCompose { + return m.composeKey(k) + } + m.note = "" + switch m.state { + case stList: + switch k.String() { + case "q", "esc": + return m, plugin.Exit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.people)-1 { + m.cursor++ + } + case "i": + m.state = stInbox + m.cursor = 0 + return m, m.loadInbox() + case "r": + return m, m.load() + case "enter": + if p := m.selected(); p != nil { + m.target = p.name + m.state = stProfile + } + case "m": + if p := m.selected(); p != nil { + m.target = p.name + m.draft = "" + m.state = stCompose + } + } + case stProfile: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + case "m": + m.draft = "" + m.state = stCompose + } + case stInbox: + switch k.String() { + case "q", "esc", "backspace": + m.state = stList + return m, m.load() // refresh unread badge state + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.inbox)-1 { + m.cursor++ + } + } + } + return m, nil +} + +// composeKey runs the minimal one-line message editor. +func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.Type { + case tea.KeyEsc: + m.state = stProfile + m.draft = "" + return m, nil + case tea.KeyEnter: + body := strings.TrimSpace(m.draft) + if body == "" { + m.note = "type a message first (esc to cancel)" + return m, nil + } + return m, m.send(m.target, body) + case tea.KeyBackspace, tea.KeyDelete: + if n := len(m.draft); n > 0 { + r := []rune(m.draft) + m.draft = string(r[:len(r)-1]) + } + return m, nil + case tea.KeySpace: + m.draft += " " + return m, nil + case tea.KeyRunes: + m.draft += string(k.Runes) + return m, nil + } + return m, nil +} + +func (m *model) selected() *person { + if m.cursor < 0 || m.cursor >= len(m.people) { + return nil + } + return &m.people[m.cursor] +} + +// --- view ------------------------------------------------------------------ + +var ( + hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + sel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) + on = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + off = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + cur = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + frame = lipgloss.NewStyle().Padding(1, 2) +) + +func (m *model) View() string { + var s string + switch m.state { + case stProfile: + s = m.profileView() + case stCompose: + s = m.composeView() + case stInbox: + s = m.inboxView() + default: + s = m.listView() + } + if m.note != "" { + s += "\n" + warn.Render(m.note) + } + return frame.Render(s) +} + +func (m *model) listView() string { + s := hdr.Render("Members") + dim.Render(" · who's here") + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.people) == 0 { + return s + dim.Render("no members yet") + } + for i, p := range m.people { + dot := off.Render("○") + if p.online { + dot = on.Render("●") + } + name := p.name + c := " " + if i == m.cursor { + c = cur.Render("❯ ") + name = sel.Render(name) + } + seen := "online" + if !p.online { + seen = "last " + relTime(p.lastSeen, p.seenOK) + } + row := fmt.Sprintf("%s%s %-20s %-8s %s", c, dot, name, p.kind, dim.Render(seen)) + s += row + "\n" + } + s += "\n" + dim.Render("↑/↓ move · enter finger · m message · i inbox · r refresh · q back") + return s +} + +func (m *model) profileView() string { + p := m.find(m.target) + s := hdr.Render("finger "+m.target) + "\n\n" + if p == nil { + return s + dim.Render("unknown member") + } + status := off.Render("offline") + dim.Render(" · last "+relTime(p.lastSeen, p.seenOK)) + if p.online { + status = on.Render("online now") + } + home := "~" + p.name + if m.ctx.Host != "" { + home = "https://" + m.ctx.Host + "/~" + p.name + } + lines := []string{ + " Login: " + sel.Render(p.name) + " Kind: " + p.kind, + " Status: " + status, + " Home: " + dim.Render(home), + } + s += strings.Join(lines, "\n") + s += "\n\n" + dim.Render("m message "+p.name+" · esc back") + return s +} + +func (m *model) composeView() string { + s := hdr.Render("message "+m.target) + "\n\n" + s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n" + s += " " + m.draft + cur.Render("▏") + "\n\n" + s += dim.Render("enter send · esc cancel") + return s +} + +func (m *model) inboxView() string { + s := hdr.Render("Inbox") + dim.Render(" · "+m.user.Name) + "\n\n" + if m.err != nil { + return s + warn.Render("error: "+m.err.Error()) + } + if len(m.inbox) == 0 { + return s + dim.Render("no messages — select a member and press m to send one") + + "\n\n" + dim.Render("esc back") + } + for i, msg := range m.inbox { + c := " " + from := msg.From + if i == m.cursor { + c = cur.Render("❯ ") + from = sel.Render(from) + } + s += fmt.Sprintf("%s%-16s %s\n", c, from, dim.Render(relTime(msg.At, true)+" ago")) + s += " " + msg.Body + "\n" + } + s += "\n" + dim.Render("↑/↓ scroll · esc back") + return s +} + +func (m *model) find(name string) *person { + for i := range m.people { + if m.people[i].name == name { + return &m.people[i] + } + } + return nil +} + +// relTime renders a coarse "2h", "3d", "just now" style age. ok=false → "never". +func relTime(t time.Time, ok bool) string { + if !ok || t.IsZero() { + return "never" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +}