mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
79 lines
1.9 KiB
Go
79 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|