mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
feat(messaging): group & broadcast messages + email-all
Members can now message a group or everyone, and the operator can announce to
the whole membership by email.
Inbox channel (any member):
- msg@ recipient spec accepts a comma list (alice,bob,carol) or all/*/everyone
to broadcast: `ssh msg@host alice,bob hi`, `ssh msg@host all hi`.
- Members hub TUI gains multi-select: space toggles, `a` selects all, `m`
messages the selected group (header names the audience); selection clears
after send.
- store.SendMessageMulti delivers one body to many inboxes in a single
transaction (dedupes, skips empties); resolveRecipients validates names,
excludes the sender, and skips banned members on broadcast.
Email channel (operator, explicit):
- new `agentbbs broadcast` subcommand sends an announcement to ALL members via
inbox + email. Preview by default (like notify-creds); --send delivers;
--no-inbox/--no-email pick a channel; --subject/--from/--user refine it.
Email reaches only verified addresses and refuses --send without SMTP.
Tests: SendMessageMulti (dedupe/empty), resolveRecipients (list/unknown/all
tokens, banned + sender exclusion), TUI selection (toggle/select-all/group
compose). Docs: docs/messaging.md. build/vet/gofmt/`go test ./...` green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
54da317f4e
commit
103ddeb7c9
8 changed files with 658 additions and 32 deletions
170
cmd/agentbbs/broadcast.go
Normal file
170
cmd/agentbbs/broadcast.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package main
|
||||
|
||||
// broadcast sends one announcement to the whole membership over BOTH channels:
|
||||
// - inbox: a store-and-forward message in every member's BBS inbox (Members ▸
|
||||
// inbox), the same place msg@ delivers to.
|
||||
// - email: a plain-text email to every member with a verified address.
|
||||
//
|
||||
// It is the explicit "mail all users" action — distinct from the per-member /
|
||||
// group msg@ route, so a normal group message never fans out to email. It is an
|
||||
// OPERATOR command (run on the host where the DB + env live); members broadcast
|
||||
// to inboxes with `ssh msg@host all`, but reaching everyone by email goes through
|
||||
// here.
|
||||
//
|
||||
// Like notify-creds it is a PREVIEW by default (scans + prints what it would do,
|
||||
// sends nothing) and only acts under --send.
|
||||
//
|
||||
// agentbbs broadcast "We're upgrading the box at 0200 UTC." # preview
|
||||
// agentbbs broadcast --send "Heads up: ..." # inbox + email
|
||||
// echo "long notice" | agentbbs broadcast --send # body on stdin
|
||||
// agentbbs broadcast --send --no-email "BBS-only notice" # inbox only
|
||||
// agentbbs broadcast --send --no-inbox --subject "Outage" "..." # email only
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/mail"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
func broadcastCmd(st store.Store, args []string) {
|
||||
fs := flag.NewFlagSet("broadcast", flag.ExitOnError)
|
||||
send := fs.Bool("send", false, "actually deliver (default: preview only)")
|
||||
noInbox := fs.Bool("no-inbox", false, "skip the BBS inbox channel")
|
||||
noEmail := fs.Bool("no-email", false, "skip the email channel")
|
||||
from := fs.String("from", env("AGENTBBS_BROADCAST_FROM", "sysop"), "inbox sender name (from_user)")
|
||||
subject := fs.String("subject", "", "email subject (default: \"Announcement from <host>\")")
|
||||
only := fs.String("user", "", "comma-separated usernames to target (default: all members)")
|
||||
fs.Parse(args)
|
||||
|
||||
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||
if strings.TrimSpace(*subject) == "" {
|
||||
*subject = "Announcement from " + host
|
||||
}
|
||||
|
||||
// Body: remaining args joined, else stdin.
|
||||
body := strings.TrimSpace(strings.Join(fs.Args(), " "))
|
||||
if body == "" {
|
||||
b, _ := io.ReadAll(bufio.NewReader(os.Stdin))
|
||||
body = strings.TrimSpace(string(b))
|
||||
}
|
||||
if body == "" {
|
||||
fmt.Fprintln(os.Stderr, "empty message — nothing to broadcast (pass text as args or on stdin)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
doInbox, doEmail := !*noInbox, !*noEmail
|
||||
if !doInbox && !doEmail {
|
||||
fmt.Fprintln(os.Stderr, "both channels disabled — nothing to do")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Optional username allow-list.
|
||||
var want map[string]bool
|
||||
if strings.TrimSpace(*only) != "" {
|
||||
want = map[string]bool{}
|
||||
for _, n := range strings.Split(*only, ",") {
|
||||
if n = strings.ToLower(strings.TrimSpace(n)); n != "" {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users, err := st.ListUsers(100000)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "list users:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
smtp := mail.ConfigFromEnv()
|
||||
if doEmail && *send && !smtp.Configured() {
|
||||
fmt.Fprintln(os.Stderr, "error: SMTP not configured (AGENTBBS_SMTP_HOST/_FROM) — cannot send email "+
|
||||
"(use --no-email for an inbox-only broadcast)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !*send {
|
||||
fmt.Println("PREVIEW (nothing delivered) — re-run with --send to broadcast")
|
||||
}
|
||||
fmt.Printf("from %q · subject %q · channels: %s\n", *from, *subject, channelLabel(doInbox, doEmail))
|
||||
|
||||
// Collect the inbox recipient list (all non-banned members in scope), and
|
||||
// count/send email per verified member.
|
||||
var inboxNames []string
|
||||
var emailTargets, emailOK, emailErr int
|
||||
for _, u := range users {
|
||||
if u.Banned {
|
||||
continue
|
||||
}
|
||||
if want != nil && !want[strings.ToLower(u.Name)] {
|
||||
continue
|
||||
}
|
||||
|
||||
if doInbox {
|
||||
inboxNames = append(inboxNames, u.Name)
|
||||
}
|
||||
|
||||
if doEmail {
|
||||
if !u.EmailVerified || u.Email == "" {
|
||||
continue
|
||||
}
|
||||
emailTargets++
|
||||
if !*send {
|
||||
fmt.Printf(" [email] %-20s -> %s\n", u.Name, u.Email)
|
||||
} else if err := smtp.Send(u.Email, *subject, broadcastEmailBody(u.Name, body, host)); err != nil {
|
||||
emailErr++
|
||||
fmt.Fprintf(os.Stderr, " [email] %s: %v\n", u.Name, err)
|
||||
} else {
|
||||
emailOK++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var inboxSent int
|
||||
if doInbox {
|
||||
if !*send {
|
||||
fmt.Printf(" [inbox] would deliver to %d member(s)\n", len(inboxNames))
|
||||
} else if inboxSent, err = st.SendMessageMulti(*from, inboxNames, body); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " [inbox] send:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if *send {
|
||||
if doInbox {
|
||||
fmt.Printf("inbox: %d delivered\n", inboxSent)
|
||||
}
|
||||
if doEmail {
|
||||
fmt.Printf("email: %d sent, %d failed (of %d verified)\n", emailOK, emailErr, emailTargets)
|
||||
}
|
||||
if emailErr > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%d member(s) in scope.\n", len(inboxNames))
|
||||
}
|
||||
}
|
||||
|
||||
func channelLabel(inbox, email bool) string {
|
||||
switch {
|
||||
case inbox && email:
|
||||
return "inbox + email"
|
||||
case inbox:
|
||||
return "inbox only"
|
||||
default:
|
||||
return "email only"
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastEmailBody(name, body, host string) string {
|
||||
return "Hi " + name + ",\n\n" +
|
||||
body + "\n\n" +
|
||||
"— " + host + "\n\n" +
|
||||
"(This is a broadcast to all members. Reply in the BBS: ssh msg@" + host + " sysop <your reply>.)\n"
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@
|
|||
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
|
||||
// agentbbs notify-creds [flags] (re)email verified members their git +
|
||||
// mailbox creds/links (preview unless --send)
|
||||
// agentbbs broadcast [flags] <text> announce to ALL members via inbox + email
|
||||
// (preview unless --send; see broadcast.go)
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -166,6 +168,10 @@ func main() {
|
|||
notifyCreds(st, os.Args[2:])
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "broadcast" {
|
||||
broadcastCmd(st, os.Args[2:])
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "provision-user" {
|
||||
provisionUser(st, os.Args[2:])
|
||||
return
|
||||
|
|
@ -1719,10 +1725,17 @@ func (a *app) handleChat(s ssh.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// handleMsg is the member-to-member messaging route: `ssh msg@host <user> [text]`
|
||||
// leaves a note in <user>'s BBS inbox. The body is the remaining args, or stdin
|
||||
// when none are given (so `echo hi | ssh msg@host bob` works). Members only;
|
||||
// the recipient reads it in the hub's Members ▸ inbox.
|
||||
// handleMsg is the member-to-member messaging route. It leaves a note in one or
|
||||
// more members' BBS inboxes:
|
||||
//
|
||||
// ssh msg@host bob hi one member
|
||||
// ssh msg@host alice,bob,carol hi a group (comma-separated, no spaces)
|
||||
// ssh msg@host all hi broadcast to every member (also: *, everyone)
|
||||
//
|
||||
// The body is the remaining args, or stdin when none are given (so
|
||||
// `echo hi | ssh msg@host all` works). Members only; recipients read it in the
|
||||
// hub's Members ▸ inbox. This is the BBS-inbox channel — to also reach members by
|
||||
// email, the operator uses `agentbbs broadcast`.
|
||||
func (a *app) handleMsg(s ssh.Session) {
|
||||
fp := auth.Fingerprint(s.PublicKey())
|
||||
if fp == "" {
|
||||
|
|
@ -1738,22 +1751,29 @@ func (a *app) handleMsg(s ssh.Session) {
|
|||
}
|
||||
args := s.Command()
|
||||
if len(args) == 0 {
|
||||
wish.Println(s, "usage: ssh msg@"+a.host+" <user> [message] (or pipe the message on stdin)")
|
||||
wish.Println(s, "usage: ssh msg@"+a.host+" <user[,user2,…]|all> [message] (or pipe the message on stdin)")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
to := strings.ToLower(args[0])
|
||||
recipient, ok, err := a.st.UserByName(to)
|
||||
if err != nil || !ok {
|
||||
wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).")
|
||||
|
||||
recipients, unknown, err := a.resolveRecipients(args[0], from.Name)
|
||||
if err != nil {
|
||||
wish.Println(s, "could not look up members: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if recipient.Name == from.Name {
|
||||
wish.Println(s, "you can't message yourself.")
|
||||
if len(unknown) > 0 {
|
||||
wish.Println(s, "no member named "+strings.Join(unknown, ", ")+
|
||||
" — check the spelling (ssh <name>@"+a.host+" to finger).")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
wish.Println(s, "no recipients (you can't message only yourself).")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(strings.Join(args[1:], " "))
|
||||
if body == "" {
|
||||
// No inline text — read the message from stdin (piped, or typed then ^D).
|
||||
|
|
@ -1765,16 +1785,65 @@ func (a *app) handleMsg(s ssh.Session) {
|
|||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if err := a.st.SendMessage(from.Name, recipient.Name, body); err != nil {
|
||||
|
||||
sent, err := a.st.SendMessageMulti(from.Name, recipients, body)
|
||||
if err != nil {
|
||||
wish.Println(s, "could not send: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
_, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg")
|
||||
wish.Println(s, "✓ message left for "+recipient.Name+" — they'll see it in Members ▸ inbox.")
|
||||
if sent == 1 {
|
||||
wish.Println(s, "✓ message left for "+recipients[0]+" — they'll see it in Members ▸ inbox.")
|
||||
} else {
|
||||
wish.Println(s, fmt.Sprintf("✓ message left for %d members — they'll see it in Members ▸ inbox.", sent))
|
||||
}
|
||||
_ = s.Exit(0)
|
||||
}
|
||||
|
||||
// allRecipientTokens are the case-insensitive specs that mean "every member".
|
||||
var allRecipientTokens = map[string]bool{"all": true, "*": true, "everyone": true, "@all": true}
|
||||
|
||||
// resolveRecipients turns a msg@ recipient spec into a validated, de-duplicated
|
||||
// list of member names, excluding the sender. The spec is either one of
|
||||
// allRecipientTokens (→ every other member) or a comma-separated list of names.
|
||||
// unknown holds any listed names that aren't members (so the caller can report
|
||||
// them); a non-nil error means the directory lookup itself failed.
|
||||
func (a *app) resolveRecipients(spec, sender string) (recipients, unknown []string, err error) {
|
||||
if allRecipientTokens[strings.ToLower(strings.TrimSpace(spec))] {
|
||||
users, err := a.st.ListUsers(100000)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Banned || strings.EqualFold(u.Name, sender) {
|
||||
continue
|
||||
}
|
||||
recipients = append(recipients, u.Name)
|
||||
}
|
||||
return recipients, nil, nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range strings.Split(spec, ",") {
|
||||
name := strings.ToLower(strings.TrimSpace(raw))
|
||||
if name == "" || seen[name] || strings.EqualFold(name, sender) {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
u, ok, err := a.st.UserByName(name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !ok {
|
||||
unknown = append(unknown, name)
|
||||
continue
|
||||
}
|
||||
recipients = append(recipients, u.Name)
|
||||
}
|
||||
return recipients, unknown, nil
|
||||
}
|
||||
|
||||
// handlePasswd is the self-service "reset my password everywhere" route. It is
|
||||
// gated by the caller's registered SSH key (so it doubles as the forgot-password
|
||||
// path — no old password needed) and sets ONE new password across every service
|
||||
|
|
|
|||
72
cmd/agentbbs/msg_test.go
Normal file
72
cmd/agentbbs/msg_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestResolveRecipients(t *testing.T) {
|
||||
st := testStore(t)
|
||||
for _, n := range []string{"alice", "bob", "carol", "dave"} {
|
||||
if _, err := st.EnsureUser(n, "member", "SHA256:"+n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Ban dave so "all" skips him.
|
||||
u, _, _ := st.UserByName("dave")
|
||||
if err := st.SetBanned(u.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &app{st: st}
|
||||
|
||||
t.Run("comma list dedupes, lowercases, excludes sender", func(t *testing.T) {
|
||||
got, unknown, err := a.resolveRecipients("Bob,carol,bob,alice", "alice")
|
||||
if err != nil || len(unknown) != 0 {
|
||||
t.Fatalf("err=%v unknown=%v", err, unknown)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
|
||||
t.Fatalf("recipients = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown names are reported", func(t *testing.T) {
|
||||
got, unknown, err := a.resolveRecipients("bob,nobody", "alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "bob" {
|
||||
t.Fatalf("recipients = %v", got)
|
||||
}
|
||||
if len(unknown) != 1 || unknown[0] != "nobody" {
|
||||
t.Fatalf("unknown = %v", unknown)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all expands to every non-banned member except sender", func(t *testing.T) {
|
||||
for _, tok := range []string{"all", "ALL", "*", "everyone", "@all"} {
|
||||
got, _, err := a.resolveRecipients(tok, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tok, err)
|
||||
}
|
||||
sort.Strings(got)
|
||||
// alice excluded (sender), dave excluded (banned) → bob, carol.
|
||||
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
|
||||
t.Fatalf("%s → %v", tok, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue