mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
This commit is contained in:
parent
d763d732a3
commit
362b47fdde
6 changed files with 687 additions and 4 deletions
421
plugins/members/members.go
Normal file
421
plugins/members/members.go
Normal file
|
|
@ -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 <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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue