diff --git a/cmd/agentbbs/broadcast.go b/cmd/agentbbs/broadcast.go new file mode 100644 index 0000000..05d2fef --- /dev/null +++ b/cmd/agentbbs/broadcast.go @@ -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 \")") + 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 .)\n" +} diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 18122a7..65afca4 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -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] 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 [text]` -// leaves a note in '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+" [message] (or pipe the message on stdin)") + wish.Println(s, "usage: ssh msg@"+a.host+" [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 @"+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 diff --git a/cmd/agentbbs/msg_test.go b/cmd/agentbbs/msg_test.go new file mode 100644 index 0000000..6d009bd --- /dev/null +++ b/cmd/agentbbs/msg_test.go @@ -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) + } + } + }) +} diff --git a/docs/messaging.md b/docs/messaging.md new file mode 100644 index 0000000..15394f1 --- /dev/null +++ b/docs/messaging.md @@ -0,0 +1,83 @@ +# Member messaging — direct, group & broadcast + +AgentBBS members leave each other store-and-forward notes (the `messages` table). +Recipients read them in the hub's **Members ▸ inbox**; an unread badge shows on +login. There are two delivery channels: + +- **BBS inbox** — in-BBS, members-only. Any member can message one member, a + hand-picked group, or everyone. This is the default. +- **Email** — reaches members in their actual mailbox. This is a separate, + explicit operator action (`agentbbs broadcast`), so an ordinary group message + never fans out to email. + +## Inbox messaging — the `msg@` route + +```bash +ssh msg@bbs.profullstack.com bob hi there # one member +ssh msg@bbs.profullstack.com alice,bob,carol hi # a group (comma-separated, no spaces) +ssh msg@bbs.profullstack.com all heads up, all # broadcast to every member +echo "long note" | ssh msg@bbs.profullstack.com all # body from stdin +``` + +The first argument is the **recipient spec**; the rest is the message (or stdin +when omitted). The spec is either a comma-separated list of member names, or one +of `all` / `*` / `everyone` / `@all` to reach everyone. The sender, unknown +names, duplicates, and banned accounts are handled for you: you can't message +yourself, an unknown name aborts the send with a hint, and a broadcast skips +banned members. Requires your registered SSH key (members only). + +## Inbox messaging — the hub TUI + +In **Members** (the in-hub directory): + +| Key | Action | +|---|---| +| `↑/↓` | move the cursor | +| `space` | select / deselect the member under the cursor | +| `a` | select all (press again to clear) | +| `m` | message — the selected group if any, else the member under the cursor | +| `enter` | finger (view) the member | +| `i` | open your inbox | +| `q` | back | + +Selected rows show `[x]`; a "`N selected`" line appears once a group is started. +`m` opens a one-line composer whose header names the audience (e.g. `3 members`); +`enter` sends to everyone in the group at once, `esc` cancels. The selection +clears after a successful send. + +All inbox writes for a group/broadcast happen in **one transaction** +(`store.SendMessageMulti`), so a partial failure never leaves some inboxes +written and others not. + +## Email broadcast — `agentbbs broadcast` (operator) + +Reaching every member by **email** (e.g. a maintenance notice) is an operator +command run on the host where the DB and SMTP env live. Like `notify-creds` it is +a **preview by default** and only delivers under `--send`. + +```bash +agentbbs broadcast "We're upgrading the box at 0200 UTC." # PREVIEW (sends nothing) +agentbbs broadcast --send "Heads up: brief downtime tonight." # inbox + email to all +echo "long notice" | agentbbs broadcast --send # body on stdin +agentbbs broadcast --send --no-email "BBS-only notice" # inbox channel only +agentbbs broadcast --send --no-inbox --subject "Outage" "..." # email channel only +agentbbs broadcast --send --user alice,bob "targeted note" # only these members +``` + +| Flag | Effect | +|---|---| +| *(none)* | **Preview** — prints intended deliveries, sends nothing. | +| `--send` | Actually deliver. | +| `--no-inbox` | Skip the BBS inbox channel. | +| `--no-email` | Skip the email channel (inbox-only broadcast). | +| `--from NAME` | Inbox sender name (default `sysop`, or `AGENTBBS_BROADCAST_FROM`). | +| `--subject S` | Email subject (default `Announcement from `). | +| `--user a,b` | Restrict to a comma-separated allow-list (default: all members). | + +The **inbox** channel reaches every non-banned member in scope; the **email** +channel reaches only members with a **verified** address (others are skipped). +`--send` refuses the email channel when SMTP is unconfigured +(`AGENTBBS_SMTP_HOST`/`_FROM`) — use `--no-email` for an inbox-only blast. It +prints a per-channel delivered/failed summary and exits non-zero on any email +failure. The email footer tells members how to reply in-BBS +(`ssh msg@ sysop `). diff --git a/internal/store/store.go b/internal/store/store.go index 7dfb75e..f88cf64 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -118,6 +118,12 @@ type Store interface { // SendMessage leaves a note from→to in the recipient's inbox. SendMessage(from, to, body string) error + // SendMessageMulti delivers one body to every recipient in to, in a single + // transaction (used for group + broadcast messages so a partial failure + // doesn't leave some inboxes written and others not). Duplicate and empty + // recipients are skipped; an empty list is a no-op. Returns the number of + // inboxes written. + SendMessageMulti(from string, to []string, body string) (int, 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. @@ -769,6 +775,38 @@ func (s *sqliteStore) SendMessage(from, to, body string) error { return err } +func (s *sqliteStore) SendMessageMulti(from string, to []string, body string) (int, error) { + if len(to) == 0 { + return 0, nil + } + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() // no-op after a successful Commit + stmt, err := tx.Prepare(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`) + if err != nil { + return 0, err + } + defer stmt.Close() + seen := make(map[string]bool, len(to)) + sent := 0 + for _, recipient := range to { + if recipient == "" || seen[recipient] { + continue + } + seen[recipient] = true + if _, err := stmt.Exec(from, recipient, body); err != nil { + return 0, err + } + sent++ + } + if err := tx.Commit(); err != nil { + return 0, err + } + return sent, nil +} + func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) { if n <= 0 { n = 50 diff --git a/internal/store/store_messages_test.go b/internal/store/store_messages_test.go index b5acac6..7910891 100644 --- a/internal/store/store_messages_test.go +++ b/internal/store/store_messages_test.go @@ -58,6 +58,33 @@ func TestMessagingRoundtrip(t *testing.T) { } } +func TestSendMessageMulti(t *testing.T) { + st := openTest(t) + for _, n := range []string{"alice", "bob", "carol"} { + _, _ = st.EnsureUser(n, "member", "SHA256:"+n) + } + + // Duplicate + empty recipients are skipped; returns the count actually written. + sent, err := st.SendMessageMulti("alice", []string{"bob", "carol", "bob", ""}, "group hello") + if err != nil { + t.Fatalf("multi: %v", err) + } + if sent != 2 { + t.Fatalf("want 2 inboxes written, got %d", sent) + } + for _, who := range []string{"bob", "carol"} { + in, _ := st.Inbox(who, 10) + if len(in) != 1 || in[0].Body != "group hello" || in[0].From != "alice" { + t.Fatalf("%s inbox = %+v", who, in) + } + } + + // Empty recipient list is a no-op. + if sent, err := st.SendMessageMulti("alice", nil, "x"); err != nil || sent != 0 { + t.Fatalf("empty list: sent=%d err=%v", sent, err) + } +} + func TestOnlineUsers(t *testing.T) { st := openTest(t) diff --git a/plugins/members/members.go b/plugins/members/members.go index 13d77e3..08f2a7f 100644 --- a/plugins/members/members.go +++ b/plugins/members/members.go @@ -23,13 +23,15 @@ import ( type Plugin struct{} -func (Plugin) ID() string { return "members" } -func (Plugin) Title() string { return "Members" } -func (Plugin) Description() string { return "Who's here · finger a profile · leave a message · inbox" } -func (Plugin) RequiresAuth() bool { return true } +func (Plugin) ID() string { return "members" } +func (Plugin) Title() string { return "Members" } +func (Plugin) Description() string { + return "Who's here · finger a profile · leave a message · inbox" +} +func (Plugin) RequiresAuth() bool { return true } func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model { - return &model{user: user, ctx: ctx, state: stList} + return &model{user: user, ctx: ctx, state: stList, marked: map[string]bool{}} } // state is which sub-screen is showing. @@ -58,8 +60,9 @@ type model struct { people []person inbox []store.Message - cursor int // list/inbox cursor - target string // who we're fingering/composing to + cursor int // list/inbox cursor + target string // who we're fingering/composing to (single) + marked map[string]bool // group selection (member name → selected) draft string // compose buffer note string // transient status line @@ -137,12 +140,54 @@ func (m *model) loadInbox() tea.Cmd { } } -type sentMsg struct{ err error } +type sentMsg struct { + n int + err error +} -func (m *model) send(to, body string) tea.Cmd { +// send delivers body to the current compose recipients (the group selection if +// any, else the single m.target) in one shot. +func (m *model) send(body string) tea.Cmd { st := m.ctx.Store from := m.user.Name - return func() tea.Msg { return sentMsg{err: st.SendMessage(from, to, body)} } + to := m.recipients() + return func() tea.Msg { + n, err := st.SendMessageMulti(from, to, body) + return sentMsg{n: n, err: err} + } +} + +// recipients is who a compose will be sent to: the group selection (sorted, the +// directory order) when one exists, otherwise just m.target. +func (m *model) recipients() []string { + if len(m.marked) == 0 { + if m.target == "" { + return nil + } + return []string{m.target} + } + var out []string + for _, p := range m.people { + if m.marked[p.name] { + out = append(out, p.name) + } + } + return out +} + +// recipientLabel summarizes the compose audience for the header. +func (m *model) recipientLabel() string { + r := m.recipients() + switch len(r) { + case 0: + return "(nobody)" + case 1: + return r[0] + case 2, 3: + return strings.Join(r, ", ") + default: + return fmt.Sprintf("%d members", len(r)) + } } func (m *model) Init() tea.Cmd { return m.load() } @@ -167,8 +212,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.note = "send failed: " + msg.err.Error() } else { - m.note = "✓ message sent to " + m.target - m.state = stProfile + if msg.n == 1 { + m.note = "✓ message sent to " + m.target + } else { + m.note = fmt.Sprintf("✓ message sent to %d members", msg.n) + } + m.marked = map[string]bool{} // clear the group after sending + m.state = stList } m.draft = "" return m, nil @@ -202,13 +252,37 @@ func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.loadInbox() case "r": return m, m.load() + case " ": + // Toggle the member under the cursor in/out of the group selection. + if p := m.selected(); p != nil { + if m.marked[p.name] { + delete(m.marked, p.name) + } else { + m.marked[p.name] = true + } + } + case "a": + // Select all, or clear if everyone is already selected. + if len(m.marked) == len(m.people) { + m.marked = map[string]bool{} + } else { + m.marked = make(map[string]bool, len(m.people)) + for _, p := range m.people { + m.marked[p.name] = true + } + } case "enter": if p := m.selected(); p != nil { m.target = p.name m.state = stProfile } case "m": - if p := m.selected(); p != nil { + // With a group selected, compose to all of them; otherwise to the + // member under the cursor. + if len(m.marked) > 0 { + m.draft = "" + m.state = stCompose + } else if p := m.selected(); p != nil { m.target = p.name m.draft = "" m.state = stCompose @@ -244,7 +318,12 @@ func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { switch k.Type { case tea.KeyEsc: - m.state = stProfile + // Group compose has no single profile to return to. + if len(m.marked) > 0 { + m.state = stList + } else { + m.state = stProfile + } m.draft = "" return m, nil case tea.KeyEnter: @@ -253,7 +332,7 @@ func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.note = "type a message first (esc to cancel)" return m, nil } - return m, m.send(m.target, body) + return m, m.send(body) case tea.KeyBackspace, tea.KeyDelete: if n := len(m.draft); n > 0 { r := []rune(m.draft) @@ -321,6 +400,10 @@ func (m *model) listView() string { if p.online { dot = on.Render("●") } + box := dim.Render("[ ]") + if m.marked[p.name] { + box = on.Render("[x]") + } name := p.name c := " " if i == m.cursor { @@ -331,10 +414,14 @@ func (m *model) listView() string { if !p.online { seen = "last " + relTime(p.lastSeen, p.seenOK) } - row := fmt.Sprintf("%s%s %-20s %-8s %s", c, dot, name, p.kind, dim.Render(seen)) + row := fmt.Sprintf("%s%s %s %-20s %-8s %s", c, box, dot, name, p.kind, dim.Render(seen)) s += row + "\n" } - s += "\n" + dim.Render("↑/↓ move · enter finger · m message · i inbox · r refresh · q back") + if n := len(m.marked); n > 0 { + s += "\n" + on.Render(fmt.Sprintf("%d selected", n)) + + dim.Render(" — m to message the group") + } + s += "\n" + dim.Render("↑/↓ move · space select · a all · m message · enter finger · i inbox · q back") return s } @@ -363,8 +450,9 @@ func (m *model) profileView() string { } func (m *model) composeView() string { - s := hdr.Render("message "+m.target) + "\n\n" - s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n" + to := m.recipientLabel() + s := hdr.Render("message "+to) + "\n\n" + s += dim.Render("from "+m.user.Name+" → "+to) + "\n\n" s += " " + m.draft + cur.Render("▏") + "\n\n" s += dim.Render("enter send · esc cancel") return s diff --git a/plugins/members/members_test.go b/plugins/members/members_test.go new file mode 100644 index 0000000..0d1f093 --- /dev/null +++ b/plugins/members/members_test.go @@ -0,0 +1,79 @@ +package members + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func key(s string) tea.KeyMsg { + switch s { + case " ": + return tea.KeyMsg{Type: tea.KeySpace} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +func newListModel() *model { + return &model{ + state: stList, + marked: map[string]bool{}, + people: []person{{name: "alice"}, {name: "bob"}, {name: "carol"}}, + } +} + +func TestRecipientsSingleVsGroup(t *testing.T) { + m := newListModel() + + // No selection, single target → just that one. + m.target = "bob" + if got := m.recipients(); len(got) != 1 || got[0] != "bob" { + t.Fatalf("single recipients = %v", got) + } + + // A group selection overrides the single target, in directory order. + m.marked = map[string]bool{"carol": true, "alice": true} + got := m.recipients() + if len(got) != 2 || got[0] != "alice" || got[1] != "carol" { + t.Fatalf("group recipients = %v (want alice,carol in list order)", got) + } +} + +func TestSpaceTogglesAndSelectAll(t *testing.T) { + m := newListModel() + + // Space marks the member under the cursor. + m.cursor = 1 // bob + m.handleKey(key(" ")) + if !m.marked["bob"] || len(m.marked) != 1 { + t.Fatalf("after space: marked = %v", m.marked) + } + // Space again unmarks. + m.handleKey(key(" ")) + if len(m.marked) != 0 { + t.Fatalf("after second space: marked = %v", m.marked) + } + + // 'a' selects everyone; 'a' again clears. + m.handleKey(key("a")) + if len(m.marked) != 3 { + t.Fatalf("after select-all: marked = %v", m.marked) + } + m.handleKey(key("a")) + if len(m.marked) != 0 { + t.Fatalf("after clear-all: marked = %v", m.marked) + } +} + +func TestGroupComposeOpensWithSelection(t *testing.T) { + m := newListModel() + m.marked = map[string]bool{"alice": true, "bob": true} + m.handleKey(key("m")) + if m.state != stCompose { + t.Fatalf("m with a group should open compose, state = %v", m.state) + } + if lbl := m.recipientLabel(); lbl != "alice, bob" { + t.Fatalf("recipient label = %q", lbl) + } +}