mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue