Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions cmd/agentbbs/broadcast.go
Original file line number Diff line number Diff line change
@@ -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"
}
95 changes: 82 additions & 13 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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).
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions cmd/agentbbs/msg_test.go
Original file line number Diff line number Diff line change
@@ -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
Loading