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>
This commit is contained in:
Anthony Ettinger 2026-06-15 14:14:44 +00:00
parent 3b8e664753
commit 580e85431f
6 changed files with 687 additions and 4 deletions

View file

@ -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 <user>` 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 (<name>.<host>), 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-<code> call routes

View file

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

View file

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

View file

@ -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")
}
}