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"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -28,6 +29,8 @@ type fakeDiscourse struct {
topics map[int]topicRec
nextT int
searchFail int
pms []pmRec // private messages, in creation order
lastBody string // raw JSON body of the last POST /posts.json
srv *httptest.Server
}
@@ -43,6 +46,13 @@ type topicRec struct {
Cat int
}
type pmRec struct {
TopicID int
Title string
Targets []string
Raw string
}
func newFake(t *testing.T) *fakeDiscourse {
t.Helper()
f := &fakeDiscourse{
@@ -111,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse {
writeJSON(w, 405, nil)
return
}
var req CreatePostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
body, err := io.ReadAll(r.Body)
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"}})
return
}
f.mu.Lock()
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 strings.TrimSpace(req.Title) == "" || req.Category == 0 {
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)
}
// 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 {
t.Helper()
c, err := New(f.srv.URL, testKey, "")