agentbbs/plugins/members/members.go
Anthony Ettinger 362b47fdde
Feat/members messaging (#29)
* fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box

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 <noreply@anthropic.com>

* 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 <user> [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 <name>@host) rather than replacing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 07:48:07 -07:00

421 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <user>` 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))
}
}