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:
Anthony Ettinger 2026-06-15 07:48:07 -07:00 committed by GitHub
parent d763d732a3
commit 362b47fdde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 687 additions and 4 deletions

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)