feat: private-message send via POST /posts.json targets

Client.SendMessage(title, raw, targets) creates a private-message
topic by posting to /posts.json with target_usernames /
target_group_names / target_emails (comma-joined on the wire, as
Discourse expects, never JSON arrays) instead of a category. The
result reuses the topic ids type, so URL() renders the PM link.
Local validation requires title, body and at least one target;
undeliverable recipients surface as the typed ErrForbidden class.
Fake-server tests assert the wire shape and the recorded recipients.

Part of Redmine 507 (Discourse Go client): PM delivery is how the
495 pipeline pings humans that a briefing has landed.
This commit is contained in:
2026-08-29 16:36:54 -05:00
parent 14cdaa0a2d
commit 6891f3febc
3 changed files with 234 additions and 2 deletions
+61 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -28,6 +29,8 @@ type fakeDiscourse struct {
topics map[int]topicRec topics map[int]topicRec
nextT int nextT int
searchFail int searchFail int
pms []pmRec // private messages, in creation order
lastBody string // raw JSON body of the last POST /posts.json
srv *httptest.Server srv *httptest.Server
} }
@@ -43,6 +46,13 @@ type topicRec struct {
Cat int Cat int
} }
type pmRec struct {
TopicID int
Title string
Targets []string
Raw string
}
func newFake(t *testing.T) *fakeDiscourse { func newFake(t *testing.T) *fakeDiscourse {
t.Helper() t.Helper()
f := &fakeDiscourse{ f := &fakeDiscourse{
@@ -111,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse {
writeJSON(w, 405, nil) writeJSON(w, 405, nil)
return return
} }
var req CreatePostRequest body, err := io.ReadAll(r.Body)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err != nil {
writeJSON(w, 400, map[string]any{"errors": []string{"bad body"}})
return
}
var req struct {
CreatePostRequest
TargetUsernames string `json:"target_usernames"`
TargetGroupNames string `json:"target_group_names"`
TargetEmails string `json:"target_emails"`
}
if err := json.Unmarshal(body, &req); err != nil {
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
return return
} }
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.lastBody = string(body)
if req.TargetUsernames != "" || req.TargetGroupNames != "" || req.TargetEmails != "" {
// private-message creation
targets := splitCSV(req.TargetUsernames)
for _, name := range append(append(targets, splitCSV(req.TargetGroupNames)...), splitCSV(req.TargetEmails)...) {
if strings.HasPrefix(name, "denied") {
writeJSON(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
return
}
}
if strings.TrimSpace(req.Title) == "" {
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
return
}
tid := f.nextT
f.nextT++
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
f.pms = append(f.pms, pmRec{TopicID: tid, Title: req.Title, Targets: targets, Raw: req.Raw})
pid := f.nextPost
f.nextPost++
f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw}}
writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
return
}
if req.TopicID == 0 { // topic creation if req.TopicID == 0 { // topic creation
if strings.TrimSpace(req.Title) == "" || req.Category == 0 { if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
@@ -264,6 +308,21 @@ func writeJSON(w http.ResponseWriter, code int, body any) {
_ = json.NewEncoder(w).Encode(body) _ = json.NewEncoder(w).Encode(body)
} }
// splitCSV splits a comma-joined target list ("ops,reachableceo").
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func testClient(t *testing.T, f *fakeDiscourse) *Client { func testClient(t *testing.T, f *fakeDiscourse) *Client {
t.Helper() t.Helper()
c, err := New(f.srv.URL, testKey, "") c, err := New(f.srv.URL, testKey, "")
+77
View File
@@ -0,0 +1,77 @@
// Private messages: sent through the same POST /posts.json endpoint as
// topics, but with target_usernames / target_group_names / target_emails
// instead of a category — Discourse then creates a private-message topic
// (archetype private_message) visible only to sender and recipients.
package discourse
import (
"context"
"fmt"
"strings"
)
// SendMessageRequest sends a private message. Exactly one of the three
// target lists is the common case; any non-empty one works. At least one
// target overall is required — Discourse 422s without one.
type SendMessageRequest struct {
Title string
Raw string
TargetUsernames []string
TargetGroupNames []string
TargetEmails []string
}
// sendMessageWire is the POST /posts.json body: Discourse expects the
// target lists comma-joined strings, not JSON arrays.
type sendMessageWire struct {
Title string `json:"title"`
Raw string `json:"raw"`
TargetUsernames string `json:"target_usernames,omitempty"`
TargetGroupNames string `json:"target_group_names,omitempty"`
TargetEmails string `json:"target_emails,omitempty"`
}
// SendMessageResult is the response: a fresh private-message topic,
// same shape as a topic creation (URL() renders its link).
type SendMessageResult = CreateTopicResult
// SendMessage posts a private message and returns the new PM topic's
// ids. Unknown recipients fail server-side as 403 (ErrForbidden) —
// that is the "not permitted to view the requested resource" case.
func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*SendMessageResult, error) {
if strings.TrimSpace(req.Title) == "" {
return nil, fmt.Errorf("%w: message title is required", ErrInvalidRequest)
}
if strings.TrimSpace(req.Raw) == "" {
return nil, fmt.Errorf("%w: message raw body is required", ErrInvalidRequest)
}
if len(req.TargetUsernames) == 0 && len(req.TargetGroupNames) == 0 && len(req.TargetEmails) == 0 {
return nil, fmt.Errorf("%w: at least one target (username, group or email) is required", ErrInvalidRequest)
}
wire := sendMessageWire{
Title: req.Title,
Raw: req.Raw,
TargetUsernames: strings.Join(cleanList(req.TargetUsernames), ","),
TargetGroupNames: strings.Join(cleanList(req.TargetGroupNames), ","),
TargetEmails: strings.Join(cleanList(req.TargetEmails), ","),
}
var out SendMessageResult
if err := c.Post(ctx, "/posts.json", wire, &out); err != nil {
return nil, err
}
if out.TopicID == 0 {
return nil, fmt.Errorf("%w: message sent but response carries no topic_id", ErrMalformedResponse)
}
return &out, nil
}
// cleanList trims whitespace and drops empties from a target list.
func cleanList(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out
}
+96
View File
@@ -0,0 +1,96 @@
package discourse
import (
"context"
"errors"
"strings"
"testing"
)
func TestSendMessageRequiresTargets(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", Raw: "r"})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest without targets, got %v", err)
}
if f.saw("/posts.json") {
t.Fatal("no request should have been sent without targets")
}
}
func TestSendMessageRequiresTitleAndRaw(t *testing.T) {
c, _ := New("https://ok.example", testKey, "")
if _, err := c.SendMessage(context.Background(), SendMessageRequest{Raw: "r", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing title: want ErrInvalidRequest, got %v", err)
}
if _, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing raw: want ErrInvalidRequest, got %v", err)
}
}
func TestSendMessage(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
res, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "Fleet ping",
Raw: "check the briefing",
TargetUsernames: []string{"ops", "reachableceo"},
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if res.TopicID == 0 || res.TopicSlug == "" {
t.Fatalf("want topic ids in result, got %+v", res)
}
if u := res.URL(f.srv.URL); !strings.Contains(u, "/t/fleet-ping/") {
t.Fatalf("PM URL should be /t/<slug>/<id>, got %q", u)
}
body := f.lastBody
for _, want := range []string{`"title":"Fleet ping"`, `"target_usernames":"ops,reachableceo"`} {
if !strings.Contains(body, want) {
t.Fatalf("wire body missing %s: %s", want, body)
}
}
if strings.Contains(body, `"target_usernames":["`) {
t.Fatalf("targets must be comma-joined, not a JSON array: %s", body)
}
if got := f.pms; len(got) != 1 || got[0].TopicID != res.TopicID || got[0].Title != "Fleet ping" {
t.Fatalf("fake did not record the PM: %+v", got)
}
if got := f.pms[0].Targets; len(got) != 2 || got[0] != "ops" || got[1] != "reachableceo" {
t.Fatalf("recipients not recorded: %+v", got)
}
}
func TestSendMessageGroupAndEmailTargets(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "Bulk",
Raw: "hi",
TargetGroupNames: []string{"staff", "admins"},
TargetEmails: []string{"a@x.test"},
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
for _, want := range []string{`"target_group_names":"staff,admins"`, `"target_emails":"a@x.test"`} {
if !strings.Contains(f.lastBody, want) {
t.Fatalf("wire body missing %s: %s", want, f.lastBody)
}
}
}
func TestSendMessageForbiddenIsTyped(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "nope",
Raw: "x",
TargetUsernames: []string{"denied"},
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("want ErrForbidden for undeliverable recipient, got %v", err)
}
}