mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 23:07:28 +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 qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
|
||||||
// agentbbs notify-creds [flags] (re)email verified members their git +
|
// agentbbs notify-creds [flags] (re)email verified members their git +
|
||||||
// mailbox creds/links (preview unless --send)
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -166,6 +168,10 @@ func main() {
|
||||||
notifyCreds(st, os.Args[2:])
|
notifyCreds(st, os.Args[2:])
|
||||||
return
|
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" {
|
if len(os.Args) > 1 && os.Args[1] == "provision-user" {
|
||||||
provisionUser(st, os.Args[2:])
|
provisionUser(st, os.Args[2:])
|
||||||
return
|
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]`
|
// handleMsg is the member-to-member messaging route. It leaves a note in one or
|
||||||
// leaves a note in <user>'s BBS inbox. The body is the remaining args, or stdin
|
// more members' BBS inboxes:
|
||||||
// when none are given (so `echo hi | ssh msg@host bob` works). Members only;
|
//
|
||||||
// the recipient reads it in the hub's Members ▸ inbox.
|
// 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) {
|
func (a *app) handleMsg(s ssh.Session) {
|
||||||
fp := auth.Fingerprint(s.PublicKey())
|
fp := auth.Fingerprint(s.PublicKey())
|
||||||
if fp == "" {
|
if fp == "" {
|
||||||
|
|
@ -1738,22 +1751,29 @@ func (a *app) handleMsg(s ssh.Session) {
|
||||||
}
|
}
|
||||||
args := s.Command()
|
args := s.Command()
|
||||||
if len(args) == 0 {
|
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)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
to := strings.ToLower(args[0])
|
|
||||||
recipient, ok, err := a.st.UserByName(to)
|
recipients, unknown, err := a.resolveRecipients(args[0], from.Name)
|
||||||
if err != nil || !ok {
|
if err != nil {
|
||||||
wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).")
|
wish.Println(s, "could not look up members: "+err.Error())
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if recipient.Name == from.Name {
|
if len(unknown) > 0 {
|
||||||
wish.Println(s, "you can't message yourself.")
|
wish.Println(s, "no member named "+strings.Join(unknown, ", ")+
|
||||||
|
" — check the spelling (ssh <name>@"+a.host+" to finger).")
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
return
|
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:], " "))
|
body := strings.TrimSpace(strings.Join(args[1:], " "))
|
||||||
if body == "" {
|
if body == "" {
|
||||||
// No inline text — read the message from stdin (piped, or typed then ^D).
|
// 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)
|
_ = s.Exit(1)
|
||||||
return
|
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())
|
wish.Println(s, "could not send: "+err.Error())
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg")
|
_, _ = 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)
|
_ = 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
|
// 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
|
// 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
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
83
docs/messaging.md
Normal file
83
docs/messaging.md
Normal file
|
|
@ -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 <host>`). |
|
||||||
|
| `--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@<host> sysop <reply>`).
|
||||||
|
|
@ -118,6 +118,12 @@ type Store interface {
|
||||||
|
|
||||||
// SendMessage leaves a note from→to in the recipient's inbox.
|
// SendMessage leaves a note from→to in the recipient's inbox.
|
||||||
SendMessage(from, to, body string) error
|
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 returns up to n messages addressed to username, newest first.
|
||||||
Inbox(username string, n int) ([]Message, error)
|
Inbox(username string, n int) ([]Message, error)
|
||||||
// UnreadCount reports how many unread messages username has waiting.
|
// UnreadCount reports how many unread messages username has waiting.
|
||||||
|
|
@ -769,6 +775,38 @@ func (s *sqliteStore) SendMessage(from, to, body string) error {
|
||||||
return err
|
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) {
|
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
n = 50
|
n = 50
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestOnlineUsers(t *testing.T) {
|
||||||
st := openTest(t)
|
st := openTest(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,13 @@ type Plugin struct{}
|
||||||
|
|
||||||
func (Plugin) ID() string { return "members" }
|
func (Plugin) ID() string { return "members" }
|
||||||
func (Plugin) Title() 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) Description() string {
|
||||||
|
return "Who's here · finger a profile · leave a message · inbox"
|
||||||
|
}
|
||||||
func (Plugin) RequiresAuth() bool { return true }
|
func (Plugin) RequiresAuth() bool { return true }
|
||||||
|
|
||||||
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
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.
|
// state is which sub-screen is showing.
|
||||||
|
|
@ -59,7 +61,8 @@ type model struct {
|
||||||
people []person
|
people []person
|
||||||
inbox []store.Message
|
inbox []store.Message
|
||||||
cursor int // list/inbox cursor
|
cursor int // list/inbox cursor
|
||||||
target string // who we're fingering/composing to
|
target string // who we're fingering/composing to (single)
|
||||||
|
marked map[string]bool // group selection (member name → selected)
|
||||||
|
|
||||||
draft string // compose buffer
|
draft string // compose buffer
|
||||||
note string // transient status line
|
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
|
st := m.ctx.Store
|
||||||
from := m.user.Name
|
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() }
|
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 {
|
if msg.err != nil {
|
||||||
m.note = "send failed: " + msg.err.Error()
|
m.note = "send failed: " + msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
|
if msg.n == 1 {
|
||||||
m.note = "✓ message sent to " + m.target
|
m.note = "✓ message sent to " + m.target
|
||||||
m.state = stProfile
|
} 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 = ""
|
m.draft = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -202,13 +252,37 @@ func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, m.loadInbox()
|
return m, m.loadInbox()
|
||||||
case "r":
|
case "r":
|
||||||
return m, m.load()
|
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":
|
case "enter":
|
||||||
if p := m.selected(); p != nil {
|
if p := m.selected(); p != nil {
|
||||||
m.target = p.name
|
m.target = p.name
|
||||||
m.state = stProfile
|
m.state = stProfile
|
||||||
}
|
}
|
||||||
case "m":
|
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.target = p.name
|
||||||
m.draft = ""
|
m.draft = ""
|
||||||
m.state = stCompose
|
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) {
|
func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
|
// Group compose has no single profile to return to.
|
||||||
|
if len(m.marked) > 0 {
|
||||||
|
m.state = stList
|
||||||
|
} else {
|
||||||
m.state = stProfile
|
m.state = stProfile
|
||||||
|
}
|
||||||
m.draft = ""
|
m.draft = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
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)"
|
m.note = "type a message first (esc to cancel)"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, m.send(m.target, body)
|
return m, m.send(body)
|
||||||
case tea.KeyBackspace, tea.KeyDelete:
|
case tea.KeyBackspace, tea.KeyDelete:
|
||||||
if n := len(m.draft); n > 0 {
|
if n := len(m.draft); n > 0 {
|
||||||
r := []rune(m.draft)
|
r := []rune(m.draft)
|
||||||
|
|
@ -321,6 +400,10 @@ func (m *model) listView() string {
|
||||||
if p.online {
|
if p.online {
|
||||||
dot = on.Render("●")
|
dot = on.Render("●")
|
||||||
}
|
}
|
||||||
|
box := dim.Render("[ ]")
|
||||||
|
if m.marked[p.name] {
|
||||||
|
box = on.Render("[x]")
|
||||||
|
}
|
||||||
name := p.name
|
name := p.name
|
||||||
c := " "
|
c := " "
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
|
|
@ -331,10 +414,14 @@ func (m *model) listView() string {
|
||||||
if !p.online {
|
if !p.online {
|
||||||
seen = "last " + relTime(p.lastSeen, p.seenOK)
|
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 += 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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,8 +450,9 @@ func (m *model) profileView() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) composeView() string {
|
func (m *model) composeView() string {
|
||||||
s := hdr.Render("message "+m.target) + "\n\n"
|
to := m.recipientLabel()
|
||||||
s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n"
|
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 += " " + m.draft + cur.Render("▏") + "\n\n"
|
||||||
s += dim.Render("enter send · esc cancel")
|
s += dim.Render("enter send · esc cancel")
|
||||||
return s
|
return s
|
||||||
|
|
|
||||||
79
plugins/members/members_test.go
Normal file
79
plugins/members/members_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue