mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 06:47: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>
72 lines
1.8 KiB
Go
72 lines
1.8 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|